Compare commits
142 Commits
fix/magist
...
feat/provi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc48aa4349 | ||
|
|
773d8dd4c3 | ||
|
|
e7a1a43856 | ||
|
|
7a0da13676 | ||
|
|
267b41242d | ||
|
|
5bbc35695a | ||
|
|
eac71f1f43 | ||
|
|
bd4ba47e61 | ||
|
|
cd2d59c6a1 | ||
|
|
5e31c809e1 | ||
|
|
961984df24 | ||
|
|
e956a9ad35 | ||
|
|
f9869ef453 | ||
|
|
7bb3826cdd | ||
|
|
0af5a85f67 | ||
|
|
3d7a64a11d | ||
|
|
548916e6e1 | ||
|
|
ffa2eb57b1 | ||
|
|
fd7d2b7580 | ||
|
|
57702f545d | ||
|
|
1764be8a30 | ||
|
|
e90b9a5a95 | ||
|
|
a398010213 | ||
|
|
c49201f365 | ||
|
|
070614cd3c | ||
|
|
cce88745c2 | ||
|
|
4b02878390 | ||
|
|
2633a1429a | ||
|
|
b2e33f892a | ||
|
|
8925d7d546 | ||
|
|
56cec26858 | ||
|
|
107c01913d | ||
|
|
6d102ccef8 | ||
|
|
fba358c0fc | ||
|
|
17cee98617 | ||
|
|
d6866052c4 | ||
|
|
3be7c2e1a8 | ||
|
|
375f966e9a | ||
|
|
4833f36e0b | ||
|
|
35968f4861 | ||
|
|
e3ca927306 | ||
|
|
c2aff60127 | ||
|
|
ae203b5c7c | ||
|
|
6a4627cddc | ||
|
|
f66cb2651f | ||
|
|
a4cdb5d45f | ||
|
|
3501d377f6 | ||
|
|
b4a3a483e9 | ||
|
|
76c025d53b | ||
|
|
cd1b0e01a0 | ||
|
|
44b2d09e63 | ||
|
|
c7dcbdcb5b | ||
|
|
daaf685c9e | ||
|
|
9c2a88179b | ||
|
|
a2d24a5cda | ||
|
|
4191d878f2 | ||
|
|
1c0e29f029 | ||
|
|
25d3b519d9 | ||
|
|
39b1332e49 | ||
|
|
0da122281e | ||
|
|
4615e97ad5 | ||
|
|
4dabc214f2 | ||
|
|
ea6a1752e7 | ||
|
|
062b3b0a33 | ||
|
|
c5d8ec9c1a | ||
|
|
1af4a2686b | ||
|
|
174b9bdc3d | ||
|
|
84212d0b1d | ||
|
|
6e9b77a97a | ||
|
|
c93b96a03f | ||
|
|
a671f95bee | ||
|
|
0e750c64db | ||
|
|
27eef50b9f | ||
|
|
8297546ed7 | ||
|
|
4e54733d38 | ||
|
|
bd9b34b9a0 | ||
|
|
b1e843973c | ||
|
|
11b130736c | ||
|
|
25531ecd76 | ||
|
|
332ba5d678 | ||
|
|
1da1721ec2 | ||
|
|
f8120c2ebb | ||
|
|
cdca8c0ed7 | ||
|
|
4f2b1e23a9 | ||
|
|
47f49532c6 | ||
|
|
cffaf99b17 | ||
|
|
973ece9eb9 | ||
|
|
a21fc91915 | ||
|
|
80dfcf05a7 | ||
|
|
0368583cfc | ||
|
|
c5554995dd | ||
|
|
70cc1c4a32 | ||
|
|
2ace9ba492 | ||
|
|
cc8915842a | ||
|
|
2e2cfc2409 | ||
|
|
2265ecab21 | ||
|
|
29d4e37f6b | ||
|
|
e0bc3bb2c5 | ||
|
|
6d602d5d48 | ||
|
|
1e7718162d | ||
|
|
e3c52a6174 | ||
|
|
585e49ac65 | ||
|
|
86545f4fff | ||
|
|
b57ec9fe70 | ||
|
|
b96af0fdef | ||
|
|
b0ea7ad71c | ||
|
|
c8c0d22787 | ||
|
|
263166c9d1 | ||
|
|
f3884af4b9 | ||
|
|
9a4200ac1a | ||
|
|
32d5f7477a | ||
|
|
ecf1f816c3 | ||
|
|
f9056b0680 | ||
|
|
afae33d588 | ||
|
|
0b8c6ee536 | ||
|
|
e652c1d783 | ||
|
|
aed9566409 | ||
|
|
33ec5c5c6b | ||
|
|
b53a5aa3af | ||
|
|
635bc084b7 | ||
|
|
f0bd6c97fa | ||
|
|
13a834ceaa | ||
|
|
ded941b7b9 | ||
|
|
535dcf4778 | ||
|
|
4dad2a593b | ||
|
|
8b5a3f734c | ||
|
|
b3643944f3 | ||
|
|
e2e8ded2c0 | ||
|
|
72d0fea3a1 | ||
|
|
62a6a0a8be | ||
|
|
04326eba21 | ||
|
|
a02b4b3955 | ||
|
|
e0dbd2d2db | ||
|
|
4a62bb6ad7 | ||
|
|
748ac600fa | ||
|
|
c2561726e0 | ||
|
|
f2b7b07e51 | ||
|
|
d1e19aad51 | ||
|
|
5d34e49c57 | ||
|
|
bef0180e4c | ||
|
|
31e59ab395 | ||
|
|
37dccd93e9 |
15
.github/workflows/pr-ci.yml
vendored
15
.github/workflows/pr-ci.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Pull Request CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
@@ -42,8 +45,14 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Build Check
|
||||
run: yarn build:check
|
||||
|
||||
- name: Lint Check
|
||||
run: yarn test:lint
|
||||
|
||||
- name: Type Check
|
||||
run: yarn typecheck
|
||||
|
||||
- name: i18n Check
|
||||
run: yarn check:i18n
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
47
.vscode/launch.json
vendored
47
.vscode/launch.json
vendored
@@ -1,39 +1,40 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"compounds": [
|
||||
{
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"name": "Debug All",
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--inspect", "--sourcemap"],
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
},
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"name": "Debug Main Process",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["--inspect", "--sourcemap"],
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"type": "node",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"port": 9222,
|
||||
"request": "attach",
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer",
|
||||
"timeout": 3000000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
"request": "attach",
|
||||
"timeout": 3000000,
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
"version": "0.2.0"
|
||||
}
|
||||
|
||||
348
.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch
vendored
Normal file
348
.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch
vendored
Normal file
@@ -0,0 +1,348 @@
|
||||
diff --git a/src/constants/languages.d.ts b/src/constants/languages.d.ts
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..6a2ba5086187622b8ca8887bcc7406018fba8a89
|
||||
--- /dev/null
|
||||
+++ b/src/constants/languages.d.ts
|
||||
@@ -0,0 +1,43 @@
|
||||
+/**
|
||||
+ * Languages with existing tesseract traineddata
|
||||
+ * https://tesseract-ocr.github.io/tessdoc/Data-Files#data-files-for-version-400-november-29-2016
|
||||
+ */
|
||||
+
|
||||
+// Define the language codes as string literals
|
||||
+type LanguageCode =
|
||||
+ | 'afr' | 'amh' | 'ara' | 'asm' | 'aze' | 'aze_cyrl' | 'bel' | 'ben' | 'bod' | 'bos'
|
||||
+ | 'bul' | 'cat' | 'ceb' | 'ces' | 'chi_sim' | 'chi_tra' | 'chr' | 'cym' | 'dan' | 'deu'
|
||||
+ | 'dzo' | 'ell' | 'eng' | 'enm' | 'epo' | 'est' | 'eus' | 'fas' | 'fin' | 'fra'
|
||||
+ | 'frk' | 'frm' | 'gle' | 'glg' | 'grc' | 'guj' | 'hat' | 'heb' | 'hin' | 'hrv'
|
||||
+ | 'hun' | 'iku' | 'ind' | 'isl' | 'ita' | 'ita_old' | 'jav' | 'jpn' | 'kan' | 'kat'
|
||||
+ | 'kat_old' | 'kaz' | 'khm' | 'kir' | 'kor' | 'kur' | 'lao' | 'lat' | 'lav' | 'lit'
|
||||
+ | 'mal' | 'mar' | 'mkd' | 'mlt' | 'msa' | 'mya' | 'nep' | 'nld' | 'nor' | 'ori'
|
||||
+ | 'pan' | 'pol' | 'por' | 'pus' | 'ron' | 'rus' | 'san' | 'sin' | 'slk' | 'slv'
|
||||
+ | 'spa' | 'spa_old' | 'sqi' | 'srp' | 'srp_latn' | 'swa' | 'swe' | 'syr' | 'tam' | 'tel'
|
||||
+ | 'tgk' | 'tgl' | 'tha' | 'tir' | 'tur' | 'uig' | 'ukr' | 'urd' | 'uzb' | 'uzb_cyrl'
|
||||
+ | 'vie' | 'yid';
|
||||
+
|
||||
+// Define the language keys as string literals
|
||||
+type LanguageKey =
|
||||
+ | 'AFR' | 'AMH' | 'ARA' | 'ASM' | 'AZE' | 'AZE_CYRL' | 'BEL' | 'BEN' | 'BOD' | 'BOS'
|
||||
+ | 'BUL' | 'CAT' | 'CEB' | 'CES' | 'CHI_SIM' | 'CHI_TRA' | 'CHR' | 'CYM' | 'DAN' | 'DEU'
|
||||
+ | 'DZO' | 'ELL' | 'ENG' | 'ENM' | 'EPO' | 'EST' | 'EUS' | 'FAS' | 'FIN' | 'FRA'
|
||||
+ | 'FRK' | 'FRM' | 'GLE' | 'GLG' | 'GRC' | 'GUJ' | 'HAT' | 'HEB' | 'HIN' | 'HRV'
|
||||
+ | 'HUN' | 'IKU' | 'IND' | 'ISL' | 'ITA' | 'ITA_OLD' | 'JAV' | 'JPN' | 'KAN' | 'KAT'
|
||||
+ | 'KAT_OLD' | 'KAZ' | 'KHM' | 'KIR' | 'KOR' | 'KUR' | 'LAO' | 'LAT' | 'LAV' | 'LIT'
|
||||
+ | 'MAL' | 'MAR' | 'MKD' | 'MLT' | 'MSA' | 'MYA' | 'NEP' | 'NLD' | 'NOR' | 'ORI'
|
||||
+ | 'PAN' | 'POL' | 'POR' | 'PUS' | 'RON' | 'RUS' | 'SAN' | 'SIN' | 'SLK' | 'SLV'
|
||||
+ | 'SPA' | 'SPA_OLD' | 'SQI' | 'SRP' | 'SRP_LATN' | 'SWA' | 'SWE' | 'SYR' | 'TAM' | 'TEL'
|
||||
+ | 'TGK' | 'TGL' | 'THA' | 'TIR' | 'TUR' | 'UIG' | 'UKR' | 'URD' | 'UZB' | 'UZB_CYRL'
|
||||
+ | 'VIE' | 'YID';
|
||||
+
|
||||
+// Create a mapped type to ensure each key maps to its specific value
|
||||
+type LanguagesMap = {
|
||||
+ [K in LanguageKey]: LanguageCode;
|
||||
+};
|
||||
+
|
||||
+// Declare the exported constant with the specific type
|
||||
+export const LANGUAGES: LanguagesMap;
|
||||
+
|
||||
+// Export the individual types for use in other modules
|
||||
+export type { LanguageCode, LanguageKey, LanguagesMap };
|
||||
\ No newline at end of file
|
||||
diff --git a/src/index.d.ts b/src/index.d.ts
|
||||
index 1f5a9c8094fe4de7983467f9efb43bdb4de535f2..16dc95cf68663673e37e189b719cb74897b7735f 100644
|
||||
--- a/src/index.d.ts
|
||||
+++ b/src/index.d.ts
|
||||
@@ -1,31 +1,74 @@
|
||||
+// Import the languages types
|
||||
+import { LanguagesMap } from "./constants/languages";
|
||||
+
|
||||
+/// <reference types="node" />
|
||||
+
|
||||
declare namespace Tesseract {
|
||||
- function createScheduler(): Scheduler
|
||||
- function createWorker(langs?: string | string[] | Lang[], oem?: OEM, options?: Partial<WorkerOptions>, config?: string | Partial<InitOptions>): Promise<Worker>
|
||||
- function setLogging(logging: boolean): void
|
||||
- function recognize(image: ImageLike, langs?: string, options?: Partial<WorkerOptions>): Promise<RecognizeResult>
|
||||
- function detect(image: ImageLike, options?: Partial<WorkerOptions>): any
|
||||
+ function createScheduler(): Scheduler;
|
||||
+ function createWorker(
|
||||
+ langs?: LanguageCode | LanguageCode[] | Lang[],
|
||||
+ oem?: OEM,
|
||||
+ options?: Partial<WorkerOptions>,
|
||||
+ config?: string | Partial<InitOptions>
|
||||
+ ): Promise<Worker>;
|
||||
+ function setLogging(logging: boolean): void;
|
||||
+ function recognize(
|
||||
+ image: ImageLike,
|
||||
+ langs?: LanguageCode,
|
||||
+ options?: Partial<WorkerOptions>
|
||||
+ ): Promise<RecognizeResult>;
|
||||
+ function detect(image: ImageLike, options?: Partial<WorkerOptions>): any;
|
||||
+
|
||||
+ // Export languages constant
|
||||
+ const languages: LanguagesMap;
|
||||
+
|
||||
+ type LanguageCode = import("./constants/languages").LanguageCode;
|
||||
+ type LanguageKey = import("./constants/languages").LanguageKey;
|
||||
|
||||
interface Scheduler {
|
||||
- addWorker(worker: Worker): string
|
||||
- addJob(action: 'recognize', ...args: Parameters<Worker['recognize']>): Promise<RecognizeResult>
|
||||
- addJob(action: 'detect', ...args: Parameters<Worker['detect']>): Promise<DetectResult>
|
||||
- terminate(): Promise<any>
|
||||
- getQueueLen(): number
|
||||
- getNumWorkers(): number
|
||||
+ addWorker(worker: Worker): string;
|
||||
+ addJob(
|
||||
+ action: "recognize",
|
||||
+ ...args: Parameters<Worker["recognize"]>
|
||||
+ ): Promise<RecognizeResult>;
|
||||
+ addJob(
|
||||
+ action: "detect",
|
||||
+ ...args: Parameters<Worker["detect"]>
|
||||
+ ): Promise<DetectResult>;
|
||||
+ terminate(): Promise<any>;
|
||||
+ getQueueLen(): number;
|
||||
+ getNumWorkers(): number;
|
||||
}
|
||||
|
||||
interface Worker {
|
||||
- load(jobId?: string): Promise<ConfigResult>
|
||||
- writeText(path: string, text: string, jobId?: string): Promise<ConfigResult>
|
||||
- readText(path: string, jobId?: string): Promise<ConfigResult>
|
||||
- removeText(path: string, jobId?: string): Promise<ConfigResult>
|
||||
- FS(method: string, args: any[], jobId?: string): Promise<ConfigResult>
|
||||
- reinitialize(langs?: string | Lang[], oem?: OEM, config?: string | Partial<InitOptions>, jobId?: string): Promise<ConfigResult>
|
||||
- setParameters(params: Partial<WorkerParams>, jobId?: string): Promise<ConfigResult>
|
||||
- getImage(type: imageType): string
|
||||
- recognize(image: ImageLike, options?: Partial<RecognizeOptions>, output?: Partial<OutputFormats>, jobId?: string): Promise<RecognizeResult>
|
||||
- detect(image: ImageLike, jobId?: string): Promise<DetectResult>
|
||||
- terminate(jobId?: string): Promise<ConfigResult>
|
||||
+ load(jobId?: string): Promise<ConfigResult>;
|
||||
+ writeText(
|
||||
+ path: string,
|
||||
+ text: string,
|
||||
+ jobId?: string
|
||||
+ ): Promise<ConfigResult>;
|
||||
+ readText(path: string, jobId?: string): Promise<ConfigResult>;
|
||||
+ removeText(path: string, jobId?: string): Promise<ConfigResult>;
|
||||
+ FS(method: string, args: any[], jobId?: string): Promise<ConfigResult>;
|
||||
+ reinitialize(
|
||||
+ langs?: string | Lang[],
|
||||
+ oem?: OEM,
|
||||
+ config?: string | Partial<InitOptions>,
|
||||
+ jobId?: string
|
||||
+ ): Promise<ConfigResult>;
|
||||
+ setParameters(
|
||||
+ params: Partial<WorkerParams>,
|
||||
+ jobId?: string
|
||||
+ ): Promise<ConfigResult>;
|
||||
+ getImage(type: imageType): string;
|
||||
+ recognize(
|
||||
+ image: ImageLike,
|
||||
+ options?: Partial<RecognizeOptions>,
|
||||
+ output?: Partial<OutputFormats>,
|
||||
+ jobId?: string
|
||||
+ ): Promise<RecognizeResult>;
|
||||
+ detect(image: ImageLike, jobId?: string): Promise<DetectResult>;
|
||||
+ terminate(jobId?: string): Promise<ConfigResult>;
|
||||
}
|
||||
|
||||
interface Lang {
|
||||
@@ -34,43 +77,43 @@ declare namespace Tesseract {
|
||||
}
|
||||
|
||||
interface InitOptions {
|
||||
- load_system_dawg: string
|
||||
- load_freq_dawg: string
|
||||
- load_unambig_dawg: string
|
||||
- load_punc_dawg: string
|
||||
- load_number_dawg: string
|
||||
- load_bigram_dawg: string
|
||||
- }
|
||||
-
|
||||
- type LoggerMessage = {
|
||||
- jobId: string
|
||||
- progress: number
|
||||
- status: string
|
||||
- userJobId: string
|
||||
- workerId: string
|
||||
+ load_system_dawg: string;
|
||||
+ load_freq_dawg: string;
|
||||
+ load_unambig_dawg: string;
|
||||
+ load_punc_dawg: string;
|
||||
+ load_number_dawg: string;
|
||||
+ load_bigram_dawg: string;
|
||||
}
|
||||
-
|
||||
+
|
||||
+ type LoggerMessage = {
|
||||
+ jobId: string;
|
||||
+ progress: number;
|
||||
+ status: string;
|
||||
+ userJobId: string;
|
||||
+ workerId: string;
|
||||
+ };
|
||||
+
|
||||
interface WorkerOptions {
|
||||
- corePath: string
|
||||
- langPath: string
|
||||
- cachePath: string
|
||||
- dataPath: string
|
||||
- workerPath: string
|
||||
- cacheMethod: string
|
||||
- workerBlobURL: boolean
|
||||
- gzip: boolean
|
||||
- legacyLang: boolean
|
||||
- legacyCore: boolean
|
||||
- logger: (arg: LoggerMessage) => void,
|
||||
- errorHandler: (arg: any) => void
|
||||
+ corePath: string;
|
||||
+ langPath: string;
|
||||
+ cachePath: string;
|
||||
+ dataPath: string;
|
||||
+ workerPath: string;
|
||||
+ cacheMethod: string;
|
||||
+ workerBlobURL: boolean;
|
||||
+ gzip: boolean;
|
||||
+ legacyLang: boolean;
|
||||
+ legacyCore: boolean;
|
||||
+ logger: (arg: LoggerMessage) => void;
|
||||
+ errorHandler: (arg: any) => void;
|
||||
}
|
||||
interface WorkerParams {
|
||||
- tessedit_pageseg_mode: PSM
|
||||
- tessedit_char_whitelist: string
|
||||
- tessedit_char_blacklist: string
|
||||
- preserve_interword_spaces: string
|
||||
- user_defined_dpi: string
|
||||
- [propName: string]: any
|
||||
+ tessedit_pageseg_mode: PSM;
|
||||
+ tessedit_char_whitelist: string;
|
||||
+ tessedit_char_blacklist: string;
|
||||
+ preserve_interword_spaces: string;
|
||||
+ user_defined_dpi: string;
|
||||
+ [propName: string]: any;
|
||||
}
|
||||
interface OutputFormats {
|
||||
text: boolean;
|
||||
@@ -88,36 +131,36 @@ declare namespace Tesseract {
|
||||
debug: boolean;
|
||||
}
|
||||
interface RecognizeOptions {
|
||||
- rectangle: Rectangle
|
||||
- pdfTitle: string
|
||||
- pdfTextOnly: boolean
|
||||
- rotateAuto: boolean
|
||||
- rotateRadians: number
|
||||
+ rectangle: Rectangle;
|
||||
+ pdfTitle: string;
|
||||
+ pdfTextOnly: boolean;
|
||||
+ rotateAuto: boolean;
|
||||
+ rotateRadians: number;
|
||||
}
|
||||
interface ConfigResult {
|
||||
- jobId: string
|
||||
- data: any
|
||||
+ jobId: string;
|
||||
+ data: any;
|
||||
}
|
||||
interface RecognizeResult {
|
||||
- jobId: string
|
||||
- data: Page
|
||||
+ jobId: string;
|
||||
+ data: Page;
|
||||
}
|
||||
interface DetectResult {
|
||||
- jobId: string
|
||||
- data: DetectData
|
||||
+ jobId: string;
|
||||
+ data: DetectData;
|
||||
}
|
||||
interface DetectData {
|
||||
- tesseract_script_id: number | null
|
||||
- script: string | null
|
||||
- script_confidence: number | null
|
||||
- orientation_degrees: number | null
|
||||
- orientation_confidence: number | null
|
||||
+ tesseract_script_id: number | null;
|
||||
+ script: string | null;
|
||||
+ script_confidence: number | null;
|
||||
+ orientation_degrees: number | null;
|
||||
+ orientation_confidence: number | null;
|
||||
}
|
||||
interface Rectangle {
|
||||
- left: number
|
||||
- top: number
|
||||
- width: number
|
||||
- height: number
|
||||
+ left: number;
|
||||
+ top: number;
|
||||
+ width: number;
|
||||
+ height: number;
|
||||
}
|
||||
enum OEM {
|
||||
TESSERACT_ONLY,
|
||||
@@ -126,28 +169,36 @@ declare namespace Tesseract {
|
||||
DEFAULT,
|
||||
}
|
||||
enum PSM {
|
||||
- OSD_ONLY = '0',
|
||||
- AUTO_OSD = '1',
|
||||
- AUTO_ONLY = '2',
|
||||
- AUTO = '3',
|
||||
- SINGLE_COLUMN = '4',
|
||||
- SINGLE_BLOCK_VERT_TEXT = '5',
|
||||
- SINGLE_BLOCK = '6',
|
||||
- SINGLE_LINE = '7',
|
||||
- SINGLE_WORD = '8',
|
||||
- CIRCLE_WORD = '9',
|
||||
- SINGLE_CHAR = '10',
|
||||
- SPARSE_TEXT = '11',
|
||||
- SPARSE_TEXT_OSD = '12',
|
||||
- RAW_LINE = '13'
|
||||
+ OSD_ONLY = "0",
|
||||
+ AUTO_OSD = "1",
|
||||
+ AUTO_ONLY = "2",
|
||||
+ AUTO = "3",
|
||||
+ SINGLE_COLUMN = "4",
|
||||
+ SINGLE_BLOCK_VERT_TEXT = "5",
|
||||
+ SINGLE_BLOCK = "6",
|
||||
+ SINGLE_LINE = "7",
|
||||
+ SINGLE_WORD = "8",
|
||||
+ CIRCLE_WORD = "9",
|
||||
+ SINGLE_CHAR = "10",
|
||||
+ SPARSE_TEXT = "11",
|
||||
+ SPARSE_TEXT_OSD = "12",
|
||||
+ RAW_LINE = "13",
|
||||
}
|
||||
const enum imageType {
|
||||
COLOR = 0,
|
||||
GREY = 1,
|
||||
- BINARY = 2
|
||||
+ BINARY = 2,
|
||||
}
|
||||
- type ImageLike = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement
|
||||
- | CanvasRenderingContext2D | File | Blob | Buffer | OffscreenCanvas;
|
||||
+ type ImageLike =
|
||||
+ | string
|
||||
+ | HTMLImageElement
|
||||
+ | HTMLCanvasElement
|
||||
+ | HTMLVideoElement
|
||||
+ | CanvasRenderingContext2D
|
||||
+ | File
|
||||
+ | Blob
|
||||
+ | (typeof Buffer extends undefined ? never : Buffer)
|
||||
+ | OffscreenCanvas;
|
||||
interface Block {
|
||||
paragraphs: Paragraph[];
|
||||
text: string;
|
||||
@@ -179,7 +230,7 @@ declare namespace Tesseract {
|
||||
text: string;
|
||||
confidence: number;
|
||||
baseline: Baseline;
|
||||
- rowAttributes: RowAttributes
|
||||
+ rowAttributes: RowAttributes;
|
||||
bbox: Bbox;
|
||||
}
|
||||
interface Paragraph {
|
||||
@@ -116,9 +116,11 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
支持 GPT-5 模型
|
||||
新增代码工具,支持快速启动 Qwen Code, Gemini Cli, Claude Code
|
||||
翻译页面改版,支持更多设置
|
||||
支持保存整个话题到知识库
|
||||
坚果云备份支持设置最大备份数量
|
||||
输入框快捷菜单增加清除按钮
|
||||
侧边栏增加代码工具入口,代码工具增加环境变量设置
|
||||
小程序增加多语言显示
|
||||
优化 MCP 服务器列表
|
||||
新增 Web 搜索图标
|
||||
优化 SVG 预览,优化 HTML 内容样式
|
||||
修复知识库文档预处理失败问题
|
||||
稳定性改进和错误修复
|
||||
|
||||
50
package.json
50
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.5.6",
|
||||
"version": "1.5.7-rc.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -78,7 +78,9 @@
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"selection-hook": "^1.0.8",
|
||||
"selection-hook": "^1.0.11",
|
||||
"sharp": "^0.34.3",
|
||||
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"turndown": "7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -103,7 +105,10 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
@@ -145,7 +150,7 @@
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^18.19.9",
|
||||
"@types/node": "^22.17.1",
|
||||
"@types/pako": "^1.0.2",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -153,9 +158,9 @@
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/word-extractor": "^1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.14",
|
||||
"@uiw/codemirror-themes-all": "^4.23.14",
|
||||
"@uiw/react-codemirror": "^4.23.14",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.1",
|
||||
"@uiw/codemirror-themes-all": "^4.25.1",
|
||||
"@uiw/react-codemirror": "^4.25.1",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
@@ -164,7 +169,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "patch:antd@npm%3A5.26.7#~/.yarn/patches/antd-npm-5.26.7-029c5c381a.patch",
|
||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.7.3",
|
||||
@@ -180,7 +185,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "37.2.3",
|
||||
"electron": "37.3.1",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-store": "^8.2.0",
|
||||
@@ -204,6 +209,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"isbinaryfile": "5.0.4",
|
||||
"jaison": "^2.0.2",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
"linguist-languages": "^8.0.0",
|
||||
@@ -225,9 +231,9 @@
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
@@ -243,7 +249,9 @@
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^7.1.0",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-cjk-friendly": "^1.2.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-github-blockquote-alert": "^2.0.0",
|
||||
@@ -270,24 +278,28 @@
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"word-extractor": "^1.0.4",
|
||||
"zipread": "^1.3.3",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"resolutions": {
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"@codemirror/language": "6.11.3",
|
||||
"@codemirror/lint": "6.8.5",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch",
|
||||
"node-abi": "4.12.0",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"node-abi": "4.12.0",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch"
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -35,6 +35,7 @@ export enum IpcChannel {
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
App_LogToMain = 'app:log-to-main',
|
||||
App_SaveData = 'app:save-data',
|
||||
App_SetFullScreen = 'app:set-full-screen',
|
||||
|
||||
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
|
||||
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
|
||||
@@ -155,7 +156,9 @@ export enum IpcChannel {
|
||||
File_Base64File = 'file:base64File',
|
||||
File_GetPdfInfo = 'file:getPdfInfo',
|
||||
Fs_Read = 'fs:read',
|
||||
Fs_ReadText = 'fs:readText',
|
||||
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
||||
File_IsTextFile = 'file:isTextFile',
|
||||
|
||||
// file service
|
||||
FileService_Upload = 'file-service:upload',
|
||||
@@ -279,5 +282,8 @@ export enum IpcChannel {
|
||||
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
|
||||
|
||||
// CodeTools
|
||||
CodeTools_Run = 'code-tools:run'
|
||||
CodeTools_Run = 'code-tools:run',
|
||||
|
||||
// OCR
|
||||
OCR_ocr = 'ocr:ocr'
|
||||
}
|
||||
|
||||
@@ -211,3 +211,10 @@ export const MIN_WINDOW_WIDTH = 1080
|
||||
export const SECOND_MIN_WINDOW_WIDTH = 520
|
||||
export const MIN_WINDOW_HEIGHT = 600
|
||||
export const defaultByPassRules = 'localhost,127.0.0.1,::1'
|
||||
|
||||
export enum codeTools {
|
||||
qwenCode = 'qwen-code',
|
||||
claudeCode = 'claude-code',
|
||||
geminiCli = 'gemini-cli',
|
||||
openaiCodex = 'openai-codex'
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isDev, isWin } from '@main/constant'
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
if (isDev) {
|
||||
app.setPath('userData', app.getPath('userData') + 'Dev')
|
||||
@@ -11,7 +11,7 @@ export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
color: isWin ? 'rgba(0,0,0,0.02)' : 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic
|
||||
import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
@@ -71,7 +72,7 @@ const dxtService = new DxtService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
const notificationService = new NotificationService()
|
||||
|
||||
// Initialize Python service with main window
|
||||
pythonService.setMainWindow(mainWindow)
|
||||
@@ -191,6 +192,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetFullScreen, (_, value: boolean): void => {
|
||||
mainWindow.setFullScreen(value)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
@@ -440,6 +445,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
|
||||
|
||||
// file service
|
||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||
@@ -464,6 +470,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// fs
|
||||
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService))
|
||||
ipcMain.handle(IpcChannel.Fs_ReadText, FileService.readTextFileWithAutoEncoding.bind(FileService))
|
||||
|
||||
// export
|
||||
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
|
||||
@@ -704,4 +711,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// CodeTools
|
||||
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
|
||||
|
||||
// OCR
|
||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export default abstract class BasePreprocessProvider {
|
||||
}
|
||||
|
||||
public async readPdf(buffer: Buffer) {
|
||||
const pdfDoc = await PDFDocument.load(buffer)
|
||||
const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true })
|
||||
return {
|
||||
numPages: pdfDoc.getPageCount()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
import { net } from 'electron'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
@@ -38,19 +38,24 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
}
|
||||
|
||||
private async validateFile(filePath: string): Promise<void> {
|
||||
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||
// 首先检查文件大小,避免读取大文件到内存
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
const fileSizeBytes = stats.size
|
||||
|
||||
// 文件大小小于300MB
|
||||
if (fileSizeBytes >= 300 * 1024 * 1024) {
|
||||
const fileSizeMB = Math.round(fileSizeBytes / (1024 * 1024))
|
||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
|
||||
}
|
||||
|
||||
// 只有在文件大小合理的情况下才读取文件内容检查页数
|
||||
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||
const doc = await this.readPdf(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 }> {
|
||||
@@ -160,11 +165,23 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
* @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)
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.provider.apiKey}`
|
||||
},
|
||||
body: null
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ApiResponse<PreuploadResponse>
|
||||
|
||||
if (data.code === 'success' && data.data) {
|
||||
return data.data
|
||||
@@ -178,17 +195,29 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* 上传文件(使用流式上传)
|
||||
* @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)
|
||||
// 获取文件大小用于设置 Content-Length
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
const fileSize = stats.size
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
|
||||
// 创建可读流
|
||||
const fileStream = fs.createReadStream(filePath)
|
||||
|
||||
const response = await net.fetch(url, {
|
||||
method: 'PUT',
|
||||
body: fileStream as any, // TypeScript 类型转换,net.fetch 支持 ReadableStream
|
||||
headers: {
|
||||
'Content-Length': fileSize.toString()
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
@@ -197,16 +226,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
}
|
||||
|
||||
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)
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.provider.apiKey}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.code === 'success' && response.data.data) {
|
||||
return response.data.data
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ApiResponse<StatusResponse>
|
||||
if (data.code === 'success' && data.data) {
|
||||
return data.data
|
||||
} else {
|
||||
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
|
||||
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
@@ -221,13 +259,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
*/
|
||||
private async convertFile(uid: string, filePath: string): Promise<void> {
|
||||
const fileName = path.parse(filePath).name
|
||||
const config = {
|
||||
...this.createAuthConfig(),
|
||||
headers: {
|
||||
...this.createAuthConfig().headers,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
uid,
|
||||
@@ -239,10 +270,22 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse`
|
||||
|
||||
try {
|
||||
const response = await axios.post<ApiResponse<any>>(endpoint, payload, config)
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.provider.apiKey}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.data.code !== 'success') {
|
||||
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ApiResponse<any>
|
||||
if (data.code !== 'success') {
|
||||
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
@@ -256,16 +299,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
* @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)
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.provider.apiKey}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.status === 200 && response.data.data) {
|
||||
return response.data.data
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ApiResponse<ParsedFileResponse>
|
||||
if (data.data) {
|
||||
return data.data
|
||||
} else {
|
||||
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
|
||||
throw new Error(`No data in response`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -295,8 +347,12 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
try {
|
||||
// 下载文件
|
||||
const response = await axios.get(url, { responseType: 'arraybuffer' })
|
||||
fs.writeFileSync(zipPath, response.data)
|
||||
const response = await net.fetch(url, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
|
||||
|
||||
// 确保提取目录存在
|
||||
if (!fs.existsSync(extractPath)) {
|
||||
@@ -318,14 +374,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private createAuthConfig(): AxiosRequestConfig {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.provider.apiKey}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public checkQuota(): Promise<number> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios from 'axios'
|
||||
import { net } from 'electron'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
@@ -95,7 +95,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
public async checkQuota() {
|
||||
try {
|
||||
const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, {
|
||||
const quota = await net.fetch(`${this.provider.apiHost}/api/v4/quota`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -179,8 +179,12 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
try {
|
||||
// 下载ZIP文件
|
||||
const response = await axios.get(zipUrl, { responseType: 'arraybuffer' })
|
||||
fs.writeFileSync(zipPath, Buffer.from(response.data))
|
||||
const response = await net.fetch(zipUrl, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
|
||||
logger.info(`Downloaded ZIP file: ${zipPath}`)
|
||||
|
||||
// 确保提取目录存在
|
||||
@@ -236,7 +240,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -271,7 +275,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
const response = await net.fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: fileBuffer,
|
||||
headers: {
|
||||
@@ -316,7 +320,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}`
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
import { net } from 'electron'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@@ -15,7 +15,17 @@ export default class GeneralReranker extends BaseReranker {
|
||||
const requestBody = this.getRerankRequestBody(query, searchResults)
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
const response = await net.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders(),
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const rerankResults = this.extractRerankResult(data)
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { net } from 'electron'
|
||||
|
||||
const WEB_SEARCH_TOOL: Tool = {
|
||||
name: 'brave_web_search',
|
||||
@@ -159,7 +160,7 @@ async function performWebSearch(apiKey: string, query: string, count: number = 1
|
||||
url.searchParams.set('count', Math.min(count, 20).toString()) // API limit
|
||||
url.searchParams.set('offset', offset.toString())
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await net.fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
@@ -192,7 +193,7 @@ async function performLocalSearch(apiKey: string, query: string, count: number =
|
||||
webUrl.searchParams.set('result_filter', 'locations')
|
||||
webUrl.searchParams.set('count', Math.min(count, 20).toString())
|
||||
|
||||
const webResponse = await fetch(webUrl, {
|
||||
const webResponse = await net.fetch(webUrl.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
@@ -225,7 +226,7 @@ async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiRespo
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/local/pois')
|
||||
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
|
||||
const response = await fetch(url, {
|
||||
const response = await net.fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
@@ -244,7 +245,7 @@ async function getDescriptionsData(apiKey: string, ids: string[]): Promise<Brave
|
||||
checkRateLimit()
|
||||
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions')
|
||||
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
|
||||
const response = await fetch(url, {
|
||||
const response = await net.fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { net } from 'electron'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
const logger = loggerService.withContext('DifyKnowledgeServer')
|
||||
@@ -134,7 +135,7 @@ class DifyKnowledgeServer {
|
||||
private async performListKnowledges(difyKey: string, apiHost: string): Promise<McpResponse> {
|
||||
try {
|
||||
const url = `${apiHost.replace(/\/$/, '')}/datasets`
|
||||
const response = await fetch(url, {
|
||||
const response = await net.fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${difyKey}`
|
||||
@@ -186,7 +187,7 @@ class DifyKnowledgeServer {
|
||||
try {
|
||||
const url = `${apiHost.replace(/\/$/, '')}/datasets/${id}/retrieve`
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await net.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${difyKey}`,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { net } from 'electron'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import TurndownService from 'turndown'
|
||||
import { z } from 'zod/v3'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const RequestPayloadSchema = z.object({
|
||||
url: z.string().url(),
|
||||
@@ -16,7 +17,7 @@ export type RequestPayload = z.infer<typeof RequestPayloadSchema>
|
||||
export class Fetcher {
|
||||
private static async _fetch({ url, headers }: RequestPayload): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await net.fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
|
||||
@@ -6,9 +6,10 @@ import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import { app, BrowserWindow, dialog, net } from 'electron'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||
import path from 'path'
|
||||
import semver from 'semver'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
@@ -44,12 +45,6 @@ export default class AppUpdater {
|
||||
|
||||
// 检测到不需要更新时
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
if (configManager.getTestPlan() && this.autoUpdater.channel !== UpgradeChannel.LATEST) {
|
||||
logger.info('test plan is enabled, but update is not available, do not send update not available event')
|
||||
// will not send update not available event, because will check for updates with latest channel
|
||||
return
|
||||
}
|
||||
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateNotAvailable)
|
||||
})
|
||||
|
||||
@@ -72,18 +67,24 @@ export default class AppUpdater {
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) {
|
||||
private async _getReleaseVersionFromGithub(channel: UpgradeChannel) {
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
try {
|
||||
logger.info(`get pre release version from github: ${channel}`)
|
||||
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
logger.info(`get release version from github: ${channel}`)
|
||||
const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
|
||||
headers
|
||||
})
|
||||
const data = (await responses.json()) as GithubReleaseInfo[]
|
||||
let mightHaveLatest = false
|
||||
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
|
||||
if (!item.draft && !item.prerelease) {
|
||||
mightHaveLatest = true
|
||||
}
|
||||
|
||||
return item.prerelease && item.tag_name.includes(`-${channel}.`)
|
||||
})
|
||||
|
||||
@@ -91,8 +92,29 @@ export default class AppUpdater {
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`prerelease url is ${release.tag_name}, set channel to ${channel}`)
|
||||
// if the release version is the same as the current version, return null
|
||||
if (release.tag_name === app.getVersion()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (mightHaveLatest) {
|
||||
logger.info(`might have latest release, get latest release`)
|
||||
const latestReleaseResponse = await net.fetch(
|
||||
'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest',
|
||||
{
|
||||
headers
|
||||
}
|
||||
)
|
||||
const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo
|
||||
if (semver.gt(latestRelease.tag_name, release.tag_name)) {
|
||||
logger.info(
|
||||
`latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`release url is ${release.tag_name}, set channel to ${channel}`)
|
||||
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
|
||||
} catch (error) {
|
||||
logger.error('Failed to get latest not draft version from github:', error as Error)
|
||||
@@ -151,14 +173,14 @@ export default class AppUpdater {
|
||||
return
|
||||
}
|
||||
|
||||
const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel)
|
||||
if (preReleaseUrl) {
|
||||
logger.info(`prerelease url is ${preReleaseUrl}, set channel to ${channel}`)
|
||||
this._setChannel(channel, preReleaseUrl)
|
||||
const releaseUrl = await this._getReleaseVersionFromGithub(channel)
|
||||
if (releaseUrl) {
|
||||
logger.info(`release url is ${releaseUrl}, set channel to ${channel}`)
|
||||
this._setChannel(channel, releaseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// if no prerelease url, use github latest to avoid error
|
||||
// if no prerelease url, use github latest to get release
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
||||
return
|
||||
}
|
||||
@@ -195,17 +217,6 @@ export default class AppUpdater {
|
||||
`update check result: ${this.updateCheckResult?.isUpdateAvailable}, channel: ${this.autoUpdater.channel}, currentVersion: ${this.autoUpdater.currentVersion}`
|
||||
)
|
||||
|
||||
// if the update is not available, and the test plan is enabled, set the feed url to the github latest
|
||||
if (
|
||||
!this.updateCheckResult?.isUpdateAvailable &&
|
||||
configManager.getTestPlan() &&
|
||||
this.autoUpdater.channel !== UpgradeChannel.LATEST
|
||||
) {
|
||||
logger.info('test plan is enabled, but update is not available, set channel to latest')
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
||||
this.updateCheckResult = await this.autoUpdater.checkForUpdates()
|
||||
}
|
||||
|
||||
if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
|
||||
// 如果 autoDownload 为 false,则需要再调用下面的函数触发下
|
||||
// do not use await, because it will block the return of this function
|
||||
|
||||
@@ -21,6 +21,27 @@ class BackupManager {
|
||||
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
|
||||
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
|
||||
|
||||
// 缓存实例,避免重复创建
|
||||
private s3Storage: S3Storage | null = null
|
||||
private webdavInstance: WebDav | null = null
|
||||
|
||||
// 缓存核心连接配置,用于检测连接配置是否变更
|
||||
private cachedS3ConnectionConfig: {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
root?: string
|
||||
} | null = null
|
||||
|
||||
private cachedWebdavConnectionConfig: {
|
||||
webdavHost: string
|
||||
webdavUser?: string
|
||||
webdavPass?: string
|
||||
webdavPath?: string
|
||||
} | null = null
|
||||
|
||||
constructor() {
|
||||
this.checkConnection = this.checkConnection.bind(this)
|
||||
this.backup = this.backup.bind(this)
|
||||
@@ -87,6 +108,88 @@ class BackupManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个配置对象是否相等,只比较影响客户端连接的核心字段,忽略 fileName 等易变字段
|
||||
*/
|
||||
private isS3ConfigEqual(cachedConfig: typeof this.cachedS3ConnectionConfig, config: S3Config): boolean {
|
||||
if (!cachedConfig) return false
|
||||
|
||||
return (
|
||||
cachedConfig.endpoint === config.endpoint &&
|
||||
cachedConfig.region === config.region &&
|
||||
cachedConfig.bucket === config.bucket &&
|
||||
cachedConfig.accessKeyId === config.accessKeyId &&
|
||||
cachedConfig.secretAccessKey === config.secretAccessKey &&
|
||||
cachedConfig.root === config.root
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度比较两个 WebDAV 配置对象是否相等,只比较影响客户端连接的核心字段,忽略 fileName 等易变字段
|
||||
*/
|
||||
private isWebDavConfigEqual(cachedConfig: typeof this.cachedWebdavConnectionConfig, config: WebDavConfig): boolean {
|
||||
if (!cachedConfig) return false
|
||||
|
||||
return (
|
||||
cachedConfig.webdavHost === config.webdavHost &&
|
||||
cachedConfig.webdavUser === config.webdavUser &&
|
||||
cachedConfig.webdavPass === config.webdavPass &&
|
||||
cachedConfig.webdavPath === config.webdavPath
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 S3Storage 实例,如果连接配置未变且实例已存在则复用,否则创建新实例
|
||||
* 注意:只有连接相关的配置变更才会重新创建实例,其他配置变更不影响实例复用
|
||||
*/
|
||||
private getS3Storage(config: S3Config): S3Storage {
|
||||
// 检查核心连接配置是否变更
|
||||
const configChanged = !this.isS3ConfigEqual(this.cachedS3ConnectionConfig, config)
|
||||
|
||||
if (configChanged || !this.s3Storage) {
|
||||
this.s3Storage = new S3Storage(config)
|
||||
// 只缓存连接相关的配置字段
|
||||
this.cachedS3ConnectionConfig = {
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
bucket: config.bucket,
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
root: config.root
|
||||
}
|
||||
logger.debug('[BackupManager] Created new S3Storage instance')
|
||||
} else {
|
||||
logger.debug('[BackupManager] Reusing existing S3Storage instance')
|
||||
}
|
||||
|
||||
return this.s3Storage
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebDav 实例,如果连接配置未变且实例已存在则复用,否则创建新实例
|
||||
* 注意:只有连接相关的配置变更才会重新创建实例,其他配置变更不影响实例复用
|
||||
*/
|
||||
private getWebDavInstance(config: WebDavConfig): WebDav {
|
||||
// 检查核心连接配置是否变更
|
||||
const configChanged = !this.isWebDavConfigEqual(this.cachedWebdavConnectionConfig, config)
|
||||
|
||||
if (configChanged || !this.webdavInstance) {
|
||||
this.webdavInstance = new WebDav(config)
|
||||
// 只缓存连接相关的配置字段
|
||||
this.cachedWebdavConnectionConfig = {
|
||||
webdavHost: config.webdavHost,
|
||||
webdavUser: config.webdavUser,
|
||||
webdavPass: config.webdavPass,
|
||||
webdavPath: config.webdavPath
|
||||
}
|
||||
logger.debug('[BackupManager] Created new WebDav instance')
|
||||
} else {
|
||||
logger.debug('[BackupManager] Reusing existing WebDav instance')
|
||||
}
|
||||
|
||||
return this.webdavInstance
|
||||
}
|
||||
|
||||
async backup(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
@@ -322,7 +425,7 @@ class BackupManager {
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const webdavClient = this.getWebDavInstance(webdavConfig)
|
||||
try {
|
||||
let result
|
||||
if (webdavConfig.disableStream) {
|
||||
@@ -349,7 +452,7 @@ class BackupManager {
|
||||
|
||||
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const webdavClient = this.getWebDavInstance(webdavConfig)
|
||||
try {
|
||||
const retrievedFile = await webdavClient.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
@@ -377,7 +480,7 @@ class BackupManager {
|
||||
|
||||
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
|
||||
try {
|
||||
const client = new WebDav(config)
|
||||
const client = this.getWebDavInstance(config)
|
||||
const response = await client.getDirectoryContents()
|
||||
const files = Array.isArray(response) ? response : response.data
|
||||
|
||||
@@ -467,7 +570,7 @@ class BackupManager {
|
||||
}
|
||||
|
||||
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const webdavClient = this.getWebDavInstance(webdavConfig)
|
||||
return await webdavClient.checkConnection()
|
||||
}
|
||||
|
||||
@@ -477,13 +580,13 @@ class BackupManager {
|
||||
path: string,
|
||||
options?: CreateDirectoryOptions
|
||||
) {
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const webdavClient = this.getWebDavInstance(webdavConfig)
|
||||
return await webdavClient.createDirectory(path, options)
|
||||
}
|
||||
|
||||
async deleteWebdavFile(_: Electron.IpcMainInvokeEvent, fileName: string, webdavConfig: WebDavConfig) {
|
||||
try {
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const webdavClient = this.getWebDavInstance(webdavConfig)
|
||||
return await webdavClient.deleteFile(fileName)
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to delete WebDAV file:', error)
|
||||
@@ -525,7 +628,7 @@ class BackupManager {
|
||||
logger.debug(`Starting S3 backup to ${filename}`)
|
||||
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
const s3Client = this.getS3Storage(s3Config)
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(backupedFilePath)
|
||||
const result = await s3Client.putFileContents(filename, fileBuffer)
|
||||
@@ -603,7 +706,7 @@ class BackupManager {
|
||||
|
||||
logger.debug(`Starting restore from S3: ${filename}`)
|
||||
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
const s3Client = this.getS3Storage(s3Config)
|
||||
try {
|
||||
const retrievedFile = await s3Client.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
@@ -628,7 +731,7 @@ class BackupManager {
|
||||
|
||||
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
|
||||
try {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
const s3Client = this.getS3Storage(s3Config)
|
||||
|
||||
const objects = await s3Client.listFiles()
|
||||
const files = objects
|
||||
@@ -652,7 +755,7 @@ class BackupManager {
|
||||
|
||||
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
|
||||
try {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
const s3Client = this.getS3Storage(s3Config)
|
||||
return await s3Client.deleteFile(fileName)
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to delete S3 file:', error)
|
||||
@@ -661,7 +764,7 @@ class BackupManager {
|
||||
}
|
||||
|
||||
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
const s3Client = this.getS3Storage(s3Config)
|
||||
return await s3Client.checkConnection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { removeEnvProxy } from '@main/utils'
|
||||
import { isUserInChina } from '@main/utils/ipService'
|
||||
import { getBinaryName } from '@main/utils/process'
|
||||
import { codeTools } from '@shared/config/constant'
|
||||
import { spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
@@ -40,23 +42,33 @@ class CodeToolsService {
|
||||
}
|
||||
|
||||
public async getPackageName(cliTool: string) {
|
||||
if (cliTool === 'claude-code') {
|
||||
return '@anthropic-ai/claude-code'
|
||||
switch (cliTool) {
|
||||
case codeTools.claudeCode:
|
||||
return '@anthropic-ai/claude-code'
|
||||
case codeTools.geminiCli:
|
||||
return '@google/gemini-cli'
|
||||
case codeTools.openaiCodex:
|
||||
return '@openai/codex'
|
||||
case codeTools.qwenCode:
|
||||
return '@qwen-code/qwen-code'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
if (cliTool === 'gemini-cli') {
|
||||
return '@google/gemini-cli'
|
||||
}
|
||||
return '@qwen-code/qwen-code'
|
||||
}
|
||||
|
||||
public async getCliExecutableName(cliTool: string) {
|
||||
if (cliTool === 'claude-code') {
|
||||
return 'claude'
|
||||
switch (cliTool) {
|
||||
case codeTools.claudeCode:
|
||||
return 'claude'
|
||||
case codeTools.geminiCli:
|
||||
return 'gemini'
|
||||
case codeTools.openaiCodex:
|
||||
return 'codex'
|
||||
case codeTools.qwenCode:
|
||||
return 'qwen'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
if (cliTool === 'gemini-cli') {
|
||||
return 'gemini'
|
||||
}
|
||||
return 'qwen'
|
||||
}
|
||||
|
||||
private async isPackageInstalled(cliTool: string): Promise<boolean> {
|
||||
@@ -114,9 +126,21 @@ class CodeToolsService {
|
||||
} else {
|
||||
logger.info(`Fetching latest version for ${packageName} from npm`)
|
||||
try {
|
||||
const bunPath = await this.getBunPath()
|
||||
const { stdout } = await execAsync(`"${bunPath}" info ${packageName} version`, { timeout: 15000 })
|
||||
latestVersion = stdout.trim().replace(/["']/g, '')
|
||||
// Get registry URL
|
||||
const registryUrl = await this.getNpmRegistryUrl()
|
||||
|
||||
// Fetch package info directly from npm registry API
|
||||
const packageUrl = `${registryUrl}/${packageName}/latest`
|
||||
const response = await fetch(packageUrl, {
|
||||
signal: AbortSignal.timeout(15000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch package info: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const packageInfo = await response.json()
|
||||
latestVersion = packageInfo.version
|
||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||
|
||||
// Cache the result
|
||||
@@ -283,12 +307,11 @@ class CodeToolsService {
|
||||
}
|
||||
|
||||
// Build command to execute
|
||||
let baseCommand: string
|
||||
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
if (isInstalled) {
|
||||
// If already installed, run executable directly (with optional update message)
|
||||
baseCommand = `"${executablePath}"`
|
||||
if (updateMessage) {
|
||||
baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}`
|
||||
}
|
||||
@@ -300,8 +323,8 @@ class CodeToolsService {
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && "${executablePath}"`
|
||||
const installCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
|
||||
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs/promises'
|
||||
import { app, net, safeStorage } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { getConfigDir } from '../utils/file'
|
||||
|
||||
const logger = loggerService.withContext('CopilotService')
|
||||
|
||||
// 配置常量,集中管理
|
||||
@@ -29,7 +29,8 @@ const CONFIG = {
|
||||
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
|
||||
GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token',
|
||||
COPILOT_TOKEN: 'https://api.github.com/copilot_internal/v2/token'
|
||||
}
|
||||
},
|
||||
TOKEN_FILE_NAME: '.copilot_token'
|
||||
}
|
||||
|
||||
// 接口定义移到顶部,便于查阅
|
||||
@@ -68,8 +69,20 @@ class CopilotService {
|
||||
private headers: Record<string, string>
|
||||
|
||||
constructor() {
|
||||
this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token')
|
||||
this.headers = { ...CONFIG.DEFAULT_HEADERS }
|
||||
this.tokenFilePath = this.getTokenFilePath()
|
||||
this.headers = {
|
||||
...CONFIG.DEFAULT_HEADERS,
|
||||
accept: 'application/json',
|
||||
'user-agent': 'Visual Studio Code (desktop)'
|
||||
}
|
||||
}
|
||||
|
||||
private getTokenFilePath = (): string => {
|
||||
const oldTokenFilePath = path.join(app.getPath('userData'), CONFIG.TOKEN_FILE_NAME)
|
||||
if (fs.existsSync(oldTokenFilePath)) {
|
||||
return oldTokenFilePath
|
||||
}
|
||||
return path.join(getConfigDir(), CONFIG.TOKEN_FILE_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,21 +99,27 @@ class CopilotService {
|
||||
*/
|
||||
public getUser = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<UserResponse> => {
|
||||
try {
|
||||
const config: AxiosRequestConfig = {
|
||||
const response = await net.fetch(CONFIG.API_URLS.GITHUB_USER, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Connection: 'keep-alive',
|
||||
'user-agent': 'Visual Studio Code (desktop)',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
accept: 'application/json',
|
||||
authorization: `token ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
|
||||
const data = await response.json()
|
||||
return {
|
||||
login: response.data.login,
|
||||
avatar: response.data.avatar_url
|
||||
login: data.login,
|
||||
avatar: data.avatar_url
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user information:', error as Error)
|
||||
@@ -118,16 +137,23 @@ class CopilotService {
|
||||
try {
|
||||
this.updateHeaders(headers)
|
||||
|
||||
const response = await axios.post<AuthResponse>(
|
||||
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
|
||||
{
|
||||
const response = await net.fetch(CONFIG.API_URLS.GITHUB_DEVICE_CODE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...this.headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
scope: 'read:user'
|
||||
},
|
||||
{ headers: this.headers }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return response.data
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as AuthResponse
|
||||
} catch (error) {
|
||||
logger.error('Failed to get auth message:', error as Error)
|
||||
throw new CopilotServiceError('无法获取GitHub授权信息', error)
|
||||
@@ -150,17 +176,25 @@ class CopilotService {
|
||||
await this.delay(currentDelay)
|
||||
|
||||
try {
|
||||
const response = await axios.post<TokenResponse>(
|
||||
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
|
||||
{
|
||||
const response = await net.fetch(CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...this.headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
device_code,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
},
|
||||
{ headers: this.headers }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const { access_token } = response.data
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse
|
||||
const { access_token } = data
|
||||
if (access_token) {
|
||||
return { access_token }
|
||||
}
|
||||
@@ -185,7 +219,13 @@ class CopilotService {
|
||||
public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => {
|
||||
try {
|
||||
const encryptedToken = safeStorage.encryptString(token)
|
||||
await fs.writeFile(this.tokenFilePath, encryptedToken)
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(this.tokenFilePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(this.tokenFilePath, encryptedToken)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save token:', error as Error)
|
||||
throw new CopilotServiceError('无法保存访问令牌', error)
|
||||
@@ -202,19 +242,22 @@ class CopilotService {
|
||||
try {
|
||||
this.updateHeaders(headers)
|
||||
|
||||
const encryptedToken = await fs.readFile(this.tokenFilePath)
|
||||
const encryptedToken = await fs.promises.readFile(this.tokenFilePath)
|
||||
const access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...this.headers,
|
||||
authorization: `token ${access_token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
|
||||
|
||||
return response.data
|
||||
return (await response.json()) as CopilotTokenResponse
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Copilot token:', error as Error)
|
||||
throw new CopilotServiceError('无法获取Copilot令牌,请重新授权', error)
|
||||
@@ -227,8 +270,8 @@ class CopilotService {
|
||||
public logout = async (): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
await fs.access(this.tokenFilePath)
|
||||
await fs.unlink(this.tokenFilePath)
|
||||
await fs.promises.access(this.tokenFilePath)
|
||||
await fs.promises.unlink(this.tokenFilePath)
|
||||
logger.debug('Successfully logged out from Copilot')
|
||||
} catch (error) {
|
||||
// 文件不存在不是错误,只是记录一下
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { documentExts, imageExts, MB } from '@shared/config/constant'
|
||||
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
|
||||
import { FileMetadata } from '@types'
|
||||
import chardet from 'chardet'
|
||||
import * as crypto from 'crypto'
|
||||
import {
|
||||
dialog,
|
||||
net,
|
||||
OpenDialogOptions,
|
||||
OpenDialogReturnValue,
|
||||
SaveDialogOptions,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
import * as fs from 'fs'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { isBinaryFile } from 'isbinaryfile'
|
||||
import officeParser from 'officeparser'
|
||||
import * as path from 'path'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
@@ -509,7 +512,7 @@ class FileStorage {
|
||||
isUseContentType?: boolean
|
||||
): Promise<FileMetadata> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const response = await net.fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
@@ -629,6 +632,34 @@ class FileStorage {
|
||||
public getFilePathById(file: FileMetadata): string {
|
||||
return path.join(this.storageDir, file.id + file.ext)
|
||||
}
|
||||
|
||||
public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
const isBinary = await isBinaryFile(filePath)
|
||||
if (isBinary) {
|
||||
return false
|
||||
}
|
||||
|
||||
const length = 8 * KB
|
||||
const fileHandle = await fs.promises.open(filePath, 'r')
|
||||
const buffer = Buffer.alloc(length)
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, length, 0)
|
||||
await fileHandle.close()
|
||||
|
||||
const sampleBuffer = buffer.subarray(0, bytesRead)
|
||||
const matches = chardet.analyse(sampleBuffer)
|
||||
|
||||
// 如果检测到的编码置信度较高,认为是文本文件
|
||||
if (matches.length > 0 && matches[0].confidence > 0.8) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Failed to check if file is text:', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fileStorage = new FileStorage()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
@@ -8,4 +9,15 @@ export default class FileService {
|
||||
if (encoding) return fs.readFile(path, { encoding })
|
||||
return fs.readFile(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动识别编码,读取文本文件
|
||||
* @param _ event
|
||||
* @param pathOrUrl
|
||||
* @throws 路径不存在时抛出错误
|
||||
*/
|
||||
@TraceMethod({ spanName: 'readTextFileWithAutoEncoding', tag: 'FileService' })
|
||||
public static async readTextFileWithAutoEncoding(_: Electron.IpcMainInvokeEvent, path: string): Promise<string> {
|
||||
return readTextFileWithAutoEncoding(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
CancelledNotificationSchema,
|
||||
type GetPromptResult,
|
||||
LoggingMessageNotificationSchema,
|
||||
ProgressNotificationSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
@@ -29,7 +28,7 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types'
|
||||
import { app } from 'electron'
|
||||
import { app, net } from 'electron'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -205,7 +204,7 @@ class McpService {
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, headers })
|
||||
return net.fetch(typeof url === 'string' ? url : url.toString(), { ...init, headers })
|
||||
}
|
||||
},
|
||||
requestInit: {
|
||||
@@ -432,15 +431,6 @@ class McpService {
|
||||
this.clearResourceCaches(serverKey)
|
||||
})
|
||||
|
||||
// Set up progress notification handler
|
||||
client.setNotificationHandler(ProgressNotificationSchema, async (notification) => {
|
||||
logger.debug(`Progress notification received for server: ${server.name}`, notification.params)
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mcp-progress', notification.params.progress / (notification.params.total || 1))
|
||||
}
|
||||
})
|
||||
|
||||
// Set up cancelled notification handler
|
||||
client.setNotificationHandler(CancelledNotificationSchema, async (notification) => {
|
||||
logger.debug(`Operation cancelled for server: ${server.name}`, notification.params)
|
||||
@@ -629,6 +619,11 @@ class McpService {
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||
onprogress: (process) => {
|
||||
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
|
||||
logger.debug(`Progress notification received for server: ${server.name}`, process)
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
|
||||
}
|
||||
},
|
||||
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute,
|
||||
// 需要服务端支持: https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#timeouts
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
|
||||
import { Notification as ElectronNotification } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
class NotificationService {
|
||||
private window: BrowserWindow
|
||||
|
||||
constructor(window: BrowserWindow) {
|
||||
// Initialize the service
|
||||
this.window = window
|
||||
}
|
||||
|
||||
public async sendNotification(notification: Notification) {
|
||||
// 使用 Electron Notification API
|
||||
const electronNotification = new ElectronNotification({
|
||||
@@ -17,8 +12,8 @@ class NotificationService {
|
||||
})
|
||||
|
||||
electronNotification.on('click', () => {
|
||||
this.window.show()
|
||||
this.window.webContents.send('notification-click', notification)
|
||||
windowService.getMainWindow()?.show()
|
||||
windowService.getMainWindow()?.webContents.send('notification-click', notification)
|
||||
})
|
||||
|
||||
electronNotification.show()
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { NUTSTORE_HOST } from '@shared/config/nutstore'
|
||||
import { net } from 'electron'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import { isNil, partial } from 'lodash'
|
||||
import { type FileStat } from 'webdav'
|
||||
@@ -62,7 +63,7 @@ export async function getDirectoryContents(token: string, target: string): Promi
|
||||
let currentUrl = `${NUTSTORE_HOST}${target}`
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(currentUrl, {
|
||||
const response = await net.fetch(currentUrl, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
|
||||
@@ -11,14 +11,42 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher
|
||||
const logger = loggerService.withContext('ProxyManager')
|
||||
let byPassRules: string[] = []
|
||||
|
||||
const isByPass = (hostname: string) => {
|
||||
const isByPass = (url: string) => {
|
||||
if (byPassRules.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return byPassRules.includes(hostname)
|
||||
}
|
||||
try {
|
||||
const subjectUrlTokens = new URL(url)
|
||||
for (const rule of byPassRules) {
|
||||
const ruleMatch = rule.replace(/^(?<leadingDot>\.)/, '*').match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/)
|
||||
|
||||
if (!ruleMatch || !ruleMatch.groups) {
|
||||
logger.warn('Failed to parse bypass rule:', { rule })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!ruleMatch.groups.hostname) {
|
||||
continue
|
||||
}
|
||||
|
||||
const hostnameIsMatch = subjectUrlTokens.hostname === ruleMatch.groups.hostname
|
||||
|
||||
if (
|
||||
hostnameIsMatch &&
|
||||
(!ruleMatch.groups ||
|
||||
!ruleMatch.groups.port ||
|
||||
(subjectUrlTokens.port && subjectUrlTokens.port === ruleMatch.groups.port))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Failed to check bypass:', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
class SelectiveDispatcher extends Dispatcher {
|
||||
private proxyDispatcher: Dispatcher
|
||||
private directDispatcher: Dispatcher
|
||||
@@ -31,9 +59,7 @@ class SelectiveDispatcher extends Dispatcher {
|
||||
|
||||
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
|
||||
if (opts.origin) {
|
||||
const url = new URL(opts.origin)
|
||||
// 检查是否为 localhost 或本地地址
|
||||
if (isByPass(url.hostname)) {
|
||||
if (isByPass(opts.origin.toString())) {
|
||||
return this.directDispatcher.dispatch(opts, handler)
|
||||
}
|
||||
}
|
||||
@@ -93,15 +119,20 @@ export class ProxyManager {
|
||||
// Set new interval
|
||||
this.systemProxyInterval = setInterval(async () => {
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules) {
|
||||
if (
|
||||
currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules &&
|
||||
currentProxy?.noProxy.join(',').toLowerCase() === this.config?.proxyBypassRules?.toLowerCase()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}`)
|
||||
logger.info(
|
||||
`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}, this.config.proxyBypassRules: ${this.config.proxyBypassRules}`
|
||||
)
|
||||
await this.configureProxy({
|
||||
mode: 'system',
|
||||
proxyRules: currentProxy?.proxyUrl.toLowerCase(),
|
||||
proxyBypassRules: undefined
|
||||
proxyBypassRules: currentProxy?.noProxy.join(',')
|
||||
})
|
||||
}, 1000 * 60)
|
||||
}
|
||||
@@ -151,6 +182,7 @@ export class ProxyManager {
|
||||
delete process.env.grpc_proxy
|
||||
delete process.env.http_proxy
|
||||
delete process.env.https_proxy
|
||||
delete process.env.no_proxy
|
||||
|
||||
delete process.env.SOCKS_PROXY
|
||||
delete process.env.ALL_PROXY
|
||||
@@ -162,6 +194,7 @@ export class ProxyManager {
|
||||
process.env.HTTPS_PROXY = url
|
||||
process.env.http_proxy = url
|
||||
process.env.https_proxy = url
|
||||
process.env.no_proxy = byPassRules.join(',')
|
||||
|
||||
if (url.startsWith('socks')) {
|
||||
process.env.SOCKS_PROXY = url
|
||||
@@ -229,8 +262,7 @@ export class ProxyManager {
|
||||
|
||||
// filter localhost
|
||||
if (url) {
|
||||
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname
|
||||
if (isByPass(hostname)) {
|
||||
if (isByPass(url.toString())) {
|
||||
return originalMethod(url, options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
//the following ZOOMs will register shortcuts seperately, so will return
|
||||
//the following ZOOMs will register shortcuts separately, so will return
|
||||
case 'zoom_in':
|
||||
globalShortcut.register('CommandOrControl+=', () => handler(window))
|
||||
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
|
||||
|
||||
@@ -223,26 +223,26 @@ export class WindowService {
|
||||
})
|
||||
|
||||
// 添加Escape键退出全屏的支持
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
// 当按下Escape键且窗口处于全屏状态时退出全屏
|
||||
if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
|
||||
if (mainWindow.isFullScreen()) {
|
||||
// 获取 shortcuts 配置
|
||||
const shortcuts = configManager.getShortcuts()
|
||||
const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
|
||||
if (exitFullscreenShortcut == undefined) {
|
||||
mainWindow.setFullScreen(false)
|
||||
return
|
||||
}
|
||||
if (exitFullscreenShortcut?.enabled) {
|
||||
event.preventDefault()
|
||||
mainWindow.setFullScreen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
})
|
||||
// mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
// // 当按下Escape键且窗口处于全屏状态时退出全屏
|
||||
// if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
|
||||
// if (mainWindow.isFullScreen()) {
|
||||
// // 获取 shortcuts 配置
|
||||
// const shortcuts = configManager.getShortcuts()
|
||||
// const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
|
||||
// if (exitFullscreenShortcut == undefined) {
|
||||
// mainWindow.setFullScreen(false)
|
||||
// return
|
||||
// }
|
||||
// if (exitFullscreenShortcut?.enabled) {
|
||||
// event.preventDefault()
|
||||
// mainWindow.setFullScreen(false)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return
|
||||
// })
|
||||
}
|
||||
|
||||
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
|
||||
@@ -555,9 +555,9 @@ export class WindowService {
|
||||
|
||||
// [Windows] hacky fix
|
||||
// the window is minimized only when in Windows platform
|
||||
// because it's a workround for Windows, see `hideMiniWindow()`
|
||||
// because it's a workaround for Windows, see `hideMiniWindow()`
|
||||
if (this.miniWindow?.isMinimized()) {
|
||||
// don't let the window being seen before we finish adusting the position across screens
|
||||
// don't let the window being seen before we finish adjusting the position across screens
|
||||
this.miniWindow?.setOpacity(0)
|
||||
// DO NOT use `restore()` here, Electron has the bug with screens of different scale factor
|
||||
// We have to use `show()` here, then set the position and bounds
|
||||
|
||||
34
src/main/services/ocr/OcrService.ts
Normal file
34
src/main/services/ocr/OcrService.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||
|
||||
import { tesseractService } from './tesseract/TesseractService'
|
||||
|
||||
const logger = loggerService.withContext('OcrService')
|
||||
|
||||
export class OcrService {
|
||||
private registry: Map<string, OcrHandler> = new Map()
|
||||
|
||||
register(providerId: string, handler: OcrHandler): void {
|
||||
if (this.registry.has(providerId)) {
|
||||
logger.warn(`Provider ${providerId} has existing handler. Overwrited.`)
|
||||
}
|
||||
this.registry.set(providerId, handler)
|
||||
}
|
||||
|
||||
unregister(providerId: string): void {
|
||||
this.registry.delete(providerId)
|
||||
}
|
||||
|
||||
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
|
||||
const handler = this.registry.get(provider.id)
|
||||
if (!handler) {
|
||||
throw new Error(`Provider ${provider.id} is not registered`)
|
||||
}
|
||||
return handler(file)
|
||||
}
|
||||
}
|
||||
|
||||
export const ocrService = new OcrService()
|
||||
|
||||
// Register built-in providers
|
||||
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
|
||||
82
src/main/services/ocr/tesseract/TesseractService.ts
Normal file
82
src/main/services/ocr/tesseract/TesseractService.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { loadOcrImage } from '@main/utils/ocr'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import Tesseract, { createWorker, LanguageCode } from 'tesseract.js'
|
||||
|
||||
const logger = loggerService.withContext('TesseractService')
|
||||
|
||||
// config
|
||||
const MB_SIZE_THRESHOLD = 50
|
||||
const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[]
|
||||
enum TesseractLangsDownloadUrl {
|
||||
CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/',
|
||||
GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/'
|
||||
}
|
||||
|
||||
export class TesseractService {
|
||||
private worker: Tesseract.Worker | null = null
|
||||
|
||||
async getWorker(): Promise<Tesseract.Worker> {
|
||||
if (!this.worker) {
|
||||
// for now, only support limited languages
|
||||
this.worker = await createWorker(tesseractLangs, undefined, {
|
||||
langPath: await this._getLangPath(),
|
||||
cachePath: await this._getCacheDir(),
|
||||
gzip: false,
|
||||
logger: (m) => logger.debug('From worker', m)
|
||||
})
|
||||
}
|
||||
return this.worker
|
||||
}
|
||||
|
||||
async imageOcr(file: ImageFileMetadata): Promise<OcrResult> {
|
||||
const worker = await this.getWorker()
|
||||
const stat = await fs.promises.stat(file.path)
|
||||
if (stat.size > MB_SIZE_THRESHOLD * MB) {
|
||||
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
|
||||
}
|
||||
const buffer = await loadOcrImage(file)
|
||||
const result = await worker.recognize(buffer)
|
||||
return { text: result.data.text }
|
||||
}
|
||||
|
||||
async ocr(file: SupportedOcrFile): Promise<OcrResult> {
|
||||
if (!isImageFile(file)) {
|
||||
throw new Error('Only image files are supported currently')
|
||||
}
|
||||
return this.imageOcr(file)
|
||||
}
|
||||
|
||||
private async _getLangPath(): Promise<string> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL
|
||||
}
|
||||
|
||||
private async _getCacheDir(): Promise<string> {
|
||||
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
|
||||
// use access to check if the directory exists
|
||||
if (
|
||||
!(await fs.promises
|
||||
.access(cacheDir, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||
}
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate()
|
||||
this.worker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tesseractService = new TesseractService()
|
||||
@@ -168,6 +168,7 @@ export function getMcpDir() {
|
||||
* 读取文件内容并自动检测编码格式进行解码
|
||||
* @param filePath - 文件路径
|
||||
* @returns 解码后的文件内容
|
||||
* @throws 如果路径不存在抛出错误
|
||||
*/
|
||||
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
|
||||
const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { net } from 'electron'
|
||||
|
||||
const logger = loggerService.withContext('IpService')
|
||||
|
||||
@@ -12,7 +13,7 @@ export async function getIpCountry(): Promise<string> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const ipinfo = await fetch('https://ipinfo.io/json', {
|
||||
const ipinfo = await net.fetch('https://ipinfo.io/json', {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
|
||||
27
src/main/utils/ocr.ts
Normal file
27
src/main/utils/ocr.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ImageFileMetadata } from '@types'
|
||||
import { readFile } from 'fs/promises'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const preprocessImage = async (buffer: Buffer) => {
|
||||
return await sharp(buffer)
|
||||
.grayscale() // 转为灰度
|
||||
.normalize()
|
||||
.sharpen()
|
||||
.toBuffer()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并预处理OCR图像
|
||||
* @param file - 图像文件元数据
|
||||
* @returns 预处理后的图像Buffer
|
||||
* @throws {Error} 当文件不存在或无法读取时抛出错误;当图像预处理失败时抛出错误
|
||||
*
|
||||
* 预处理步骤:
|
||||
* 1. 读取图像文件
|
||||
* 2. 转换为灰度图
|
||||
* 3. 后续可扩展其他预处理步骤
|
||||
*/
|
||||
export const loadOcrImage = async (file: ImageFileMetadata): Promise<Buffer> => {
|
||||
const buffer = await readFile(file.path)
|
||||
return await preprocessImage(buffer)
|
||||
}
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
MemoryConfig,
|
||||
MemoryListOptions,
|
||||
MemorySearchOptions,
|
||||
OcrProvider,
|
||||
OcrResult,
|
||||
Provider,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
SupportedOcrFile,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
@@ -76,6 +79,7 @@ const api = {
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
logToMain: (source: LogSourceWithContext, level: LogLevel, message: string, data: any[]) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
|
||||
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
|
||||
mac: {
|
||||
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
||||
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
|
||||
@@ -132,14 +136,15 @@ const api = {
|
||||
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
select: (options?: OpenDialogOptions): Promise<FileMetadata[] | null> =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
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, detectEncoding?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
|
||||
clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext),
|
||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
get: (filePath: string): Promise<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
/**
|
||||
* 创建一个空的临时文件
|
||||
* @param fileName 文件名
|
||||
@@ -169,10 +174,12 @@ const api = {
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
|
||||
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
|
||||
readText: (pathOrUrl: string): Promise<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
||||
@@ -295,7 +302,8 @@ const api = {
|
||||
return ipcRenderer.invoke(IpcChannel.Mcp_UploadDxt, buffer, file.name)
|
||||
},
|
||||
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
|
||||
getServerVersion: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
|
||||
getServerVersion: (server: MCPServer): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
|
||||
},
|
||||
python: {
|
||||
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
|
||||
@@ -403,6 +411,10 @@ const api = {
|
||||
env: Record<string, string>,
|
||||
options?: { autoUpdateToLatest?: boolean }
|
||||
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
|
||||
},
|
||||
ocr: {
|
||||
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
|
||||
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
<title>Cherry Studio Quick Assistant</title>
|
||||
|
||||
<style>
|
||||
html,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FC, useMemo } from 'react'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import TabsContainer from './components/Tab/TabContainer'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import { useNavbarPosition } from './hooks/useSettings'
|
||||
@@ -23,18 +24,20 @@ const Router: FC = () => {
|
||||
|
||||
const routes = useMemo(() => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} />
|
||||
</Routes>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AihubmixAPIClient } from '../AihubmixAPIClient'
|
||||
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
|
||||
import { ApiClientFactory } from '../ApiClientFactory'
|
||||
import { AwsBedrockAPIClient } from '../aws/AwsBedrockAPIClient'
|
||||
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
|
||||
import { VertexAPIClient } from '../gemini/VertexAPIClient'
|
||||
import { NewAPIClient } from '../NewAPIClient'
|
||||
@@ -54,6 +55,19 @@ vi.mock('../openai/OpenAIResponseAPIClient', () => ({
|
||||
vi.mock('../ppio/PPIOAPIClient', () => ({
|
||||
PPIOAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
vi.mock('../aws/AwsBedrockAPIClient', () => ({
|
||||
AwsBedrockAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
|
||||
// Mock the models config to prevent circular dependency issues
|
||||
vi.mock('@renderer/config/models', () => ({
|
||||
findTokenLimit: vi.fn(),
|
||||
isReasoningModel: vi.fn(),
|
||||
SYSTEM_MODELS: {
|
||||
silicon: [],
|
||||
defaultModel: []
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ApiClientFactory', () => {
|
||||
beforeEach(() => {
|
||||
@@ -144,6 +158,15 @@ describe('ApiClientFactory', () => {
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create AwsBedrockAPIClient for aws-bedrock type', () => {
|
||||
const provider = createTestProvider('aws-bedrock', 'aws-bedrock')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(AwsBedrockAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
// 测试默认情况
|
||||
it('should create OpenAIAPIClient as default for unknown type', () => {
|
||||
const provider = createTestProvider('unknown', 'unknown-type')
|
||||
|
||||
@@ -2,19 +2,24 @@ import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesComman
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConverseCommand,
|
||||
ConverseStreamCommand,
|
||||
InvokeModelCommand
|
||||
InvokeModelCommand,
|
||||
InvokeModelWithResponseStreamCommand
|
||||
} from '@aws-sdk/client-bedrock-runtime'
|
||||
import { loggerService } from '@logger'
|
||||
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
|
||||
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
||||
import { findTokenLimit, isReasoningModel } from '@renderer/config/models'
|
||||
import {
|
||||
getAwsBedrockAccessKeyId,
|
||||
getAwsBedrockRegion,
|
||||
getAwsBedrockSecretAccessKey
|
||||
} from '@renderer/hooks/useAwsBedrock'
|
||||
import { getAssistantSettings } from '@renderer/services/AssistantService'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import {
|
||||
Assistant,
|
||||
EFFORT_RATIO,
|
||||
FileTypes,
|
||||
GenerateImageParams,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
@@ -23,7 +28,13 @@ import {
|
||||
Provider,
|
||||
ToolCallResponse
|
||||
} from '@renderer/types'
|
||||
import { ChunkType, MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
import {
|
||||
ChunkType,
|
||||
MCPToolCreatedChunk,
|
||||
TextDeltaChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
AwsBedrockSdkInstance,
|
||||
@@ -33,6 +44,7 @@ import {
|
||||
AwsBedrockSdkRawOutput,
|
||||
AwsBedrockSdkTool,
|
||||
AwsBedrockSdkToolCall,
|
||||
AwsBedrockStreamChunk,
|
||||
SdkModel
|
||||
} from '@renderer/types/sdk'
|
||||
import { convertBase64ImageToAwsBedrockFormat } from '@renderer/utils/aws-bedrock-utils'
|
||||
@@ -42,7 +54,7 @@ import {
|
||||
mcpToolCallResponseToAwsBedrockMessage,
|
||||
mcpToolsToAwsBedrockTools
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { BaseApiClient } from '../BaseApiClient'
|
||||
@@ -103,46 +115,65 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
override async createCompletions(payload: AwsBedrockSdkParams): Promise<AwsBedrockSdkRawOutput> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
// 转换消息格式到AWS SDK原生格式
|
||||
// 转换消息格式(用于 InvokeModelWithResponseStreamCommand)
|
||||
const awsMessages = payload.messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content.map((content) => {
|
||||
if (content.text) {
|
||||
return { text: content.text }
|
||||
return { type: 'text', text: content.text }
|
||||
}
|
||||
if (content.image) {
|
||||
// 处理图片数据,将 Uint8Array 或数字数组转换为 base64 字符串
|
||||
let base64Data = ''
|
||||
if (content.image.source.bytes) {
|
||||
if (typeof content.image.source.bytes === 'string') {
|
||||
// 如果已经是字符串,直接使用
|
||||
base64Data = content.image.source.bytes
|
||||
} else {
|
||||
// 如果是数组或 Uint8Array,转换为 base64
|
||||
const uint8Array = new Uint8Array(Object.values(content.image.source.bytes))
|
||||
const binaryString = Array.from(uint8Array)
|
||||
.map((byte) => String.fromCharCode(byte))
|
||||
.join('')
|
||||
base64Data = btoa(binaryString)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format: content.image.format,
|
||||
source: content.image.source
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: `image/${content.image.format}`,
|
||||
data: base64Data
|
||||
}
|
||||
}
|
||||
}
|
||||
if (content.toolResult) {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: content.toolResult.toolUseId,
|
||||
content: content.toolResult.content,
|
||||
status: content.toolResult.status
|
||||
}
|
||||
type: 'tool_result',
|
||||
tool_use_id: content.toolResult.toolUseId,
|
||||
content: content.toolResult.content
|
||||
}
|
||||
}
|
||||
if (content.toolUse) {
|
||||
return {
|
||||
toolUse: {
|
||||
toolUseId: content.toolUse.toolUseId,
|
||||
name: content.toolUse.name,
|
||||
input: content.toolUse.input
|
||||
}
|
||||
type: 'tool_use',
|
||||
id: content.toolUse.toolUseId,
|
||||
name: content.toolUse.name,
|
||||
input: content.toolUse.input
|
||||
}
|
||||
}
|
||||
// 返回符合AWS SDK ContentBlock类型的对象
|
||||
return { text: 'Unknown content type' }
|
||||
return { type: 'text', text: 'Unknown content type' }
|
||||
})
|
||||
}))
|
||||
|
||||
logger.info('Creating completions with model ID:', { modelId: payload.modelId })
|
||||
|
||||
const excludeKeys = ['modelId', 'messages', 'system', 'maxTokens', 'temperature', 'topP', 'stream', 'tools']
|
||||
const additionalParams = Object.keys(payload)
|
||||
.filter((key) => !excludeKeys.includes(key))
|
||||
.reduce((acc, key) => ({ ...acc, [key]: payload[key] }), {})
|
||||
|
||||
const commonParams = {
|
||||
modelId: payload.modelId,
|
||||
messages: awsMessages as any,
|
||||
@@ -162,10 +193,18 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
|
||||
try {
|
||||
if (payload.stream) {
|
||||
const command = new ConverseStreamCommand(commonParams)
|
||||
// 根据模型类型选择正确的 API 格式
|
||||
const requestBody = this.createRequestBodyForModel(commonParams, additionalParams)
|
||||
|
||||
const command = new InvokeModelWithResponseStreamCommand({
|
||||
modelId: commonParams.modelId,
|
||||
body: JSON.stringify(requestBody),
|
||||
contentType: 'application/json',
|
||||
accept: 'application/json'
|
||||
})
|
||||
|
||||
const response = await sdk.client.send(command)
|
||||
// 直接返回AWS Bedrock流式响应的异步迭代器
|
||||
return this.createStreamIterator(response)
|
||||
return this.createInvokeModelStreamIterator(response)
|
||||
} else {
|
||||
const command = new ConverseCommand(commonParams)
|
||||
const response = await sdk.client.send(command)
|
||||
@@ -177,32 +216,236 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
private async *createStreamIterator(response: any): AsyncIterable<AwsBedrockSdkRawChunk> {
|
||||
try {
|
||||
if (response.stream) {
|
||||
for await (const chunk of response.stream) {
|
||||
logger.debug('AWS Bedrock chunk received:', chunk)
|
||||
/**
|
||||
* 根据模型类型创建请求体
|
||||
*/
|
||||
private createRequestBodyForModel(commonParams: any, additionalParams: any): any {
|
||||
const modelId = commonParams.modelId.toLowerCase()
|
||||
|
||||
// AWS Bedrock的流式响应格式转换为标准格式
|
||||
if (chunk.contentBlockDelta?.delta?.text) {
|
||||
yield {
|
||||
contentBlockDelta: {
|
||||
delta: { text: chunk.contentBlockDelta.delta.text }
|
||||
// Claude 系列模型使用 Anthropic API 格式
|
||||
if (modelId.includes('claude')) {
|
||||
return {
|
||||
anthropic_version: 'bedrock-2023-05-31',
|
||||
max_tokens: commonParams.inferenceConfig.maxTokens,
|
||||
temperature: commonParams.inferenceConfig.temperature,
|
||||
top_p: commonParams.inferenceConfig.topP,
|
||||
messages: commonParams.messages,
|
||||
...(commonParams.system && commonParams.system[0]?.text ? { system: commonParams.system[0].text } : {}),
|
||||
...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {}),
|
||||
...additionalParams
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI 系列模型
|
||||
if (modelId.includes('gpt') || modelId.includes('openai')) {
|
||||
const messages: any[] = []
|
||||
|
||||
// 添加系统消息
|
||||
if (commonParams.system && commonParams.system[0]?.text) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: commonParams.system[0].text
|
||||
})
|
||||
}
|
||||
|
||||
// 转换消息格式
|
||||
for (const message of commonParams.messages) {
|
||||
const content: any[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.text) {
|
||||
content.push({ type: 'text', text: part.text })
|
||||
} else if (part.image) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:image/${part.image.format};base64,${part.image.source.bytes}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
messages.push({
|
||||
role: message.role,
|
||||
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
|
||||
})
|
||||
}
|
||||
|
||||
const baseBody: any = {
|
||||
model: commonParams.modelId,
|
||||
messages: messages,
|
||||
max_tokens: commonParams.inferenceConfig.maxTokens,
|
||||
temperature: commonParams.inferenceConfig.temperature,
|
||||
top_p: commonParams.inferenceConfig.topP,
|
||||
stream: true,
|
||||
...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {})
|
||||
}
|
||||
|
||||
// OpenAI 模型的 thinking 参数格式
|
||||
if (additionalParams.reasoning_effort) {
|
||||
baseBody.reasoning_effort = additionalParams.reasoning_effort
|
||||
delete additionalParams.reasoning_effort
|
||||
}
|
||||
|
||||
return {
|
||||
...baseBody,
|
||||
...additionalParams
|
||||
}
|
||||
}
|
||||
|
||||
// Llama 系列模型
|
||||
if (modelId.includes('llama')) {
|
||||
const baseBody: any = {
|
||||
prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
|
||||
max_gen_len: commonParams.inferenceConfig.maxTokens,
|
||||
temperature: commonParams.inferenceConfig.temperature,
|
||||
top_p: commonParams.inferenceConfig.topP
|
||||
}
|
||||
|
||||
// Llama 模型的 thinking 参数格式
|
||||
if (additionalParams.thinking_mode) {
|
||||
baseBody.thinking_mode = additionalParams.thinking_mode
|
||||
delete additionalParams.thinking_mode
|
||||
}
|
||||
|
||||
return {
|
||||
...baseBody,
|
||||
...additionalParams
|
||||
}
|
||||
}
|
||||
|
||||
// Amazon Titan 系列模型
|
||||
if (modelId.includes('titan')) {
|
||||
const textGenerationConfig: any = {
|
||||
maxTokenCount: commonParams.inferenceConfig.maxTokens,
|
||||
temperature: commonParams.inferenceConfig.temperature,
|
||||
topP: commonParams.inferenceConfig.topP
|
||||
}
|
||||
|
||||
// 将 thinking 相关参数添加到 textGenerationConfig 中
|
||||
if (additionalParams.thinking) {
|
||||
textGenerationConfig.thinking = additionalParams.thinking
|
||||
delete additionalParams.thinking
|
||||
}
|
||||
|
||||
return {
|
||||
inputText: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
|
||||
textGenerationConfig: {
|
||||
...textGenerationConfig,
|
||||
...Object.keys(additionalParams).reduce((acc, key) => {
|
||||
if (['thinking_tokens', 'reasoning_mode'].includes(key)) {
|
||||
acc[key] = additionalParams[key]
|
||||
delete additionalParams[key]
|
||||
}
|
||||
return acc
|
||||
}, {} as any)
|
||||
},
|
||||
...additionalParams
|
||||
}
|
||||
}
|
||||
|
||||
// Cohere Command 系列模型
|
||||
if (modelId.includes('cohere') || modelId.includes('command')) {
|
||||
const baseBody: any = {
|
||||
message: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
|
||||
max_tokens: commonParams.inferenceConfig.maxTokens,
|
||||
temperature: commonParams.inferenceConfig.temperature,
|
||||
p: commonParams.inferenceConfig.topP
|
||||
}
|
||||
|
||||
// Cohere 模型的 thinking 参数格式
|
||||
if (additionalParams.thinking) {
|
||||
baseBody.thinking = additionalParams.thinking
|
||||
delete additionalParams.thinking
|
||||
}
|
||||
if (additionalParams.reasoning_tokens) {
|
||||
baseBody.reasoning_tokens = additionalParams.reasoning_tokens
|
||||
delete additionalParams.reasoning_tokens
|
||||
}
|
||||
|
||||
return {
|
||||
...baseBody,
|
||||
...additionalParams
|
||||
}
|
||||
}
|
||||
|
||||
// 默认使用通用格式
|
||||
const baseBody: any = {
|
||||
prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
|
||||
max_tokens: commonParams.inferenceConfig.maxTokens,
|
||||
temperature: commonParams.inferenceConfig.temperature,
|
||||
top_p: commonParams.inferenceConfig.topP
|
||||
}
|
||||
|
||||
return {
|
||||
...baseBody,
|
||||
...additionalParams
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将消息转换为简单的 prompt 格式
|
||||
*/
|
||||
private convertMessagesToPrompt(messages: any[], system?: any[]): string {
|
||||
let prompt = ''
|
||||
|
||||
// 添加系统消息
|
||||
if (system && system[0]?.text) {
|
||||
prompt += `System: ${system[0].text}\n\n`
|
||||
}
|
||||
|
||||
// 添加对话消息
|
||||
for (const message of messages) {
|
||||
const role = message.role === 'assistant' ? 'Assistant' : 'Human'
|
||||
let content = ''
|
||||
|
||||
for (const part of message.content) {
|
||||
if (part.text) {
|
||||
content += part.text
|
||||
} else if (part.image) {
|
||||
content += '[Image]'
|
||||
}
|
||||
}
|
||||
|
||||
prompt += `${role}: ${content}\n\n`
|
||||
}
|
||||
|
||||
prompt += 'Assistant:'
|
||||
return prompt
|
||||
}
|
||||
|
||||
private async *createInvokeModelStreamIterator(response: any): AsyncIterable<AwsBedrockSdkRawChunk> {
|
||||
try {
|
||||
if (response.body) {
|
||||
for await (const event of response.body) {
|
||||
if (event.chunk) {
|
||||
const chunk: AwsBedrockStreamChunk = JSON.parse(new TextDecoder().decode(event.chunk.bytes))
|
||||
|
||||
// 转换为标准格式
|
||||
if (chunk.type === 'content_block_delta') {
|
||||
yield {
|
||||
contentBlockDelta: {
|
||||
delta: chunk.delta,
|
||||
contentBlockIndex: chunk.index
|
||||
}
|
||||
}
|
||||
} else if (chunk.type === 'message_start') {
|
||||
yield { messageStart: chunk }
|
||||
} else if (chunk.type === 'message_stop') {
|
||||
yield { messageStop: chunk }
|
||||
} else if (chunk.type === 'content_block_start') {
|
||||
yield {
|
||||
contentBlockStart: {
|
||||
start: chunk.content_block,
|
||||
contentBlockIndex: chunk.index
|
||||
}
|
||||
}
|
||||
} else if (chunk.type === 'content_block_stop') {
|
||||
yield {
|
||||
contentBlockStop: {
|
||||
contentBlockIndex: chunk.index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.messageStart) {
|
||||
yield { messageStart: chunk.messageStart }
|
||||
}
|
||||
|
||||
if (chunk.messageStop) {
|
||||
yield { messageStop: chunk.messageStop }
|
||||
}
|
||||
|
||||
if (chunk.metadata) {
|
||||
yield { metadata: chunk.metadata }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -441,6 +684,30 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件内容
|
||||
const fileBlocks = findFileBlocks(message)
|
||||
for (const fileBlock of fileBlocks) {
|
||||
const file = fileBlock.file
|
||||
if (!file) {
|
||||
logger.warn(`No file in the file block. Passed.`, { fileBlock })
|
||||
continue
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
try {
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
if (fileContent) {
|
||||
parts.push({
|
||||
text: `${file.origin_name}\n${fileContent}`
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error reading file content:', error as Error)
|
||||
parts.push({ text: `[File: ${file.origin_name} - Failed to read content]` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有任何内容,添加默认文本而不是空文本
|
||||
if (parts.length === 0) {
|
||||
parts.push({ text: 'No content provided' })
|
||||
@@ -485,6 +752,38 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
// 获取推理预算token(对所有支持推理的模型)
|
||||
const budgetTokens = this.getBudgetToken(assistant, model)
|
||||
|
||||
// 构建基础自定义参数
|
||||
const customParams: Record<string, any> =
|
||||
coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {}
|
||||
|
||||
// 根据模型类型添加 thinking 参数
|
||||
if (budgetTokens) {
|
||||
const modelId = model.id.toLowerCase()
|
||||
|
||||
if (modelId.includes('claude')) {
|
||||
// Claude 模型使用 Anthropic 格式
|
||||
customParams.thinking = { type: 'enabled', budget_tokens: budgetTokens }
|
||||
} else if (modelId.includes('gpt') || modelId.includes('openai')) {
|
||||
// OpenAI 模型格式
|
||||
customParams.reasoning_effort = assistant?.settings?.reasoning_effort
|
||||
} else if (modelId.includes('llama')) {
|
||||
// Llama 模型格式
|
||||
customParams.thinking_mode = true
|
||||
customParams.thinking_tokens = budgetTokens
|
||||
} else if (modelId.includes('titan')) {
|
||||
// Titan 模型格式
|
||||
customParams.thinking = { enabled: true }
|
||||
customParams.thinking_tokens = budgetTokens
|
||||
} else if (modelId.includes('cohere') || modelId.includes('command')) {
|
||||
// Cohere 模型格式
|
||||
customParams.thinking = { enabled: true }
|
||||
customParams.reasoning_tokens = budgetTokens
|
||||
}
|
||||
}
|
||||
|
||||
const payload: AwsBedrockSdkParams = {
|
||||
modelId: model.id,
|
||||
messages:
|
||||
@@ -497,9 +796,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
topP: this.getTopP(assistant, model),
|
||||
stream: streamOutput !== false,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
||||
...customParams
|
||||
}
|
||||
|
||||
const timeout = this.getTimeout(model)
|
||||
@@ -511,6 +808,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<AwsBedrockSdkRawChunk> {
|
||||
return () => {
|
||||
let hasStartedText = false
|
||||
let hasStartedThinking = false
|
||||
let accumulatedJson = ''
|
||||
const toolCalls: Record<number, AwsBedrockSdkToolCall> = {}
|
||||
|
||||
@@ -570,6 +868,24 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
} as TextDeltaChunk)
|
||||
}
|
||||
|
||||
// 处理thinking增量
|
||||
if (
|
||||
rawChunk.contentBlockDelta?.delta?.type === 'thinking_delta' &&
|
||||
rawChunk.contentBlockDelta?.delta?.thinking
|
||||
) {
|
||||
if (!hasStartedThinking) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
hasStartedThinking = true
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: rawChunk.contentBlockDelta.delta.thinking
|
||||
} as ThinkingDeltaChunk)
|
||||
}
|
||||
|
||||
// 处理内容块停止事件 - 参考 Anthropic 的 content_block_stop 处理
|
||||
if (rawChunk.contentBlockStop) {
|
||||
const blockIndex = rawChunk.contentBlockStop.contentBlockIndex || 0
|
||||
@@ -708,4 +1024,49 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
extractMessagesFromSdkPayload(sdkPayload: AwsBedrockSdkParams): AwsBedrockSdkMessageParam[] {
|
||||
return sdkPayload.messages || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AWS Bedrock 的推理工作量预算token
|
||||
* @param assistant - The assistant
|
||||
* @param model - The model
|
||||
* @returns The budget tokens for reasoning effort
|
||||
*/
|
||||
private getBudgetToken(assistant: Assistant, model: Model): number | undefined {
|
||||
try {
|
||||
if (!isReasoningModel(model)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { maxTokens } = getAssistantSettings(assistant)
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
if (reasoningEffort === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
const tokenLimits = findTokenLimit(model.id)
|
||||
|
||||
if (tokenLimits) {
|
||||
// 使用模型特定的 token 限制
|
||||
const budgetTokens = Math.max(
|
||||
1024,
|
||||
Math.floor(
|
||||
Math.min(
|
||||
(tokenLimits.max - tokenLimits.min) * effortRatio + tokenLimits.min,
|
||||
(maxTokens || DEFAULT_MAX_TOKENS) * effortRatio
|
||||
)
|
||||
)
|
||||
)
|
||||
return budgetTokens
|
||||
} else {
|
||||
// 对于没有特定限制的模型,使用简化计算
|
||||
const budgetTokens = Math.max(1024, Math.floor((maxTokens || DEFAULT_MAX_TOKENS) * effortRatio))
|
||||
return budgetTokens
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to calculate budget tokens for reasoning effort:', error as Error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ import {
|
||||
GEMINI_FLASH_MODEL_REGEX,
|
||||
getOpenAIWebSearchParams,
|
||||
getThinkModelType,
|
||||
isClaudeReasoningModel,
|
||||
isDeepSeekHybridInferenceModel,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isGeminiReasoningModel,
|
||||
isGPT5SeriesModel,
|
||||
isGrokReasoningModel,
|
||||
isNotSupportSystemMessageModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isOpenAIReasoningModel,
|
||||
isQwenAlwaysThinkModel,
|
||||
isQwenMTModel,
|
||||
isQwenReasoningModel,
|
||||
@@ -23,7 +28,8 @@ import {
|
||||
isSupportedThinkingTokenQwenModel,
|
||||
isSupportedThinkingTokenZhipuModel,
|
||||
isVisionModel,
|
||||
MODEL_SUPPORTED_REASONING_EFFORT
|
||||
MODEL_SUPPORTED_REASONING_EFFORT,
|
||||
ZHIPU_RESULT_TOKENS
|
||||
} from '@renderer/config/models'
|
||||
import {
|
||||
isSupportArrayContentProvider,
|
||||
@@ -39,12 +45,14 @@ import {
|
||||
Assistant,
|
||||
EFFORT_RATIO,
|
||||
FileTypes,
|
||||
isSystemProvider,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
Model,
|
||||
OpenAIServiceTier,
|
||||
Provider,
|
||||
SystemProviderIds,
|
||||
ToolCallResponse,
|
||||
TranslateAssistant,
|
||||
WebSearchSource
|
||||
@@ -52,7 +60,6 @@ import {
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
MistralDeltaSchema,
|
||||
OpenAISdkMessageParam,
|
||||
OpenAISdkParams,
|
||||
OpenAISdkRawChunk,
|
||||
@@ -108,7 +115,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
*/
|
||||
// Method for reasoning effort, moved from OpenAIProvider
|
||||
override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams {
|
||||
if (this.provider.id === 'groq') {
|
||||
if (this.provider.id === SystemProviderIds.groq) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -117,22 +124,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
// Doubao 思考模式支持
|
||||
if (isSupportedThinkingTokenDoubaoModel(model)) {
|
||||
// reasoningEffort 为空,默认开启 enabled
|
||||
if (!reasoningEffort) {
|
||||
return { thinking: { type: 'disabled' } }
|
||||
}
|
||||
if (reasoningEffort === 'high') {
|
||||
return { thinking: { type: 'enabled' } }
|
||||
}
|
||||
if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
|
||||
return { thinking: { type: 'auto' } }
|
||||
}
|
||||
// 其他情况不带 thinking 字段
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||
if (!reasoningEffort) {
|
||||
return { thinking: { type: 'disabled' } }
|
||||
@@ -141,29 +132,41 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if (!reasoningEffort) {
|
||||
if (model.provider === 'openrouter') {
|
||||
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
|
||||
// 不同的 provider 有不同的思考控制方式,在这里统一解决
|
||||
// if (isDeepSeekHybridInferenceModel(model)) {
|
||||
// // do nothing for now. default to non-think.
|
||||
// }
|
||||
|
||||
// openrouter: use reasoning
|
||||
if (model.provider === SystemProviderIds.openrouter) {
|
||||
// Don't disable reasoning for Gemini models that support thinking tokens
|
||||
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
return {}
|
||||
}
|
||||
// Don't disable reasoning for models that require it
|
||||
if (isGrokReasoningModel(model)) {
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
|
||||
return {}
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
}
|
||||
|
||||
// providers that use enable_thinking
|
||||
if (
|
||||
isSupportEnableThinkingProvider(this.provider) &&
|
||||
(isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model))
|
||||
(isSupportedThinkingTokenQwenModel(model) ||
|
||||
isSupportedThinkingTokenHunyuanModel(model) ||
|
||||
(this.provider.id === SystemProviderIds.dashscope && isDeepSeekHybridInferenceModel(model)))
|
||||
) {
|
||||
return { enable_thinking: false }
|
||||
}
|
||||
|
||||
// claude
|
||||
if (isSupportedThinkingTokenClaudeModel(model)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// gemini
|
||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
return {
|
||||
@@ -192,8 +195,48 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min!
|
||||
)
|
||||
|
||||
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
|
||||
// 不同的 provider 有不同的思考控制方式,在这里统一解决
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
if (isSystemProvider(this.provider)) {
|
||||
switch (this.provider.id) {
|
||||
case SystemProviderIds.dashscope:
|
||||
return {
|
||||
enable_thinking: true,
|
||||
incremental_output: true
|
||||
}
|
||||
case SystemProviderIds.silicon:
|
||||
return {
|
||||
enable_thinking: true
|
||||
}
|
||||
case SystemProviderIds.doubao:
|
||||
return {
|
||||
thinking: {
|
||||
type: 'enabled' // auto is invalid
|
||||
}
|
||||
}
|
||||
case SystemProviderIds.openrouter:
|
||||
return {
|
||||
reasoning: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
case 'nvidia':
|
||||
return {
|
||||
chat_template_kwargs: {
|
||||
thinking: true
|
||||
}
|
||||
}
|
||||
default:
|
||||
logger.warn(
|
||||
`Skipping thinking options for provider ${this.provider.name} as DeepSeek v3.1 thinking control method is unknown`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter models
|
||||
if (model.provider === 'openrouter') {
|
||||
if (model.provider === SystemProviderIds.openrouter) {
|
||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||
return {
|
||||
reasoning: {
|
||||
@@ -203,6 +246,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
}
|
||||
|
||||
// Doubao 思考模式支持
|
||||
if (isSupportedThinkingTokenDoubaoModel(model)) {
|
||||
if (reasoningEffort === 'high') {
|
||||
return { thinking: { type: 'enabled' } }
|
||||
}
|
||||
if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
|
||||
return { thinking: { type: 'auto' } }
|
||||
}
|
||||
// 其他情况不带 thinking 字段
|
||||
return {}
|
||||
}
|
||||
|
||||
// Qwen models
|
||||
if (isQwenReasoningModel(model)) {
|
||||
const thinkConfig = {
|
||||
@@ -210,7 +265,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true,
|
||||
thinking_budget: budgetTokens
|
||||
}
|
||||
if (this.provider.id === 'dashscope') {
|
||||
if (this.provider.id === SystemProviderIds.dashscope) {
|
||||
return {
|
||||
...thinkConfig,
|
||||
incremental_output: true
|
||||
@@ -525,13 +580,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 1. 处理系统消息
|
||||
let systemMessage = { role: 'system', content: assistant.prompt || '' }
|
||||
const systemMessage = { role: 'system', content: assistant.prompt || '' }
|
||||
|
||||
if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
systemMessage = {
|
||||
role: isSupportDeveloperRoleProvider(this.provider) ? 'developer' : 'system',
|
||||
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
|
||||
}
|
||||
if (
|
||||
isSupportedReasoningEffortOpenAIModel(model) &&
|
||||
isSupportDeveloperRoleProvider(this.provider) &&
|
||||
!isOpenAIOpenWeightModel(model)
|
||||
) {
|
||||
systemMessage.role = 'developer'
|
||||
}
|
||||
|
||||
if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) {
|
||||
@@ -555,24 +611,46 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
userMessages.push(await this.convertMessageToSdkParam(message, model))
|
||||
}
|
||||
}
|
||||
if (userMessages.length === 0) {
|
||||
logger.warn('No user message. Some providers may not support.')
|
||||
}
|
||||
|
||||
// poe 需要通过用户消息传递 reasoningEffort
|
||||
const reasoningEffort = this.getReasoningEffort(assistant, model)
|
||||
|
||||
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
|
||||
if (
|
||||
lastUserMsg &&
|
||||
isSupportedThinkingTokenQwenModel(model) &&
|
||||
!isSupportEnableThinkingProvider(this.provider)
|
||||
) {
|
||||
const postsuffix = '/no_think'
|
||||
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
|
||||
const currentContent = lastUserMsg.content
|
||||
if (lastUserMsg) {
|
||||
if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) {
|
||||
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
|
||||
const currentContent = lastUserMsg.content
|
||||
|
||||
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, postsuffix, qwenThinkModeEnabled) as any
|
||||
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled)
|
||||
}
|
||||
if (this.provider.id === SystemProviderIds.poe) {
|
||||
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
|
||||
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) {
|
||||
lastUserMsg.content += ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
|
||||
} else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
|
||||
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
|
||||
} else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
|
||||
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 最终请求消息
|
||||
let reqMessages: OpenAISdkMessageParam[]
|
||||
if (!systemMessage.content || isNotSupportSystemMessageModel(model)) {
|
||||
if (!systemMessage.content) {
|
||||
reqMessages = [...userMessages]
|
||||
} else if (isNotSupportSystemMessageModel(model)) {
|
||||
// transform into user message
|
||||
const firstUserMsg = userMessages.shift()
|
||||
if (firstUserMsg) {
|
||||
firstUserMsg.content = `System Instruction: \n${systemMessage.content}\n\nUser Message(s):\n${firstUserMsg.content}`
|
||||
reqMessages = [firstUserMsg, ...userMessages]
|
||||
} else {
|
||||
reqMessages = []
|
||||
}
|
||||
} else {
|
||||
reqMessages = [systemMessage, ...userMessages].filter(Boolean) as OpenAISdkMessageParam[]
|
||||
}
|
||||
@@ -584,8 +662,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// Note: Some providers like Mistral don't support stream_options
|
||||
const shouldIncludeStreamOptions = streamOutput && isSupportStreamOptionsProvider(this.provider)
|
||||
|
||||
const reasoningEffort = this.getReasoningEffort(assistant, model)
|
||||
|
||||
// minimal cannot be used with web_search tool
|
||||
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort === 'minimal' && enableWebSearch) {
|
||||
reasoningEffort.reasoning_effort = 'low'
|
||||
@@ -805,8 +881,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
(typeof choice.delta.content === 'string' && choice.delta.content !== '') ||
|
||||
(typeof (choice.delta as any).reasoning_content === 'string' &&
|
||||
(choice.delta as any).reasoning_content !== '') ||
|
||||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '') ||
|
||||
Array.isArray(choice.delta.content))
|
||||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== ''))
|
||||
) {
|
||||
contentSource = choice.delta
|
||||
} else if ('message' in choice) {
|
||||
@@ -817,15 +892,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
if (!contentSource?.content) {
|
||||
accumulatingText = false
|
||||
}
|
||||
const mistralDelta = MistralDeltaSchema.safeParse(contentSource?.content)
|
||||
|
||||
if (
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
!contentSource?.reasoning_content &&
|
||||
// @ts-ignore - reasoning is not in standard OpenAI types but some providers use it
|
||||
!contentSource?.reasoning &&
|
||||
(mistralDelta.data?.[0]?.type !== 'thinking' || !mistralDelta.success)
|
||||
) {
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
if (!contentSource?.reasoning_content && !contentSource?.reasoning) {
|
||||
isThinking = false
|
||||
}
|
||||
|
||||
@@ -859,14 +927,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 处理推理内容 (e.g. from OpenRouter DeepSeek-R1)
|
||||
const reasoningText =
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
contentSource.reasoning_content ||
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
contentSource.reasoning ||
|
||||
(mistralDelta.data?.[0]?.type === 'thinking' ? mistralDelta.data?.[0]?.thinking[0]?.text : undefined)
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
if (reasoningText) {
|
||||
// logger.silly('since reasoningText is trusy, try to enqueue THINKING_START AND THINKING_DELTA')
|
||||
if (!isThinking) {
|
||||
// logger.silly('since isThinking is falsy, try to enqueue THINKING_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
@@ -883,35 +949,34 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if (mistralDelta.success && mistralDelta.data?.[0]?.type === 'text') {
|
||||
if (contentSource.content) {
|
||||
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
|
||||
if (!accumulatingText) {
|
||||
// logger.silly('enqueue TEXT_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
accumulatingText = true
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: mistralDelta.data?.[0]?.text
|
||||
})
|
||||
} else if (!mistralDelta.success) {
|
||||
if (contentSource.content) {
|
||||
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
|
||||
if (!accumulatingText) {
|
||||
// logger.silly('enqueue TEXT_START')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
accumulatingText = true
|
||||
}
|
||||
// logger.silly('enqueue TEXT_DELTA')
|
||||
// logger.silly('enqueue TEXT_DELTA')
|
||||
// 处理特殊token
|
||||
// 智谱api的一个chunk中只会输出一个token,因而使用 ===,避免正常内容被误判
|
||||
if (
|
||||
context.provider.id === SystemProviderIds.zhipu &&
|
||||
ZHIPU_RESULT_TOKENS.some((pattern) => contentSource.content === pattern)
|
||||
) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: '**' // strong
|
||||
})
|
||||
} else {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
})
|
||||
} else {
|
||||
accumulatingText = false
|
||||
}
|
||||
} else {
|
||||
accumulatingText = false
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
@@ -920,13 +985,19 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
if ('index' in toolCall) {
|
||||
const { id, index, function: fun } = toolCall
|
||||
if (fun?.name) {
|
||||
toolCalls[index] = {
|
||||
const toolCallObject = {
|
||||
id: id || '',
|
||||
function: {
|
||||
name: fun.name,
|
||||
arguments: fun.arguments || ''
|
||||
},
|
||||
type: 'function'
|
||||
type: 'function' as const
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
toolCalls.push(toolCallObject)
|
||||
} else {
|
||||
toolCalls[index] = toolCallObject
|
||||
}
|
||||
} else if (fun?.arguments) {
|
||||
if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isGPT5SeriesModel,
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAILLMModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
isSupportVerbosityModel,
|
||||
isVisionModel
|
||||
@@ -374,12 +375,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
text: assistant.prompt || '',
|
||||
type: 'input_text'
|
||||
}
|
||||
if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
if (isSupportDeveloperRoleProvider(this.provider)) {
|
||||
systemMessage.role = 'developer'
|
||||
} else {
|
||||
systemMessage.role = 'system'
|
||||
}
|
||||
if (
|
||||
isSupportedReasoningEffortOpenAIModel(model) &&
|
||||
isSupportDeveloperRoleProvider(this.provider) &&
|
||||
isOpenAIOpenWeightModel(model)
|
||||
) {
|
||||
systemMessage.role = 'developer'
|
||||
}
|
||||
|
||||
// 2. 设置工具
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient'
|
||||
import { isAnthropicModel } from '@renderer/config/models'
|
||||
import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk'
|
||||
|
||||
import { AnthropicStreamListener } from '../../clients/types'
|
||||
@@ -16,9 +15,8 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 在这里可以监听到从SDK返回的最原始流
|
||||
if (result.rawOutput) {
|
||||
const model = params.assistant.model
|
||||
// TODO: 后面下放到AnthropicAPIClient
|
||||
if (isAnthropicModel(model)) {
|
||||
if (ctx.apiClientInstance instanceof AnthropicAPIClient) {
|
||||
const anthropicListener: AnthropicStreamListener<AnthropicSdkRawChunk> = {
|
||||
onMessage: (message) => {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { getLowerBaseModelName } from '@renderer/utils'
|
||||
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
|
||||
|
||||
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
|
||||
@@ -22,13 +23,16 @@ const reasoningTags: TagConfig[] = [
|
||||
{ openingTag: '<thought>', closingTag: '</thought>', separator: '\n' },
|
||||
{ openingTag: '###Thinking', closingTag: '###Response', separator: '\n' },
|
||||
{ openingTag: '◁think▷', closingTag: '◁/think▷', separator: '\n' },
|
||||
{ openingTag: '<thinking>', closingTag: '</thinking>', separator: '\n' }
|
||||
{ openingTag: '<thinking>', closingTag: '</thinking>', separator: '\n' },
|
||||
{ openingTag: '<seed:think>', closingTag: '</seed:think>', separator: '\n' }
|
||||
]
|
||||
|
||||
const getAppropriateTag = (model?: Model): TagConfig => {
|
||||
if (model?.id?.includes('qwen3')) return reasoningTags[0]
|
||||
if (model?.id?.includes('gemini-2.5')) return reasoningTags[1]
|
||||
if (model?.id?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3]
|
||||
const modelId = model?.id ? getLowerBaseModelName(model.id) : undefined
|
||||
if (modelId?.includes('qwen3')) return reasoningTags[0]
|
||||
if (modelId?.includes('gemini-2.5')) return reasoningTags[1]
|
||||
if (modelId?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3]
|
||||
if (modelId?.includes('seed-oss-36b')) return reasoningTags[5]
|
||||
// 可以在这里添加更多模型特定的标签配置
|
||||
return reasoningTags[0] // 默认使用 <think> 标签
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ export interface CompletionsParams {
|
||||
* 'search': 搜索摘要
|
||||
* 'generate': 生成
|
||||
* 'check': API检查
|
||||
* 'test': 测试调用
|
||||
* 'translate-lang-detect': 翻译语言检测
|
||||
*/
|
||||
callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check' | 'test'
|
||||
callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check' | 'test' | 'translate-lang-detect'
|
||||
|
||||
// 基础对话数据
|
||||
messages: Message[] | string // 联合类型方便判断是否为空
|
||||
|
||||
BIN
src/renderer/src/assets/images/providers/Tesseract.js.png
Normal file
BIN
src/renderer/src/assets/images/providers/Tesseract.js.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Poe</title><path d="M20.708 6.876a1.412 1.412 0 00-1.029-.415h-.006a2.019 2.019 0 01-2.02-2.023A1.415 1.415 0 0016.254 3H4.871A1.412 1.412 0 003.47 4.434a2.026 2.026 0 01-2.025 2.025v.002A1.414 1.414 0 000 7.883v3.642a1.414 1.414 0 001.444 1.42 2.025 2.025 0 012.025 2.02v3.693a.5.5 0 00.89.313l2.051-2.567h9.843a1.412 1.412 0 001.4-1.434v-.002c0-1.12.904-2.025 2.026-2.025a1.412 1.412 0 001.446-1.42V7.88c0-.363-.14-.727-.417-1.005zm-2.42 4.687a2.025 2.025 0 01-2.025 2.005H4.861a2.025 2.025 0 01-2.025-2.005v-3.72A2.026 2.026 0 014.86 5.838h11.4a2.026 2.026 0 012.026 2.005v3.72h.002z"></path><path d="M7.413 7.57A1.422 1.422 0 005.99 8.99v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422zm6.297 0a1.422 1.422 0 00-1.422 1.421v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422z"></path><path d="M7.292 22.643l1.993-2.492h9.844a1.413 1.413 0 001.4-1.434 2.025 2.025 0 012.017-2.027h.01A1.409 1.409 0 0024 15.27v-3.594c0-.344-.113-.68-.324-.951l-.397-.519v4.127a1.415 1.415 0 01-1.444 1.42h-.007a2.026 2.026 0 00-2.018 2.025 1.415 1.415 0 01-1.402 1.436H8.565l-2.169 2.712a.574.574 0 00.896.715v.002z" fill="url(#lobe-icons-poe-fill-0)"></path><path d="M5.004 19.992l2.12-2.65h9.844a1.414 1.414 0 001.402-1.437c0-1.116.9-2.021 2.014-2.025h.012a1.413 1.413 0 001.443-1.422v-4.13l.52.68c.21.273.324.607.324.95v3.594a1.416 1.416 0 01-1.443 1.42h-.01a2.026 2.026 0 00-2.016 2.026 1.414 1.414 0 01-1.402 1.435H7.97l-1.916 2.4a.671.671 0 01-1.049-.839v-.002z" fill="url(#lobe-icons-poe-fill-1)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-poe-fill-0" x1="34.01" x2="1.086" y1="7.303" y2="27.715"><stop stop-color="#46A6F7"></stop><stop offset="1" stop-color="#8364FF"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-poe-fill-1" x1="4.915" x2="24.34" y1="23.511" y2="9.464"><stop stop-color="#FF44D3"></stop><stop offset="1" stop-color="#CF4BFF"></stop></linearGradient></defs></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -68,3 +68,23 @@
|
||||
transform-origin: center;
|
||||
animation: animation-rotate 0.75s linear infinite;
|
||||
}
|
||||
|
||||
// 定位高亮动画
|
||||
@keyframes animation-locate-highlight {
|
||||
0% {
|
||||
background-color: transparent;
|
||||
}
|
||||
10% {
|
||||
background-color: var(--color-primary-mute);
|
||||
}
|
||||
70% {
|
||||
background-color: var(--color-primary-mute);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.animation-locate-highlight {
|
||||
animation: animation-locate-highlight 2.5s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
@use './container.scss';
|
||||
|
||||
/* Modal 关闭按钮不应该可拖拽,以确保点击正常 */
|
||||
.ant-modal-close {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* 普通 Drawer 内容不应该可拖拽 */
|
||||
.ant-drawer-content {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* minapp-drawer 有自己的拖拽规则 */
|
||||
|
||||
/* 下拉菜单和弹出框内容不应该可拖拽 */
|
||||
.ant-dropdown,
|
||||
.ant-dropdown-menu,
|
||||
.ant-popover-content,
|
||||
.ant-tooltip-content,
|
||||
.ant-popconfirm {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
#inputbar {
|
||||
resize: none;
|
||||
}
|
||||
@@ -66,6 +87,7 @@
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
/* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@@ -76,7 +98,7 @@
|
||||
}
|
||||
|
||||
.ant-dropdown-menu .ant-dropdown-menu-sub {
|
||||
max-height: 50vh;
|
||||
max-height: 80vh;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -88,7 +110,7 @@
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
user-select: none;
|
||||
.ant-dropdown-menu {
|
||||
max-height: 50vh;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
@@ -148,6 +170,7 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ant-modal-body {
|
||||
/* 保持 body 在视口内,使用标准的最大高度 */
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 0 16px;
|
||||
@@ -184,3 +207,28 @@
|
||||
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-splitter-bar {
|
||||
.ant-splitter-bar-dragger {
|
||||
&::before {
|
||||
background-color: var(--color-border) !important;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
width 0.15s ease;
|
||||
}
|
||||
&:hover {
|
||||
&::before {
|
||||
width: 4px !important;
|
||||
background-color: var(--color-primary) !important;
|
||||
transition-delay: 0.15s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-splitter-bar-dragger-active {
|
||||
&::before {
|
||||
width: 4px !important;
|
||||
background-color: var(--color-primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.katex span {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -260,10 +264,6 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
|
||||
@@ -6,10 +6,8 @@ html {
|
||||
|
||||
:root {
|
||||
// Basic Colors
|
||||
--color-primary: #00b96b;
|
||||
--color-error: #f44336;
|
||||
|
||||
--selection-toolbar-color-primary: var(--color-primary);
|
||||
--selection-toolbar-color-error: var(--color-error);
|
||||
|
||||
// Toolbar
|
||||
@@ -54,8 +52,6 @@ html {
|
||||
|
||||
--selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
|
||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-bgcolor: transparent; // default: transparent
|
||||
--selection-toolbar-button-bgcolor-hover: #333333;
|
||||
}
|
||||
@@ -72,7 +68,5 @@ html {
|
||||
|
||||
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
|
||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { CodeOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { Code, Download, Globe, Sparkles } from 'lucide-react'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClipLoader } from 'react-spinners'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
@@ -14,92 +14,10 @@ import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
||||
|
||||
const logger = loggerService.withContext('HtmlArtifactsCard')
|
||||
|
||||
const HTML_VOID_ELEMENTS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
])
|
||||
|
||||
const HTML_COMPLETION_PATTERNS = [
|
||||
/<\/html\s*>/i,
|
||||
/<!DOCTYPE\s+html/i,
|
||||
/<\/body\s*>/i,
|
||||
/<\/div\s*>/i,
|
||||
/<\/script\s*>/i,
|
||||
/<\/style\s*>/i
|
||||
]
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
function hasUnmatchedTags(html: string): boolean {
|
||||
const stack: string[] = []
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
let match
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const [fullTag, tagName] = match
|
||||
const isClosing = fullTag.startsWith('</')
|
||||
const isSelfClosing = fullTag.endsWith('/>') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
|
||||
|
||||
if (isSelfClosing) continue
|
||||
|
||||
if (isClosing) {
|
||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
stack.push(tagName.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length > 0
|
||||
}
|
||||
|
||||
function checkIsStreaming(html: string): boolean {
|
||||
if (!html?.trim()) return false
|
||||
|
||||
const trimmed = html.trim()
|
||||
|
||||
// 快速检查:如果有明显的完成标志,直接返回false
|
||||
for (const pattern of HTML_COMPLETION_PATTERNS) {
|
||||
if (pattern.test(trimmed)) {
|
||||
// 特殊情况:同时有DOCTYPE和</body>
|
||||
if (trimmed.includes('<!DOCTYPE') && /<\/body\s*>/i.test(trimmed)) {
|
||||
return false
|
||||
}
|
||||
// 如果只是以</html>结尾,也认为是完成的
|
||||
if (/<\/html\s*>$/i.test(trimmed)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查未完成的标志
|
||||
const hasIncompleteTag = /<[^>]*$/.test(trimmed)
|
||||
const hasUnmatched = hasUnmatchedTags(trimmed)
|
||||
|
||||
if (hasIncompleteTag || hasUnmatched) return true
|
||||
|
||||
// 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成
|
||||
const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed)
|
||||
if (!hasStructureTags && trimmed.length < 500) {
|
||||
return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed))
|
||||
}
|
||||
|
||||
return false
|
||||
onSave?: (html: string) => void
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
const getTerminalStyles = (theme: ThemeMode) => ({
|
||||
@@ -108,7 +26,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({
|
||||
promptColor: theme === 'dark' ? '#00ff00' : '#007700'
|
||||
})
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
@@ -116,7 +34,6 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
|
||||
const htmlContent = html || ''
|
||||
const hasContent = htmlContent.trim().length > 0
|
||||
const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent])
|
||||
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
@@ -181,10 +98,10 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="text" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
|
||||
<Button icon={<LinkIcon size={14} />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
<Button icon={<Download size={16} />} onClick={handleDownload} type="text" disabled={!hasContent}>
|
||||
<Button icon={<DownloadIcon size={14} />} onClick={handleDownload} type="text" disabled={!hasContent}>
|
||||
{t('code_block.download.label')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
@@ -192,7 +109,13 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
</Content>
|
||||
</Container>
|
||||
|
||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={() => setIsPopupOpen(false)} />
|
||||
<HtmlArtifactsPopup
|
||||
open={isPopupOpen}
|
||||
title={title}
|
||||
html={htmlContent}
|
||||
onSave={onSave}
|
||||
onClose={() => setIsPopupOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -234,6 +157,7 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: ${(props) =>
|
||||
@@ -254,13 +178,16 @@ const TitleSection = styled.div`
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 0 !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
const Title = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.4;
|
||||
font-family: 'Ubuntu';
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const TypeBadge = styled.div`
|
||||
@@ -286,7 +213,6 @@ const ButtonContainer = styled.div`
|
||||
margin: 10px 16px !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
@@ -294,7 +220,7 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
background: ${(props) => getTerminalStyles(props.$theme).background};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
font-family: var(--code-font-family);
|
||||
`
|
||||
|
||||
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal } from 'antd'
|
||||
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
||||
import { Button, Modal, Splitter, Tooltip, Typography } from 'antd'
|
||||
import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -11,60 +11,17 @@ interface HtmlArtifactsPopupProps {
|
||||
open: boolean
|
||||
title: string
|
||||
html: string
|
||||
onSave?: (html: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = 'split' | 'code' | 'preview'
|
||||
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onSave, onClose }) => {
|
||||
const { t } = useTranslation()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// Preview refresh related state
|
||||
const [previewHtml, setPreviewHtml] = useState(html)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const latestHtmlRef = useRef(html)
|
||||
const currentPreviewHtmlRef = useRef(html)
|
||||
|
||||
// Sync internal state when external html updates
|
||||
useEffect(() => {
|
||||
setCurrentHtml(html)
|
||||
latestHtmlRef.current = html
|
||||
}, [html])
|
||||
|
||||
// Update reference when internally edited html changes
|
||||
useEffect(() => {
|
||||
latestHtmlRef.current = currentHtml
|
||||
}, [currentHtml])
|
||||
|
||||
// Update reference when preview content changes
|
||||
useEffect(() => {
|
||||
currentPreviewHtmlRef.current = previewHtml
|
||||
}, [previewHtml])
|
||||
|
||||
// Check and refresh preview every 2 seconds (only when content changes)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
// Set initial preview content immediately
|
||||
setPreviewHtml(latestHtmlRef.current)
|
||||
|
||||
// Set timer to check for content changes every 2 seconds
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (latestHtmlRef.current !== currentPreviewHtmlRef.current) {
|
||||
setPreviewHtml(latestHtmlRef.current)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
const codeEditorRef = useRef<CodeEditorHandles>(null)
|
||||
|
||||
// Prevent body scroll when fullscreen
|
||||
useEffect(() => {
|
||||
@@ -79,13 +36,14 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
}
|
||||
}, [isFullscreen, open])
|
||||
|
||||
const showCode = viewMode === 'split' || viewMode === 'code'
|
||||
const showPreview = viewMode === 'split' || viewMode === 'preview'
|
||||
const handleSave = () => {
|
||||
codeEditorRef.current?.save?.()
|
||||
}
|
||||
|
||||
const renderHeader = () => (
|
||||
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
<TitleText ellipsis={{ tooltip: true }}>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderCenter>
|
||||
@@ -93,7 +51,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'split' ? 'primary' : 'default'}
|
||||
icon={<MonitorSpeaker size={14} />}
|
||||
icon={<SquareSplitHorizontal size={14} />}
|
||||
onClick={() => setViewMode('split')}>
|
||||
{t('html_artifacts.split')}
|
||||
</ViewButton>
|
||||
@@ -107,7 +65,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'preview' ? 'primary' : 'default'}
|
||||
icon={<Monitor size={14} />}
|
||||
icon={<Eye size={14} />}
|
||||
onClick={() => setViewMode('preview')}>
|
||||
{t('html_artifacts.preview')}
|
||||
</ViewButton>
|
||||
@@ -126,6 +84,75 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
</ModalHeader>
|
||||
)
|
||||
|
||||
const renderContent = () => {
|
||||
const codePanel = (
|
||||
<CodeSection>
|
||||
<CodeEditor
|
||||
ref={codeEditorRef}
|
||||
value={html}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={onSave}
|
||||
style={{ height: '100%' }}
|
||||
expanded
|
||||
unwrapped={false}
|
||||
options={{
|
||||
stream: true, // FIXME: 避免多余空行
|
||||
lineNumbers: true,
|
||||
keymap: true
|
||||
}}
|
||||
/>
|
||||
<ToolbarWrapper>
|
||||
<Tooltip title={t('code_block.edit.save.label')} mouseLeaveDelay={0}>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<SaveIcon size={16} className="custom-lucide" />}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ToolbarWrapper>
|
||||
</CodeSection>
|
||||
)
|
||||
|
||||
const previewPanel = (
|
||||
<PreviewSection>
|
||||
{html.trim() ? (
|
||||
<PreviewFrame
|
||||
key={html} // Force recreate iframe when preview content changes
|
||||
srcDoc={html}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
) : (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)
|
||||
|
||||
switch (viewMode) {
|
||||
case 'split':
|
||||
return (
|
||||
<Splitter>
|
||||
<Splitter.Panel defaultSize="50%" min="25%">
|
||||
{codePanel}
|
||||
</Splitter.Panel>
|
||||
<Splitter.Panel defaultSize="50%" min="25%">
|
||||
{previewPanel}
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
)
|
||||
case 'code':
|
||||
return codePanel
|
||||
case 'preview':
|
||||
return previewPanel
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
$isFullscreen={isFullscreen}
|
||||
@@ -144,41 +171,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
zIndex={isFullscreen ? 10000 : 1000}
|
||||
footer={null}
|
||||
closable={false}>
|
||||
<Container>
|
||||
{showCode && (
|
||||
<CodeSection>
|
||||
<CodeEditor
|
||||
value={currentHtml}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={setCurrentHtml}
|
||||
style={{ height: '100%' }}
|
||||
expanded
|
||||
unwrapped={false}
|
||||
options={{
|
||||
stream: false
|
||||
}}
|
||||
/>
|
||||
</CodeSection>
|
||||
)}
|
||||
|
||||
{showPreview && (
|
||||
<PreviewSection>
|
||||
{previewHtml.trim() ? (
|
||||
<PreviewFrame
|
||||
key={previewHtml} // Force recreate iframe when preview content changes
|
||||
srcDoc={previewHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
) : (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)}
|
||||
</Container>
|
||||
<Container>{renderContent()}</Container>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
@@ -213,7 +206,6 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
: `
|
||||
.ant-modal-body {
|
||||
height: 80vh !important;
|
||||
min-height: 600px !important;
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -238,6 +230,10 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
margin-bottom: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const ModalHeader = styled.div`
|
||||
@@ -270,13 +266,13 @@ const HeaderRight = styled.div<{ $isFullscreen?: boolean }>`
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
|
||||
`
|
||||
|
||||
const TitleText = styled.span`
|
||||
const TitleText = styled(Typography.Text)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 50%;
|
||||
`
|
||||
|
||||
const ViewControls = styled.div`
|
||||
@@ -315,13 +311,24 @@ const Container = styled.div`
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: var(--color-background);
|
||||
overflow: hidden;
|
||||
|
||||
.ant-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
|
||||
.ant-splitter-pane {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CodeSection = styled.div`
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.monaco-editor,
|
||||
.cm-editor,
|
||||
@@ -331,8 +338,8 @@ const CodeSection = styled.div`
|
||||
`
|
||||
|
||||
const PreviewSection = styled.div`
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
`
|
||||
@@ -355,4 +362,15 @@ const EmptyPreview = styled.div`
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const ToolbarWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
gap: 4px;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as HtmlArtifactsCard } from './HtmlArtifactsCard'
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
|
||||
@@ -18,15 +18,14 @@ import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { getExtensionByLanguage } from '@renderer/utils/code-language'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { getExtensionByLanguage, isHtmlCode } from '@renderer/utils/markdown'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
||||
import StatusBar from './StatusBar'
|
||||
import { ViewMode } from './types'
|
||||
|
||||
@@ -301,11 +300,6 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
)
|
||||
}, [specialView, sourceView, viewMode])
|
||||
|
||||
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
|
||||
if (language === 'html' && isHtmlCode(children)) {
|
||||
return <HtmlArtifactsCard html={children} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { loggerService } from '@logger'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { Extension, keymap } from '@uiw/react-codemirror'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { getNormalizedExtension } from './utils'
|
||||
|
||||
const logger = loggerService.withContext('CodeEditorHooks')
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
@@ -17,32 +18,33 @@ const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
|
||||
/**
|
||||
* 特殊语言加载器
|
||||
* key: 语言文件扩展名(不包含 `.`)
|
||||
*/
|
||||
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
|
||||
dot: async () => {
|
||||
const mod = await import('@viz-js/lang-dot')
|
||||
return mod.dot()
|
||||
},
|
||||
// @uiw/codemirror-extensions-langs 4.25.1 移除了 mermaid 支持,这里加回来
|
||||
mmd: async () => {
|
||||
const mod = await import('codemirror-lang-mermaid')
|
||||
return mod.mermaid()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言扩展
|
||||
*/
|
||||
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
async function loadLanguageExtension(language: string): Promise<Extension | null> {
|
||||
const fileExt = await getNormalizedExtension(language)
|
||||
|
||||
// 尝试加载特殊语言
|
||||
const specialLoader = specialLanguageLoaders[normalizedLang]
|
||||
const specialLoader = specialLanguageLoaders[fileExt]
|
||||
if (specialLoader) {
|
||||
try {
|
||||
return await specialLoader()
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to load language ${normalizedLang}`, error as Error)
|
||||
logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -50,10 +52,10 @@ async function loadLanguageExtension(language: string, languageMap: Record<strin
|
||||
// 回退到 uiw/codemirror 包含的语言
|
||||
try {
|
||||
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
const extension = loadLanguage(fileExt as any)
|
||||
return extension || null
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to load language ${normalizedLang}`, error as Error)
|
||||
logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -77,7 +79,6 @@ async function loadLinterExtension(language: string): Promise<Extension | null>
|
||||
* 加载语言相关扩展
|
||||
*/
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,7 +88,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
try {
|
||||
// 加载所有扩展
|
||||
const [languageResult, linterResult] = await Promise.allSettled([
|
||||
loadLanguageExtension(language, languageMap),
|
||||
loadLanguageExtension(language),
|
||||
lint ? loadLinterExtension(language) : Promise.resolve(null)
|
||||
])
|
||||
|
||||
@@ -119,7 +120,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [language, lint, languageMap])
|
||||
}, [language, lint])
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
34
src/renderer/src/components/CodeEditor/utils.ts
Normal file
34
src/renderer/src/components/CodeEditor/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getExtensionByLanguage } from '@renderer/utils/code-language'
|
||||
|
||||
// 自定义语言文件扩展名映射
|
||||
// key: 语言名小写
|
||||
// value: 扩展名
|
||||
const _customLanguageExtensions: Record<string, string> = {
|
||||
svg: 'xml',
|
||||
vab: 'vb',
|
||||
graphviz: 'dot'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs
|
||||
* - 先搜索自定义扩展名
|
||||
* - 再搜索 github linguist 扩展名
|
||||
* @param language 语言名称
|
||||
* @returns 扩展名(不包含 `.`)
|
||||
*/
|
||||
export async function getNormalizedExtension(language: string) {
|
||||
const lowerLanguage = language.toLowerCase()
|
||||
|
||||
const customExt = _customLanguageExtensions[lowerLanguage]
|
||||
if (customExt) {
|
||||
return customExt
|
||||
}
|
||||
|
||||
const linguistExt = getExtensionByLanguage(language)
|
||||
if (linguistExt) {
|
||||
return linguistExt.slice(1)
|
||||
}
|
||||
|
||||
// 回退到语言名称
|
||||
return language
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import { Input, InputRef, Tooltip } from 'antd'
|
||||
import { Search } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface CollapsibleSearchBarProps {
|
||||
onSearch: (text: string) => void
|
||||
placeholder?: string
|
||||
tooltip?: string
|
||||
icon?: React.ReactNode
|
||||
maxWidth?: string | number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsible search bar for list headers
|
||||
* Renders as an icon initially, expands to full search input when clicked
|
||||
*/
|
||||
const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, icon, maxWidth }) => {
|
||||
const { t } = useTranslation()
|
||||
const CollapsibleSearchBar = ({
|
||||
onSearch,
|
||||
placeholder = i18n.t('common.search'),
|
||||
tooltip = i18n.t('common.search'),
|
||||
icon = <Search size={14} color="var(--color-icon)" />,
|
||||
maxWidth = '100%',
|
||||
style
|
||||
}: CollapsibleSearchBarProps) => {
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
@@ -46,22 +55,23 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
|
||||
initial="collapsed"
|
||||
animate={searchVisible ? 'expanded' : 'collapsed'}
|
||||
variants={{
|
||||
expanded: { maxWidth: maxWidth || '100%', opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
expanded: { maxWidth: maxWidth, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
|
||||
}}
|
||||
style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t('models.search')}
|
||||
placeholder={placeholder}
|
||||
size="small"
|
||||
suffix={icon || <Search size={14} color="var(--color-icon)" />}
|
||||
suffix={icon}
|
||||
value={searchText}
|
||||
autoFocus
|
||||
allowClear
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
handleTextChange('')
|
||||
if (!searchText) setSearchVisible(false)
|
||||
}
|
||||
@@ -70,7 +80,7 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
|
||||
if (!searchText) setSearchVisible(false)
|
||||
}}
|
||||
onClear={handleClear}
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: '100%', ...style }}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -82,8 +92,8 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
|
||||
}}
|
||||
style={{ cursor: 'pointer', display: 'flex' }}
|
||||
onClick={() => setSearchVisible(true)}>
|
||||
<Tooltip title={t('models.search')} mouseLeaveDelay={0}>
|
||||
{icon || <Search size={14} color="var(--color-icon)" />}
|
||||
<Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
{icon}
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -282,6 +282,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
implementation.searchNext()
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
implementation.disable()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -29,14 +29,14 @@ vi.mock('@hello-pangea/dnd', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// mock VirtualList 只做简单渲染
|
||||
vi.mock('rc-virtual-list', () => ({
|
||||
// mock antd list 只做简单渲染
|
||||
vi.mock('antd', () => ({
|
||||
__esModule: true,
|
||||
default: ({ data, itemKey, children }: any) => (
|
||||
List: ({ dataSource, renderItem }: any) => (
|
||||
<div data-testid="virtual-list">
|
||||
{data.map((item: any, idx: number) => (
|
||||
<div key={item[itemKey] || item} data-testid="virtual-list-item">
|
||||
{children(item, idx)}
|
||||
{dataSource.map((item: any, idx: number) => (
|
||||
<div key={item.id || item} data-testid="virtual-list-item">
|
||||
{renderItem(item, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,8 +157,7 @@ describe('DraggableList', () => {
|
||||
|
||||
// 模拟拖拽到自身
|
||||
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(list)
|
||||
expect(onUpdate).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,8 +174,7 @@ describe('DraggableList', () => {
|
||||
|
||||
// 拖拽自身
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(list)
|
||||
expect(onUpdate).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('should not crash if callbacks are undefined', () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/// <reference types="@vitest/browser/context" />
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -47,11 +45,6 @@ vi.mock('@tanstack/react-virtual', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('react-virtualized-auto-sizer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }) => <div data-testid="auto-sizer">{children({ height: 500, width: 300 })}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Scrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ ref, children, ...props }) => (
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
ResponderProvided
|
||||
} from '@hello-pangea/dnd'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import { List, ListProps } from 'antd'
|
||||
import { FC } from 'react'
|
||||
|
||||
interface Props<T> {
|
||||
list: T[]
|
||||
style?: React.CSSProperties
|
||||
listStyle?: React.CSSProperties
|
||||
listProps?: ListProps<T>
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
onUpdate: (list: T[]) => void
|
||||
onDragStart?: OnDragStartResponder
|
||||
@@ -28,6 +29,7 @@ const DraggableList: FC<Props<any>> = ({
|
||||
list,
|
||||
style,
|
||||
listStyle,
|
||||
listProps,
|
||||
droppableProps,
|
||||
onDragStart,
|
||||
onUpdate,
|
||||
@@ -38,8 +40,10 @@ const DraggableList: FC<Props<any>> = ({
|
||||
if (result.destination) {
|
||||
const sourceIndex = result.source.index
|
||||
const destIndex = result.destination.index
|
||||
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||
onUpdate(reorderAgents)
|
||||
if (sourceIndex !== destIndex) {
|
||||
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||
onUpdate(reorderAgents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +52,10 @@ const DraggableList: FC<Props<any>> = ({
|
||||
<Droppable droppableId="droppable" {...droppableProps}>
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
|
||||
<VirtualList data={list} itemKey="id">
|
||||
{(item, index) => {
|
||||
<List
|
||||
{...listProps}
|
||||
dataSource={list}
|
||||
renderItem={(item, index) => {
|
||||
const id = item.id || item
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
@@ -69,7 +75,7 @@ const DraggableList: FC<Props<any>> = ({
|
||||
</Draggable>
|
||||
)
|
||||
}}
|
||||
</VirtualList>
|
||||
/>
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -82,8 +82,10 @@ function DraggableVirtualList<T>({
|
||||
if (onUpdate && result.destination) {
|
||||
const sourceIndex = result.source.index
|
||||
const destIndex = result.destination.index
|
||||
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||
onUpdate(reorderAgents)
|
||||
if (sourceIndex !== destIndex) {
|
||||
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||
onUpdate(reorderAgents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ const EditableNumber: FC<EditableNumberProps> = ({
|
||||
if (e.key === 'Enter') {
|
||||
handleBlur()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
setInputValue(value)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
57
src/renderer/src/components/ErrorBoundary.tsx
Normal file
57
src/renderer/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Alert, Button, Space } from 'antd'
|
||||
import { ComponentType, ReactNode } from 'react'
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const DefaultFallback: ComponentType<FallbackProps> = (props: FallbackProps): ReactNode => {
|
||||
const { t } = useTranslation()
|
||||
const { error } = props
|
||||
const debug = async () => {
|
||||
await window.api.devTools.toggle()
|
||||
}
|
||||
const reload = async () => {
|
||||
await window.api.reload()
|
||||
}
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<Alert
|
||||
message={t('error.boundary.default.message')}
|
||||
showIcon
|
||||
description={formatErrorMessage(error)}
|
||||
type="error"
|
||||
action={
|
||||
<Space>
|
||||
<Button size="small" danger onClick={debug}>
|
||||
{t('error.boundary.default.devtools')}
|
||||
</Button>
|
||||
<Button size="small" danger onClick={reload}>
|
||||
{t('error.boundary.default.reload')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</ErrorContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorBoundaryCustomized = ({
|
||||
children,
|
||||
fallbackComponent
|
||||
}: {
|
||||
children: ReactNode
|
||||
fallbackComponent?: ComponentType<FallbackProps>
|
||||
}) => {
|
||||
return <ErrorBoundary FallbackComponent={fallbackComponent ?? DefaultFallback}>{children}</ErrorBoundary>
|
||||
}
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
`
|
||||
|
||||
export { ErrorBoundaryCustomized as ErrorBoundary }
|
||||
@@ -112,3 +112,174 @@ export function MdiLightbulbOn(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BingLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<path d="M4.842.005a.966.966 0 01.604.142l2.62 1.813c.369.256.492.352.637.496.471.47.752 1.09.797 1.765l.008.847.003 1.441.004 13.002.144-.094 7.015-4.353.015.003.029.01c-.398-.17-.893-.339-1.655-.566l-.484-.146c-.584-.18-.71-.238-.921-.38a2.009 2.009 0 01-.37-.312 2.172 2.172 0 01-.41-.592L11.32 9.063c-.166-.444-.166-.49-.156-.63a.92.92 0 01.806-.864l.094-.01c.044-.005.22.023.29.044l.052.021c.06.026.16.075.313.154l3.63 1.908a6.626 6.626 0 013.292 4.531c.194.99.159 2.037-.102 3.012-.216.805-.639 1.694-1.054 2.213l-.08.099-.047.05c-.01.01-.013.01-.01.002l.043-.074-.072.114c-.011.031-.233.28-.38.425l-.17.161c-.22.202-.431.36-.832.62L13.544 23c-.941.6-1.86.912-2.913.992-.23.018-.854.008-1.074-.017a6.31 6.31 0 01-1.658-.412c-1.854-.738-3.223-2.288-3.705-4.195a8.077 8.077 0 01-.121-.57l-.046-.325a1.123 1.123 0 01-.014-.168l-.006-.029L4 11.617 4.01.866a.981.981 0 01.007-.111.943.943 0 01.825-.75z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearXNGLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 265 265" style={{ display: 'block' }} {...props}>
|
||||
<g transform="translate(-40.921 -17.417)">
|
||||
<circle
|
||||
cx="142.2"
|
||||
cy="122.9"
|
||||
r="85"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="28.3465"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="11.3386"
|
||||
/>
|
||||
<path
|
||||
d="M118.4 77.6c19.8-10.2 44-6.4 59.7 9.4s19.3 40 8.9 59.7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="14.1732"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="11.3386"
|
||||
/>
|
||||
<path d="m184.2 202 37-38.6 81.8 78.3-37 38.6z" fill="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TavilyLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="m16.44.964 4.921 7.79c.79 1.252-.108 2.883-1.588 2.883H17.76V23.3h-2.91V.088c.61 0 1.22.292 1.59.876z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8.342 8.755 13.263.964a1.864 1.864 0 0 1 1.59-.876V23.3a4.87 4.87 0 0 0-.252-.006c-.99 0-1.907.311-2.658.842V11.637H9.93c-1.48 0-2.38-1.631-1.589-2.882z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M30.278 31H18.031a4.596 4.596 0 0 0 1.219-2.91h22.577c0 .61-.292 1.22-.875 1.59L33.16 34.6c-1.251.791-2.883-.108-2.883-1.588V31z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="m33.16 21.581 7.79 4.921c.585.369.876.979.876 1.589H19.25a4.619 4.619 0 0 0-.858-2.91h11.887V23.17c0-1.48 1.631-2.38 2.882-1.589z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="m8.24 34.25-7.107 7.108a1.864 1.864 0 0 0 1.742.504l8.989-2.03c1.443-.325 1.961-2.114.915-3.16l-1.423-1.423 5.356-5.356a2.805 2.805 0 0 0 0-3.966l-.074-.075L8.24 34.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="m7.243 31.135 5.355-5.356a2.805 2.805 0 0 1 3.967 0l.074.074-8.397 8.397-7.108 7.108a1.864 1.864 0 0 1-.504-1.742l2.029-8.989c.325-1.444 2.115-1.961 3.161-.915l1.423 1.423z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExaLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<title>Exa</title>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M3 0h19v1.791L13.892 12 22 22.209V24H3V0zm9.62 10.348l6.589-8.557H6.03l6.59 8.557zM5.138 3.935v7.17h5.52l-5.52-7.17zm5.52 8.96h-5.52v7.17l5.52-7.17zM6.03 22.21l6.59-8.557 6.589 8.557H6.03z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BochaLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 135 116" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.5754 13.8123C24.6109 7.94459 39.1223 12.9435 44.9955 24.9805L57.5355 50.6805C60.4695 56.6936 57.9756 63.9478 51.9652 66.8832C51.9627 66.8844 51.9602 66.8856 51.9577 66.8868C45.94 69.8206 38.6843 67.3212 35.7477 61.3027L12.5754 13.8123Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.64774"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 38.3013C9.46916 28.836 24.813 28.836 34.2822 38.3013L55.2526 59.2631C59.9819 63.9904 59.9852 71.6582 55.2601 76.3896C55.2576 76.3921 55.2551 76.3946 55.2526 76.397C50.5181 81.1297 42.8461 81.1297 38.1116 76.397L0 38.3013Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M86.8777 18.0444C113.939 18.0444 135.876 39.9725 135.876 67.0222C135.876 80.2286 129.086 93.6477 120.585 102.457L117.065 98.2367C111.026 90.9998 108.882 81.2777 111.314 72.1702C111.755 70.5198 111.976 69.0033 111.976 67.6209C111.976 53.6689 100.661 42.3586 86.7029 42.3586C72.7452 42.3586 61.4303 53.6689 61.4303 67.6209C61.4303 81.5728 72.7452 92.8831 86.7029 92.8831C89.3159 92.8831 91.8363 92.4867 94.2071 91.7508C101.312 89.5455 109.054 91.3768 114.419 96.5322L120.585 102.457C111.83 110.626 99.7992 116 86.8777 116C59.8168 116 37.8796 94.0719 37.8796 67.0222C37.8796 39.9725 59.8168 18.0444 86.8777 18.0444Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M37.8796 0C51.2677 0 62.1208 10.8581 62.1208 24.2522V41.7389C62.1208 55.133 51.2677 65.9911 37.8796 65.9911V0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PoeLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
height="1em"
|
||||
width="1em"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<title>Poe</title>
|
||||
<path d="M20.708 6.876a1.412 1.412 0 00-1.029-.415h-.006a2.019 2.019 0 01-2.02-2.023A1.415 1.415 0 0016.254 3H4.871A1.412 1.412 0 003.47 4.434a2.026 2.026 0 01-2.025 2.025v.002A1.414 1.414 0 000 7.883v3.642a1.414 1.414 0 001.444 1.42 2.025 2.025 0 012.025 2.02v3.693a.5.5 0 00.89.313l2.051-2.567h9.843a1.412 1.412 0 001.4-1.434v-.002c0-1.12.904-2.025 2.026-2.025a1.412 1.412 0 001.446-1.42V7.88c0-.363-.14-.727-.417-1.005zm-2.42 4.687a2.025 2.025 0 01-2.025 2.005H4.861a2.025 2.025 0 01-2.025-2.005v-3.72A2.026 2.026 0 014.86 5.838h11.4a2.026 2.026 0 012.026 2.005v3.72h.002z"></path>
|
||||
<path d="M7.413 7.57A1.422 1.422 0 005.99 8.99v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422zm6.297 0a1.422 1.422 0 00-1.422 1.421v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422z"></path>
|
||||
<path
|
||||
d="M7.292 22.643l1.993-2.492h9.844a1.413 1.413 0 001.4-1.434 2.025 2.025 0 012.017-2.027h.01A1.409 1.409 0 0024 15.27v-3.594c0-.344-.113-.68-.324-.951l-.397-.519v4.127a1.415 1.415 0 01-1.444 1.42h-.007a2.026 2.026 0 00-2.018 2.025 1.415 1.415 0 01-1.402 1.436H8.565l-2.169 2.712a.574.574 0 00.896.715v.002z"
|
||||
fill="url(#lobe-icons-poe-fill-0)"></path>
|
||||
<path
|
||||
d="M5.004 19.992l2.12-2.65h9.844a1.414 1.414 0 001.402-1.437c0-1.116.9-2.021 2.014-2.025h.012a1.413 1.413 0 001.443-1.422v-4.13l.52.68c.21.273.324.607.324.95v3.594a1.416 1.416 0 01-1.443 1.42h-.01a2.026 2.026 0 00-2.016 2.026 1.414 1.414 0 01-1.402 1.435H7.97l-1.916 2.4a.671.671 0 01-1.049-.839v-.002z"
|
||||
fill="url(#lobe-icons-poe-fill-1)"></path>
|
||||
<defs>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-poe-fill-0"
|
||||
x1="34.01"
|
||||
x2="1.086"
|
||||
y1="7.303"
|
||||
y2="27.715">
|
||||
<stop stopColor="#46A6F7"></stop>
|
||||
<stop offset="1" stop-color="#8364FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="lobe-icons-poe-fill-1"
|
||||
x1="4.915"
|
||||
x2="24.34"
|
||||
y1="23.511"
|
||||
y2="9.464">
|
||||
<stop stopColor="#FF44D3"></stop>
|
||||
<stop offset="1" stop-color="#CF4BFF"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={hostRef}>
|
||||
<div ref={hostRef} style={{ display: 'none' }}>
|
||||
{createPortal(
|
||||
<StyleSheetManager target={shadowRoot}>
|
||||
<StyleProvider container={shadowRoot} layer>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
@@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
const isInDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
useBridge()
|
||||
|
||||
/** set the popup display status */
|
||||
@@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
if (appid == currentMinappId) {
|
||||
setTimeout(() => setIsReady(true), 200)
|
||||
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { useSystemAgents } from '@renderer/pages/agents'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
@@ -33,6 +34,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const loadingRef = useRef(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const agents = useMemo(() => {
|
||||
const allAgents = [...userAgents, ...systemAgents] as Agent[]
|
||||
@@ -80,11 +82,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
assistant = await createAssistantFromAgent(agent)
|
||||
}
|
||||
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||
resolve(assistant)
|
||||
setOpen(false)
|
||||
},
|
||||
[resolve, addAssistant, setOpen]
|
||||
[setTimeoutTimer, resolve, addAssistant]
|
||||
) // 添加函数内使用的依赖项
|
||||
// 键盘导航处理
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,7 +3,14 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup'
|
||||
import { checkApi } from '@renderer/services/ApiService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
|
||||
import {
|
||||
isPreprocessProviderId,
|
||||
isWebSearchProviderId,
|
||||
Model,
|
||||
PreprocessProvider,
|
||||
Provider,
|
||||
WebSearchProvider
|
||||
} from '@renderer/types'
|
||||
import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
@@ -12,12 +19,11 @@ import { isEmpty } from 'lodash'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ApiKeyValidity, ApiProviderKind, ApiProviderUnion } from './types'
|
||||
import { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types'
|
||||
|
||||
interface UseApiKeysProps {
|
||||
provider: ApiProviderUnion
|
||||
updateProvider: (provider: Partial<ApiProviderUnion>) => void
|
||||
providerKind: ApiProviderKind
|
||||
provider: ApiProvider
|
||||
updateProvider: UpdateApiProviderFunc
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('ApiKeyListPopup')
|
||||
@@ -25,7 +31,7 @@ const logger = loggerService.withContext('ApiKeyListPopup')
|
||||
/**
|
||||
* API Keys 管理 hook
|
||||
*/
|
||||
export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKeysProps) {
|
||||
export function useApiKeys({ provider, updateProvider }: UseApiKeysProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 连通性检查的 UI 状态管理
|
||||
@@ -199,11 +205,13 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
if (isLlmProvider(provider, providerKind) && model) {
|
||||
if (isLlmProvider(provider) && model) {
|
||||
await checkApi({ ...provider, apiKey: keyToCheck }, model)
|
||||
} else {
|
||||
} else if (isWebSearchProvider(provider)) {
|
||||
const result = await WebSearchService.checkSearch({ ...provider, apiKey: keyToCheck })
|
||||
if (!result.valid) throw new Error(result.error)
|
||||
} else {
|
||||
// 不处理预处理供应商
|
||||
}
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
@@ -228,7 +236,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
logger.error('failed to validate the connectivity of the api key', error)
|
||||
}
|
||||
},
|
||||
[keys, connectivityStates, updateConnectivityState, provider, providerKind]
|
||||
[keys, connectivityStates, updateConnectivityState, provider]
|
||||
)
|
||||
|
||||
// 检查单个 key 的连通性
|
||||
@@ -240,23 +248,23 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
const currentState = connectivityStates.get(keyToCheck)
|
||||
if (currentState?.checking) return
|
||||
|
||||
const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined
|
||||
const model = isLlmProvider(provider) ? await getModelForCheck(provider, t) : undefined
|
||||
if (model === null) return
|
||||
|
||||
await runConnectivityCheck(index, model)
|
||||
},
|
||||
[provider, keys, connectivityStates, providerKind, t, runConnectivityCheck]
|
||||
[provider, keys, connectivityStates, t, runConnectivityCheck]
|
||||
)
|
||||
|
||||
// 检查所有 keys 的连通性
|
||||
const checkAllKeysConnectivity = useCallback(async () => {
|
||||
if (!provider || keys.length === 0) return
|
||||
|
||||
const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined
|
||||
const model = isLlmProvider(provider) ? await getModelForCheck(provider, t) : undefined
|
||||
if (model === null) return
|
||||
|
||||
await Promise.allSettled(keys.map((_, index) => runConnectivityCheck(index, model)))
|
||||
}, [provider, keys, providerKind, t, runConnectivityCheck])
|
||||
}, [provider, keys, t, runConnectivityCheck])
|
||||
|
||||
// 计算是否有 key 正在检查
|
||||
const isChecking = useMemo(() => {
|
||||
@@ -275,16 +283,18 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
}
|
||||
}
|
||||
|
||||
export function isLlmProvider(obj: any, kind: ApiProviderKind): obj is Provider {
|
||||
return kind === 'llm' && 'type' in obj && 'models' in obj
|
||||
export function isLlmProvider(provider: ApiProvider): provider is Provider {
|
||||
return 'models' in provider
|
||||
}
|
||||
|
||||
export function isWebSearchProvider(obj: any, kind: ApiProviderKind): obj is WebSearchProvider {
|
||||
return kind === 'websearch' && ('url' in obj || 'engines' in obj)
|
||||
export function isWebSearchProvider(provider: ApiProvider): provider is WebSearchProvider {
|
||||
return isWebSearchProviderId(provider.id)
|
||||
}
|
||||
|
||||
export function isPreprocessProvider(obj: any, kind: ApiProviderKind): obj is PreprocessProvider {
|
||||
return kind === 'doc-preprocess' && ('quota' in obj || 'options' in obj)
|
||||
export function isPreprocessProvider(provider: ApiProvider): provider is PreprocessProvider {
|
||||
// NOTE: mistral 同时提供预处理和llm服务,所以其llm provier可能被误判为预处理provider
|
||||
// 后面需要使用更严格的判断方式
|
||||
return isPreprocessProviderId(provider.id) && !isLlmProvider(provider)
|
||||
}
|
||||
|
||||
// 获取模型用于检查
|
||||
|
||||
@@ -130,7 +130,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
mouseEnterDelay={0.5}
|
||||
placement="top"
|
||||
// 确保不留下明文
|
||||
destroyTooltipOnHide>
|
||||
destroyOnHidden>
|
||||
<span style={{ cursor: 'help' }}>{maskApiKey(keyStatus.key)}</span>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { SettingHelpText } from '@renderer/pages/settings'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import { PreprocessProviderId, WebSearchProviderId } from '@renderer/types'
|
||||
import { ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd'
|
||||
import { Plus } from 'lucide-react'
|
||||
@@ -15,19 +16,18 @@ import styled from 'styled-components'
|
||||
|
||||
import { isLlmProvider, useApiKeys } from './hook'
|
||||
import ApiKeyItem from './item'
|
||||
import { ApiProviderKind, ApiProviderUnion } from './types'
|
||||
import { ApiProvider, UpdateApiProviderFunc } from './types'
|
||||
|
||||
interface ApiKeyListProps {
|
||||
provider: ApiProviderUnion
|
||||
updateProvider: (provider: Partial<ApiProviderUnion>) => void
|
||||
providerKind: ApiProviderKind
|
||||
provider: ApiProvider
|
||||
updateProvider: UpdateApiProviderFunc
|
||||
showHealthCheck?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Api key 列表,管理 CRUD 操作、连接检查
|
||||
*/
|
||||
export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, providerKind, showHealthCheck = true }) => {
|
||||
export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, showHealthCheck = true }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 临时新项状态
|
||||
@@ -42,7 +42,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
checkKeyConnectivity,
|
||||
checkAllKeysConnectivity,
|
||||
isChecking
|
||||
} = useApiKeys({ provider, updateProvider, providerKind: providerKind })
|
||||
} = useApiKeys({ provider, updateProvider })
|
||||
|
||||
// 创建一个临时新项
|
||||
const handleAddNew = () => {
|
||||
@@ -73,7 +73,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
|
||||
const shouldAutoFocus = () => {
|
||||
if (provider.apiKey) return false
|
||||
return isLlmProvider(provider, providerKind) && provider.enabled && !isProviderSupportAuth(provider)
|
||||
return isLlmProvider(provider) && provider.enabled && !isProviderSupportAuth(provider)
|
||||
}
|
||||
|
||||
// 合并真实 keys 和临时新项
|
||||
@@ -179,55 +179,33 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
|
||||
interface SpecificApiKeyListProps {
|
||||
providerId: string
|
||||
providerKind: ApiProviderKind
|
||||
showHealthCheck?: boolean
|
||||
}
|
||||
|
||||
export const LlmApiKeyList: FC<SpecificApiKeyListProps> = ({ providerId, providerKind, showHealthCheck = true }) => {
|
||||
type WebSearchApiKeyList = SpecificApiKeyListProps & {
|
||||
providerId: WebSearchProviderId
|
||||
}
|
||||
|
||||
type DocPreprocessApiKeyListProps = SpecificApiKeyListProps & {
|
||||
providerId: PreprocessProviderId
|
||||
}
|
||||
|
||||
export const LlmApiKeyList: FC<SpecificApiKeyListProps> = ({ providerId, showHealthCheck = true }) => {
|
||||
const { provider, updateProvider } = useProvider(providerId)
|
||||
|
||||
return (
|
||||
<ApiKeyList
|
||||
provider={provider}
|
||||
updateProvider={updateProvider}
|
||||
providerKind={providerKind}
|
||||
showHealthCheck={showHealthCheck}
|
||||
/>
|
||||
)
|
||||
return <ApiKeyList provider={provider} updateProvider={updateProvider} showHealthCheck={showHealthCheck} />
|
||||
}
|
||||
|
||||
export const WebSearchApiKeyList: FC<SpecificApiKeyListProps> = ({
|
||||
providerId,
|
||||
providerKind,
|
||||
showHealthCheck = true
|
||||
}) => {
|
||||
export const WebSearchApiKeyList: FC<WebSearchApiKeyList> = ({ providerId, showHealthCheck = true }) => {
|
||||
const { provider, updateProvider } = useWebSearchProvider(providerId)
|
||||
|
||||
return (
|
||||
<ApiKeyList
|
||||
provider={provider}
|
||||
updateProvider={updateProvider}
|
||||
providerKind={providerKind}
|
||||
showHealthCheck={showHealthCheck}
|
||||
/>
|
||||
)
|
||||
return <ApiKeyList provider={provider} updateProvider={updateProvider} showHealthCheck={showHealthCheck} />
|
||||
}
|
||||
|
||||
export const DocPreprocessApiKeyList: FC<SpecificApiKeyListProps> = ({
|
||||
providerId,
|
||||
providerKind,
|
||||
showHealthCheck = true
|
||||
}) => {
|
||||
export const DocPreprocessApiKeyList: FC<DocPreprocessApiKeyListProps> = ({ providerId, showHealthCheck = true }) => {
|
||||
const { provider, updateProvider } = usePreprocessProvider(providerId)
|
||||
|
||||
return (
|
||||
<ApiKeyList
|
||||
provider={provider}
|
||||
updateProvider={updateProvider}
|
||||
providerKind={providerKind}
|
||||
showHealthCheck={showHealthCheck}
|
||||
/>
|
||||
)
|
||||
return <ApiKeyList provider={provider} updateProvider={updateProvider} showHealthCheck={showHealthCheck} />
|
||||
}
|
||||
|
||||
const ListContainer = styled.div`
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { isPreprocessProviderId, isWebSearchProviderId } from '@renderer/types'
|
||||
import { Modal } from 'antd'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DocPreprocessApiKeyList, LlmApiKeyList, WebSearchApiKeyList } from './list'
|
||||
import { ApiProviderKind } from './types'
|
||||
|
||||
interface ShowParams {
|
||||
providerId: string
|
||||
providerKind: ApiProviderKind
|
||||
title?: string
|
||||
showHealthCheck?: boolean
|
||||
}
|
||||
@@ -20,7 +19,7 @@ interface Props extends ShowParams {
|
||||
/**
|
||||
* API Key 列表弹窗容器组件
|
||||
*/
|
||||
const PopupContainer: React.FC<Props> = ({ providerId, providerKind, title, resolve, showHealthCheck = true }) => {
|
||||
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -33,17 +32,14 @@ const PopupContainer: React.FC<Props> = ({ providerId, providerKind, title, reso
|
||||
}
|
||||
|
||||
const ListComponent = useMemo(() => {
|
||||
switch (providerKind) {
|
||||
case 'llm':
|
||||
return LlmApiKeyList
|
||||
case 'websearch':
|
||||
return WebSearchApiKeyList
|
||||
case 'doc-preprocess':
|
||||
return DocPreprocessApiKeyList
|
||||
default:
|
||||
return null
|
||||
if (isWebSearchProviderId(providerId)) {
|
||||
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||
}
|
||||
}, [providerKind])
|
||||
if (isPreprocessProviderId(providerId)) {
|
||||
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||
}
|
||||
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||
}, [providerId, showHealthCheck])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -55,9 +51,7 @@ const PopupContainer: React.FC<Props> = ({ providerId, providerKind, title, reso
|
||||
centered
|
||||
width={600}
|
||||
footer={null}>
|
||||
{ListComponent && (
|
||||
<ListComponent providerId={providerId} providerKind={providerKind} showHealthCheck={showHealthCheck} />
|
||||
)}
|
||||
{ListComponent}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ export type ApiKeyValidity = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ApiProviderUnion = Provider | WebSearchProvider | PreprocessProvider
|
||||
export type ApiProvider = Provider | WebSearchProvider | PreprocessProvider
|
||||
|
||||
export type ApiProviderKind = 'llm' | 'websearch' | 'doc-preprocess'
|
||||
export type UpdateProviderFunc = (p: Partial<Provider>) => void
|
||||
|
||||
export type UpdateWebSearchProviderFunc = (p: Partial<WebSearchProvider>) => void
|
||||
|
||||
export type UpdatePreprocessProviderFunc = (p: Partial<PreprocessProvider>) => void
|
||||
|
||||
export type UpdateApiProviderFunc = UpdateProviderFunc | UpdateWebSearchProviderFunc | UpdatePreprocessProviderFunc
|
||||
|
||||
238
src/renderer/src/components/Popups/ConflictResolutionPopup.tsx
Normal file
238
src/renderer/src/components/Popups/ConflictResolutionPopup.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'
|
||||
import { getFancyProviderName } from '@renderer/utils'
|
||||
import { ConflictInfo, ConflictResolution } from '@renderer/utils/provider'
|
||||
import { Button, Card, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
interface Props {
|
||||
conflicts: ConflictInfo[]
|
||||
onResolve: (resolutions: ConflictResolution[]) => void
|
||||
onCancel: () => void
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const ConflictResolutionPopup: FC<Props> = ({ conflicts, onResolve, onCancel, visible }) => {
|
||||
const { t } = useTranslation()
|
||||
const [resolutions, setResolutions] = useState<Record<string, string>>({})
|
||||
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({})
|
||||
|
||||
const handleProviderSelect = (conflictId: string, providerId: string) => {
|
||||
setResolutions((prev) => ({
|
||||
...prev,
|
||||
[conflictId]: providerId
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleApiKeyVisibility = (providerKey: string) => {
|
||||
setShowApiKeys((prev) => ({
|
||||
...prev,
|
||||
[providerKey]: !prev[providerKey]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleResolve = () => {
|
||||
const conflictResolutions: ConflictResolution[] = Object.entries(resolutions).map(
|
||||
([conflictId, selectedProviderId]) => ({
|
||||
conflictId,
|
||||
selectedProviderId
|
||||
})
|
||||
)
|
||||
onResolve(conflictResolutions)
|
||||
}
|
||||
|
||||
const isAllResolved = conflicts.every((conflict) => resolutions[conflict.id])
|
||||
|
||||
const renderProviderCard = (provider: ConflictInfo['providers'][0], conflictId: string, isSelected: boolean) => {
|
||||
const providerName = getFancyProviderName(provider)
|
||||
const providerKey = `${conflictId}-${provider._tempIndex}`
|
||||
const isApiKeyVisible = showApiKeys[providerKey]
|
||||
|
||||
const renderApiKeyValue = () => {
|
||||
if (!provider.apiKey) {
|
||||
return <DetailValue>未设置</DetailValue>
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiKeyContainer>
|
||||
<DetailValue>{isApiKeyVisible ? provider.apiKey : '●●●●●●●●'}</DetailValue>
|
||||
<ApiKeyToggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation() // 防止触发卡片选择
|
||||
toggleApiKeyVisibility(providerKey)
|
||||
}}>
|
||||
{isApiKeyVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
</ApiKeyToggle>
|
||||
</ApiKeyContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
key={provider._tempIndex}
|
||||
size="small"
|
||||
$selected={isSelected}
|
||||
onClick={() => handleProviderSelect(conflictId, provider._tempIndex!.toString())}>
|
||||
<ProviderHeader>
|
||||
<Radio checked={isSelected} />
|
||||
<ProviderName>{providerName}</ProviderName>
|
||||
{provider.enabled && <Tag color="green">ON</Tag>}
|
||||
</ProviderHeader>
|
||||
<ProviderDetails>
|
||||
<DetailRow>
|
||||
<DetailLabel>API Key:</DetailLabel>
|
||||
{renderApiKeyValue()}
|
||||
</DetailRow>
|
||||
<DetailRow>
|
||||
<DetailLabel>API Host:</DetailLabel>
|
||||
<DetailValue>{provider.apiHost || '默认'}</DetailValue>
|
||||
</DetailRow>
|
||||
</ProviderDetails>
|
||||
</ProviderCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.provider.cleanup.conflict.resolution_title')}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" onClick={handleResolve} disabled={!isAllResolved}>
|
||||
{t('settings.provider.cleanup.conflict.apply_resolution')}
|
||||
</Button>
|
||||
</Space>
|
||||
}>
|
||||
<ConflictContainer>
|
||||
<Text type="secondary">{t('settings.provider.cleanup.conflict.resolution_desc')}</Text>
|
||||
|
||||
{conflicts.map((conflict, index) => (
|
||||
<ConflictSection key={conflict.id}>
|
||||
<Title level={5}>
|
||||
{t('settings.provider.cleanup.conflict.provider_conflict', {
|
||||
provider: getFancyProviderName({ name: conflict.id, id: conflict.id } as any)
|
||||
})}
|
||||
</Title>
|
||||
|
||||
<ProvidersGrid>
|
||||
{conflict.providers.map((provider) =>
|
||||
renderProviderCard(provider, conflict.id, resolutions[conflict.id] === provider._tempIndex!.toString())
|
||||
)}
|
||||
</ProvidersGrid>
|
||||
|
||||
{index < conflicts.length - 1 && <ConflictDivider />}
|
||||
</ConflictSection>
|
||||
))}
|
||||
</ConflictContainer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const ConflictContainer = styled.div`
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const ConflictSection = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`
|
||||
|
||||
const ProvidersGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const ProviderCard = styled(Card)<{ $selected: boolean }>`
|
||||
cursor: pointer;
|
||||
border: 2px solid ${(props) => (props.$selected ? 'var(--color-primary)' : 'var(--color-border)')};
|
||||
background: ${(props) => (props.$selected ? 'var(--color-primary-bg)' : 'var(--color-background)')};
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ProviderDetails = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const DetailRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const DetailLabel = styled(Text)`
|
||||
min-width: 80px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const DetailValue = styled(Text)`
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
`
|
||||
|
||||
const ApiKeyContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ApiKeyToggle = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-3);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-fill-tertiary);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`
|
||||
|
||||
const ConflictDivider = styled.div`
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 24px 0;
|
||||
`
|
||||
|
||||
export default ConflictResolutionPopup
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Input, Modal } from 'antd'
|
||||
import { TextAreaProps } from 'antd/es/input'
|
||||
import { useRef, useState } from 'react'
|
||||
import { ReactNode, useRef, useState } from 'react'
|
||||
|
||||
import { Box } from '../Layout'
|
||||
import { TopView } from '../TopView'
|
||||
@@ -11,6 +11,7 @@ interface PromptPopupShowParams {
|
||||
defaultValue?: string
|
||||
inputPlaceholder?: string
|
||||
inputProps?: TextAreaProps
|
||||
extraNode?: ReactNode
|
||||
}
|
||||
|
||||
interface Props extends PromptPopupShowParams {
|
||||
@@ -23,6 +24,7 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
defaultValue = '',
|
||||
inputPlaceholder = '',
|
||||
inputProps = {},
|
||||
extraNode = null,
|
||||
resolve
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue)
|
||||
@@ -88,6 +90,7 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
rows={1}
|
||||
{...inputProps}
|
||||
/>
|
||||
{extraNode}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
resolve(undefined)
|
||||
break
|
||||
|
||||
@@ -41,7 +41,7 @@ const SelectModelSearchBar: React.FC<SelectModelSearchBarProps> = ({ onSearch })
|
||||
</SearchIcon>
|
||||
}
|
||||
ref={inputRef}
|
||||
placeholder={t('models.search')}
|
||||
placeholder={t('models.search.placeholder')}
|
||||
value={searchText}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onClear={handleClear}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { getLanguageByLangcode } from '@renderer/utils/translate'
|
||||
import { Modal, ModalProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { TextAreaProps } from 'antd/lib/input'
|
||||
@@ -38,6 +38,7 @@ const PopupContainer: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const { getLanguageByLangcode } = useTranslate()
|
||||
const [textValue, setTextValue] = useState(text)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||
import { ShadowWhiteContainer } from './styles'
|
||||
import { BasicPreviewHandles, BasicPreviewProps } from './types'
|
||||
import { renderSvgInShadowHost } from './utils'
|
||||
|
||||
@@ -13,8 +13,10 @@ const vizInitializer = new AsyncInitializer(async () => {
|
||||
return await module.instance()
|
||||
})
|
||||
|
||||
/** 预览 Graphviz 图表
|
||||
* 使用 usePreviewRenderer hook 大幅简化组件逻辑
|
||||
/**
|
||||
* 预览 Graphviz 图表
|
||||
* - 使用 useDebouncedRender 改善体验
|
||||
* - 使用 shadow dom 渲染 SVG
|
||||
*/
|
||||
const GraphvizPreview = ({
|
||||
children,
|
||||
@@ -41,16 +43,9 @@ const GraphvizPreview = ({
|
||||
ref={ref}
|
||||
imageRef={containerRef}
|
||||
source="graphviz">
|
||||
<StyledGraphviz ref={containerRef} className="graphviz special-preview" />
|
||||
<ShadowWhiteContainer ref={containerRef} className="graphviz special-preview" />
|
||||
</ImagePreviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledGraphviz = styled.div`
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
export default memo(GraphvizPreview)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||
import { ShadowTransparentContainer } from './styles'
|
||||
import { BasicPreviewHandles, BasicPreviewProps } from './types'
|
||||
import { renderSvgInShadowHost } from './utils'
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 使用 usePreviewRenderer hook 重构,同时保留必要的可见性检测逻辑
|
||||
* FIXME: 等将来 mermaid-js 修复可见性问题后可以进一步简化
|
||||
/**
|
||||
* 预览 Mermaid 图表
|
||||
* - 使用 useDebouncedRender 改善体验
|
||||
* - 使用 shadow dom 渲染 SVG
|
||||
*/
|
||||
const MermaidPreview = ({
|
||||
children,
|
||||
@@ -20,17 +22,39 @@ const MermaidPreview = ({
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
// 定义渲染函数
|
||||
/**
|
||||
* 定义渲染函数,在临时容器中测量,在 shadow dom 中渲染。
|
||||
* 如果这个方案有问题,可以回退到 innerHTML。
|
||||
*/
|
||||
const renderMermaid = useCallback(
|
||||
async (content: string, container: HTMLDivElement) => {
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(content)
|
||||
|
||||
const { svg } = await mermaid.render(diagramId, content, container)
|
||||
// 获取容器宽度
|
||||
const { width } = container.getBoundingClientRect()
|
||||
if (width === 0) return
|
||||
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
container.innerHTML = fixedSvg
|
||||
// 创建临时的 div 用于 mermaid 测量
|
||||
const measureEl = document.createElement('div')
|
||||
measureEl.style.position = 'absolute'
|
||||
measureEl.style.left = '-9999px'
|
||||
measureEl.style.top = '-9999px'
|
||||
measureEl.style.width = `${width}px`
|
||||
document.body.appendChild(measureEl)
|
||||
|
||||
try {
|
||||
const { svg } = await mermaid.render(diagramId, content, measureEl)
|
||||
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
|
||||
// 有问题可以回退到 innerHTML
|
||||
renderSvgInShadowHost(fixedSvg, container)
|
||||
// container.innerHTML = fixedSvg
|
||||
} finally {
|
||||
document.body.removeChild(measureEl)
|
||||
}
|
||||
},
|
||||
[diagramId, mermaid]
|
||||
)
|
||||
@@ -63,7 +87,7 @@ const MermaidPreview = ({
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
|
||||
const currentlyVisible = element.offsetParent !== null
|
||||
const currentlyVisible = element.offsetParent !== null && element.offsetWidth > 0 && element.offsetHeight > 0
|
||||
setIsVisible(currentlyVisible)
|
||||
}
|
||||
|
||||
@@ -105,16 +129,9 @@ const MermaidPreview = ({
|
||||
ref={ref}
|
||||
imageRef={containerRef}
|
||||
source="mermaid">
|
||||
<StyledMermaid ref={containerRef} className="mermaid special-preview" />
|
||||
<ShadowTransparentContainer ref={containerRef} className="mermaid special-preview" />
|
||||
</ImagePreviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledMermaid = styled.div`
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
export default memo(MermaidPreview)
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { memo, useCallback, useEffect } from 'react'
|
||||
|
||||
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||
import { ShadowWhiteContainer } from './styles'
|
||||
import { BasicPreviewHandles, BasicPreviewProps } from './types'
|
||||
import { renderSvgInShadowHost } from './utils'
|
||||
|
||||
@@ -128,7 +129,7 @@ const PlantUmlPreview = ({
|
||||
ref={ref}
|
||||
imageRef={containerRef}
|
||||
source="plantuml">
|
||||
<div ref={containerRef} className="plantuml-preview special-preview" />
|
||||
<ShadowWhiteContainer ref={containerRef} className="plantuml-preview special-preview" />
|
||||
</ImagePreviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useCallback } from 'react'
|
||||
|
||||
import { useDebouncedRender } from './hooks/useDebouncedRender'
|
||||
import ImagePreviewLayout from './ImagePreviewLayout'
|
||||
import { ShadowTransparentContainer } from './styles'
|
||||
import { BasicPreviewHandles } from './types'
|
||||
import { renderSvgInShadowHost } from './utils'
|
||||
|
||||
@@ -34,7 +35,8 @@ const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPrev
|
||||
ref={ref}
|
||||
imageRef={containerRef}
|
||||
source="svg">
|
||||
<div ref={containerRef} className={className ?? 'svg-preview special-preview'}></div>
|
||||
{/* 使用透明容器,把背景色完全交给 SVG 自己控制 */}
|
||||
<ShadowTransparentContainer ref={containerRef} className={className ?? 'svg-preview special-preview'} />
|
||||
</ImagePreviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--shadow-host-background-color: white;
|
||||
--shadow-host-border: 0.5px solid var(--color-code-background);
|
||||
--shadow-host-border-radius: 8px;
|
||||
}
|
||||
|
||||
<div>
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
exports[`MermaidPreview > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--shadow-host-background-color: transparent;
|
||||
--shadow-host-border: unset;
|
||||
--shadow-host-border-radius: unset;
|
||||
}
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
--shadow-host-background-color: white;
|
||||
--shadow-host-border: 0.5px solid var(--color-code-background);
|
||||
--shadow-host-border-radius: 8px;
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
data-source="plantuml"
|
||||
@@ -15,7 +21,7 @@ exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
|
||||
data-testid="preview-content"
|
||||
>
|
||||
<div
|
||||
class="plantuml-preview special-preview"
|
||||
class="c0 plantuml-preview special-preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
--shadow-host-background-color: transparent;
|
||||
--shadow-host-border: unset;
|
||||
--shadow-host-border-radius: unset;
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
data-source="svg"
|
||||
@@ -15,7 +21,7 @@ exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
|
||||
data-testid="preview-content"
|
||||
>
|
||||
<div
|
||||
class="svg-preview special-preview"
|
||||
class="c0 svg-preview special-preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,29 +10,39 @@ describe('renderSvgInShadowHost', () => {
|
||||
|
||||
// Mock attachShadow
|
||||
Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) {
|
||||
const shadowRoot = document.createElement('div')
|
||||
// Check if a shadow root already exists to prevent re-creating it.
|
||||
if (this.shadowRoot) {
|
||||
return this.shadowRoot
|
||||
}
|
||||
|
||||
// Create a container that acts as the shadow root.
|
||||
const shadowRootContainer = document.createElement('div')
|
||||
shadowRootContainer.dataset.testid = 'shadow-root'
|
||||
|
||||
Object.defineProperty(this, 'shadowRoot', {
|
||||
value: shadowRoot,
|
||||
value: shadowRootContainer,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
// Simple innerHTML copy for test verification
|
||||
Object.defineProperty(shadowRoot, 'innerHTML', {
|
||||
set(value) {
|
||||
shadowRoot.textContent = value // A simplified mock
|
||||
|
||||
// Mock essential methods like appendChild and innerHTML.
|
||||
// JSDOM doesn't fully implement shadow DOM, so we simulate its behavior.
|
||||
const originalInnerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')
|
||||
Object.defineProperty(shadowRootContainer, 'innerHTML', {
|
||||
set(value: string) {
|
||||
// Clear existing content and parse the new HTML.
|
||||
originalInnerHTMLDescriptor?.set?.call(this, '')
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = value
|
||||
shadowRootContainer.append(...Array.from(template.content.childNodes))
|
||||
},
|
||||
get() {
|
||||
return shadowRoot.textContent || ''
|
||||
return originalInnerHTMLDescriptor?.get?.call(this) ?? ''
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
shadowRoot.appendChild = vi.fn(<T extends Node>(node: T): T => {
|
||||
shadowRoot.append(node)
|
||||
return node
|
||||
})
|
||||
|
||||
return shadowRoot as unknown as ShadowRoot
|
||||
return shadowRootContainer as unknown as ShadowRoot
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +67,7 @@ describe('renderSvgInShadowHost', () => {
|
||||
|
||||
expect(Element.prototype.attachShadow).not.toHaveBeenCalled()
|
||||
// Verify it works with the existing shadow root
|
||||
expect(existingShadowRoot.appendChild).toHaveBeenCalled()
|
||||
expect(existingShadowRoot.innerHTML).toContain('<svg')
|
||||
})
|
||||
|
||||
it('should inject styles and valid SVG content into the shadow DOM', () => {
|
||||
@@ -71,20 +81,31 @@ describe('renderSvgInShadowHost', () => {
|
||||
expect(shadowRoot?.querySelector('rect')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should add the xmlns attribute if it is missing', () => {
|
||||
const svgWithoutXmlns = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
|
||||
renderSvgInShadowHost(svgWithoutXmlns, hostElement)
|
||||
|
||||
const svgElement = hostElement.shadowRoot?.querySelector('svg')
|
||||
expect(svgElement).not.toBeNull()
|
||||
expect(svgElement?.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
|
||||
})
|
||||
|
||||
it('should throw an error if the host element is not available', () => {
|
||||
expect(() => renderSvgInShadowHost('<svg></svg>', null as any)).toThrow(
|
||||
'Host element for SVG rendering is not available.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error for invalid SVG content', () => {
|
||||
const invalidSvg = '<svg><rect></svg>' // Malformed
|
||||
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/)
|
||||
it('should not throw an error for malformed SVG content due to HTML parser fallback', () => {
|
||||
const invalidSvg = '<svg><rect></svg>' // Malformed, but fixable by the browser's HTML parser
|
||||
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).not.toThrow()
|
||||
// Also, assert that it successfully rendered something.
|
||||
expect(hostElement.shadowRoot?.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should throw an error for non-SVG content', () => {
|
||||
const nonSvg = '<div>this is not svg</div>'
|
||||
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content')
|
||||
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow()
|
||||
})
|
||||
|
||||
it('should not throw an error for empty or whitespace content', () => {
|
||||
|
||||
@@ -33,3 +33,15 @@ export const PreviewContainer = styled(Flex).attrs({ role: 'alert' })`
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ShadowWhiteContainer = styled.div`
|
||||
--shadow-host-background-color: white;
|
||||
--shadow-host-border: 0.5px solid var(--color-code-background);
|
||||
--shadow-host-border-radius: 8px;
|
||||
`
|
||||
|
||||
export const ShadowTransparentContainer = styled.div`
|
||||
--shadow-host-background-color: transparent;
|
||||
--shadow-host-border: unset;
|
||||
--shadow-host-border-radius: unset;
|
||||
`
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { makeSvgSizeAdaptive } from '@renderer/utils'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
* Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation.
|
||||
* This function handles creating the shadow root, injecting base styles for the host,
|
||||
@@ -12,50 +15,76 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
throw new Error('Host element for SVG rendering is not available.')
|
||||
}
|
||||
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
ADD_TAGS: ['foreignObject']
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
|
||||
// Base styles for the host element
|
||||
// Base styles for the host element and the inner SVG
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host {
|
||||
--shadow-host-background-color: white;
|
||||
--shadow-host-border: 0.5px solid var(--color-code-background);
|
||||
--shadow-host-border-radius: 8px;
|
||||
|
||||
background-color: var(--shadow-host-background-color);
|
||||
border: var(--shadow-host-border);
|
||||
border-radius: var(--shadow-host-border-radius);
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-radius: 8px;
|
||||
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
|
||||
// Clear previous content and append new style and SVG
|
||||
// Clear previous content and append new style
|
||||
shadowRoot.innerHTML = ''
|
||||
shadowRoot.appendChild(style)
|
||||
|
||||
// Parse and append the SVG using DOMParser to prevent script execution and check for errors
|
||||
if (svgContent.trim() === '') {
|
||||
if (sanitizedContent.trim() === '') {
|
||||
return
|
||||
}
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
|
||||
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(sanitizedContent, 'image/svg+xml')
|
||||
const parserError = doc.querySelector('parsererror')
|
||||
if (parserError) {
|
||||
// Throw a specific error that can be caught by the calling component
|
||||
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
|
||||
let svgElement: Element = doc.documentElement
|
||||
|
||||
// If parsing fails or the namespace is incorrect, fall back to the more lenient HTML parser.
|
||||
if (parserError || svgElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = sanitizedContent
|
||||
const svgFromHtml = tempDiv.querySelector('svg')
|
||||
|
||||
if (svgFromHtml) {
|
||||
// Directly use the DOM node created by the HTML parser.
|
||||
svgElement = svgFromHtml
|
||||
// Ensure the xmlns attribute is present.
|
||||
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
} else {
|
||||
// If both parsing methods fail, the SVG content is genuinely invalid.
|
||||
if (parserError) {
|
||||
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
|
||||
}
|
||||
throw new Error('Invalid SVG content: The provided string does not contain a valid SVG element.')
|
||||
}
|
||||
}
|
||||
|
||||
const svgElement = doc.documentElement
|
||||
if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') {
|
||||
shadowRoot.appendChild(svgElement.cloneNode(true))
|
||||
} else if (svgContent.trim() !== '') {
|
||||
// Do not throw error for empty content
|
||||
// Type guard
|
||||
if (svgElement instanceof SVGSVGElement) {
|
||||
// Standardize the SVG element for proper scaling
|
||||
makeSvgSizeAdaptive(svgElement)
|
||||
|
||||
// Append the SVG element to the shadow root
|
||||
shadowRoot.appendChild(svgElement)
|
||||
} else {
|
||||
// This path is taken if the content is valid XML but not a valid SVG document
|
||||
// (e.g., root element is not <svg>), or if the fallback parser fails.
|
||||
throw new Error('Invalid SVG content: The provided string is not a valid SVG document.')
|
||||
}
|
||||
}
|
||||
|
||||
113
src/renderer/src/components/ProviderLogoPicker/index.tsx
Normal file
113
src/renderer/src/components/ProviderLogoPicker/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import { Input, Tooltip } from 'antd'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
onProviderClick: (providerId: string) => void
|
||||
}
|
||||
|
||||
// 用于选择内置头像的提供商Logo选择器组件
|
||||
const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
const providers = Object.entries(PROVIDER_LOGO_MAP).map(([id, logo]) => ({
|
||||
id,
|
||||
logo,
|
||||
name: getProviderLabel(id)
|
||||
}))
|
||||
|
||||
if (!searchText) return providers
|
||||
|
||||
const searchLower = searchText.toLowerCase()
|
||||
return providers.filter((p) => p.name.toLowerCase().includes(searchLower))
|
||||
}, [searchText])
|
||||
|
||||
const handleProviderClick = (event: React.MouseEvent, providerId: string) => {
|
||||
event.stopPropagation()
|
||||
onProviderClick(providerId)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SearchContainer>
|
||||
<Input
|
||||
placeholder="search"
|
||||
prefix={<SearchOutlined style={{ color: 'var(--color-text-3)' }} />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
size="small"
|
||||
allowClear
|
||||
style={{
|
||||
borderRadius: 'var(--list-item-border-radius)',
|
||||
background: 'var(--color-background-soft)'
|
||||
}}
|
||||
/>
|
||||
</SearchContainer>
|
||||
<LogoGrid>
|
||||
{filteredProviders.map(({ id, logo, name }) => (
|
||||
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
|
||||
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
|
||||
<img src={logo} alt={name} draggable={false} />
|
||||
</LogoItem>
|
||||
</Tooltip>
|
||||
))}
|
||||
</LogoGrid>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 350px;
|
||||
max-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
`
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
const LogoGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
`
|
||||
|
||||
const LogoItem = styled.div`
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-mute);
|
||||
transform: scale(1.05);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default ProviderLogoPicker
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
QuickPanelCloseAction,
|
||||
QuickPanelContextType,
|
||||
QuickPanelListItem,
|
||||
QuickPanelOpenOptions
|
||||
QuickPanelOpenOptions,
|
||||
QuickPanelTriggerInfo
|
||||
} from './types'
|
||||
|
||||
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
||||
@@ -19,9 +20,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
const [pageSize, setPageSize] = useState<number>(7)
|
||||
const [multiple, setMultiple] = useState<boolean>(false)
|
||||
const [onClose, setOnClose] = useState<
|
||||
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
|
||||
>()
|
||||
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
|
||||
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
|
||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
|
||||
@@ -44,6 +44,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setPageSize(options.pageSize ?? 7)
|
||||
setMultiple(options.multiple ?? false)
|
||||
setSymbol(options.symbol)
|
||||
setTriggerInfo(options.triggerInfo)
|
||||
|
||||
setOnClose(() => options.onClose)
|
||||
setBeforeAction(() => options.beforeAction)
|
||||
@@ -53,9 +54,9 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
}, [])
|
||||
|
||||
const close = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
(action?: QuickPanelCloseAction, searchText?: string) => {
|
||||
setIsVisible(false)
|
||||
onClose?.({ symbol, action })
|
||||
onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false })
|
||||
|
||||
clearTimer.current = setTimeout(() => {
|
||||
setList([])
|
||||
@@ -64,9 +65,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setAfterAction(undefined)
|
||||
setTitle(undefined)
|
||||
setSymbol('')
|
||||
setTriggerInfo(undefined)
|
||||
}, 200)
|
||||
},
|
||||
[onClose, symbol]
|
||||
[onClose, symbol, triggerInfo]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,6 +94,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
triggerInfo,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
@@ -107,6 +110,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
triggerInfo,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
export type QuickPanelTriggerInfo = {
|
||||
type: 'input' | 'button'
|
||||
position?: number
|
||||
originalText?: string
|
||||
}
|
||||
|
||||
export type QuickPanelCallBackOptions = {
|
||||
symbol: string
|
||||
action: QuickPanelCloseAction
|
||||
@@ -8,6 +14,7 @@ export type QuickPanelCallBackOptions = {
|
||||
searchText?: string
|
||||
/** 是否处于多选状态 */
|
||||
multiple?: boolean
|
||||
triggerInfo?: QuickPanelTriggerInfo
|
||||
}
|
||||
|
||||
export type QuickPanelOpenOptions = {
|
||||
@@ -26,6 +33,8 @@ export type QuickPanelOpenOptions = {
|
||||
* 可以是/@#符号,也可以是其他字符串
|
||||
*/
|
||||
symbol: string
|
||||
/** 触发信息,记录面板是如何被打开的 */
|
||||
triggerInfo?: QuickPanelTriggerInfo
|
||||
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
||||
afterAction?: (options: QuickPanelCallBackOptions) => void
|
||||
onClose?: (options: QuickPanelCallBackOptions) => void
|
||||
@@ -45,13 +54,19 @@ export type QuickPanelListItem = {
|
||||
isSelected?: boolean
|
||||
isMenu?: boolean
|
||||
disabled?: boolean
|
||||
/**
|
||||
* 固定显示项:不参与过滤,始终出现在列表顶部。
|
||||
* 例如“清除”按钮可设置为 alwaysVisible,从而在有匹配项时始终可见;
|
||||
* 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。
|
||||
*/
|
||||
alwaysVisible?: boolean
|
||||
action?: (options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
|
||||
// 定义上下文类型
|
||||
export interface QuickPanelContextType {
|
||||
readonly open: (options: QuickPanelOpenOptions) => void
|
||||
readonly close: (action?: QuickPanelCloseAction) => void
|
||||
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
|
||||
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
|
||||
readonly isVisible: boolean
|
||||
readonly symbol: string
|
||||
@@ -60,6 +75,7 @@ export interface QuickPanelContextType {
|
||||
readonly defaultIndex: number
|
||||
readonly pageSize: number
|
||||
readonly multiple: boolean
|
||||
readonly triggerInfo?: QuickPanelTriggerInfo
|
||||
|
||||
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { debounce } from 'lodash'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -62,18 +64,32 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 缓存:按 item 缓存拼音文本,避免重复转换
|
||||
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
|
||||
|
||||
// 轻量防抖:减少高频输入时的过滤调用
|
||||
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
||||
|
||||
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
||||
const prevSearchTextRef = useRef('')
|
||||
const prevSymbolRef = useRef('')
|
||||
|
||||
// 无匹配项自动关闭的定时器
|
||||
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
const newList = ctx.list?.filter((item) => {
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
const lowerSearchText = _searchText.toLowerCase()
|
||||
const fuzzyPattern = lowerSearchText
|
||||
.split('')
|
||||
.map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('.*')
|
||||
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
|
||||
|
||||
// 拆分:固定显示项(不参与过滤)与普通项
|
||||
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
|
||||
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
|
||||
|
||||
const filteredNormalItems = normalItems.filter((item) => {
|
||||
if (!_searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
@@ -85,29 +101,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = _searchText.toLowerCase()
|
||||
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const pattern = lowerSearchText
|
||||
.split('')
|
||||
.map((char) => {
|
||||
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
})
|
||||
.join('.*')
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
try {
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(pinyinText)
|
||||
let pinyinText = pinyinCacheRef.current.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCacheRef.current.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(filterText.toLowerCase())
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -120,8 +131,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
if (prevIndex >= newList.length) {
|
||||
return newList.length > 0 ? newList.length - 1 : -1
|
||||
const combinedLength = pinnedItems.length + filteredNormalItems.length
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
}
|
||||
return prevIndex
|
||||
})
|
||||
@@ -130,81 +142,59 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
return newList
|
||||
// 固定项置顶 + 过滤后的普通项
|
||||
return [...pinnedItems, ...filteredNormalItems]
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
}, [list, historyPanel])
|
||||
|
||||
// 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板
|
||||
useEffect(() => {
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
|
||||
// 清除之前的定时器(无论面板是否可见都要清理)
|
||||
if (noMatchTimeoutRef.current) {
|
||||
clearTimeout(noMatchTimeoutRef.current)
|
||||
noMatchTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// 面板不可见时不设置新定时器
|
||||
if (!ctx.isVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
// 只有在有搜索文本但无匹配项时才设置延迟关闭
|
||||
if (_searchText && _searchText.length > 0 && list.length === 0) {
|
||||
noMatchTimeoutRef.current = setTimeout(() => {
|
||||
ctx.close('no-matches')
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (noMatchTimeoutRef.current) {
|
||||
clearTimeout(noMatchTimeoutRef.current)
|
||||
noMatchTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染
|
||||
}, [ctx.isVisible, searchText, list.length, ctx.close])
|
||||
|
||||
const clearSearchText = useCallback(
|
||||
(includeSymbol = false) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
if (!textArea) return
|
||||
|
||||
const cursorPosition = textArea.selectionStart ?? 0
|
||||
const prevChar = textArea.value[cursorPosition - 1]
|
||||
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
|
||||
searchTextRef.current = prevChar
|
||||
}
|
||||
const textBeforeCursor = textArea.value.slice(0, cursorPosition)
|
||||
|
||||
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
|
||||
if (!_searchText) return
|
||||
// 查找最后一个 @ 或 / 符号的位置
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||
const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex)
|
||||
|
||||
const inputText = textArea.value
|
||||
let newText = inputText
|
||||
const searchPattern = new RegExp(`${_searchText}$`)
|
||||
if (lastSymbolIndex === -1) return
|
||||
|
||||
const match = inputText.slice(0, cursorPosition).match(searchPattern)
|
||||
if (match) {
|
||||
const start = match.index || 0
|
||||
const end = start + match[0].length
|
||||
newText = inputText.slice(0, start) + inputText.slice(end)
|
||||
setInputText(newText)
|
||||
// 根据 includeSymbol 决定是否删除符号
|
||||
const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1
|
||||
const deleteEnd = cursorPosition
|
||||
|
||||
setTimeout(() => {
|
||||
if (deleteStart >= deleteEnd) return
|
||||
|
||||
// 删除文本
|
||||
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
|
||||
setInputText(newText)
|
||||
|
||||
// 设置光标位置
|
||||
setTimeoutTimer(
|
||||
'quickpanel_focus',
|
||||
() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(start, start)
|
||||
}, 0)
|
||||
}
|
||||
textArea.setSelectionRange(deleteStart, deleteStart)
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
setSearchText('')
|
||||
},
|
||||
[setInputText]
|
||||
[setInputText, setTimeoutTimer]
|
||||
)
|
||||
|
||||
const handleClose = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
ctx.close(action)
|
||||
// 传递 searchText 给 close 函数,去掉第一个字符(@ 或 /)
|
||||
const cleanSearchText = searchText.length > 1 ? searchText.slice(1) : ''
|
||||
ctx.close(action, cleanSearchText)
|
||||
setHistoryPanel([])
|
||||
scrollTriggerRef.current = 'initial'
|
||||
|
||||
@@ -217,7 +207,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
clearSearchText(true)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText]
|
||||
[ctx, clearSearchText, setInputText, searchText]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
@@ -308,9 +298,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
if (lastSymbolIndex !== -1) {
|
||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||
setSearchText(newSearchText)
|
||||
setSearchTextDebounced(newSearchText)
|
||||
} else {
|
||||
ctx.close('delete-symbol')
|
||||
// 使用本地 handleClose,确保在删除触发符时同步受控输入值
|
||||
handleClose('delete-symbol')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,9 +322,14 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
textArea.removeEventListener('input', handleInput)
|
||||
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
||||
setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200) // 等待面板关闭动画结束后,再清空搜索词
|
||||
setSearchTextDebounced.cancel()
|
||||
setTimeoutTimer(
|
||||
'quickpanel_clear_search',
|
||||
() => {
|
||||
setSearchText('')
|
||||
},
|
||||
200
|
||||
) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible])
|
||||
@@ -347,9 +343,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
scrollTriggerRef.current = 'none'
|
||||
}, [index])
|
||||
|
||||
// 处理键盘事件
|
||||
// 处理键盘事件(折叠时不拦截全局键盘)
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0
|
||||
const isCollapsed = hasSearchTextFlag && list.length === 0
|
||||
if (!ctx.isVisible || isCollapsed) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isMac ? e.metaKey : e.ctrlKey) {
|
||||
@@ -456,6 +454,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.stopPropagation()
|
||||
handleClose('esc')
|
||||
break
|
||||
}
|
||||
@@ -475,16 +474,26 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('keyup', handleKeyUp, true)
|
||||
window.addEventListener('click', handleClickOutside, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('keyup', handleKeyUp, true)
|
||||
window.removeEventListener('click', handleClickOutside, true)
|
||||
}
|
||||
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
|
||||
}, [
|
||||
index,
|
||||
isAssistiveKeyPressed,
|
||||
historyPanel,
|
||||
ctx,
|
||||
list,
|
||||
handleItemAction,
|
||||
handleClose,
|
||||
clearSearchText,
|
||||
searchText
|
||||
])
|
||||
|
||||
const [footerWidth, setFooterWidth] = useState(0)
|
||||
|
||||
@@ -504,6 +513,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const listHeight = useMemo(() => {
|
||||
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
|
||||
}, [ctx.pageSize, list.length])
|
||||
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
|
||||
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
|
||||
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
|
||||
const collapsed = hasSearchText && visibleNonPinnedCount === 0
|
||||
|
||||
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
||||
|
||||
@@ -551,6 +564,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
$pageSize={ctx.pageSize}
|
||||
$selectedColor={selectedColor}
|
||||
$selectedColorHover={selectedColorHover}
|
||||
$collapsed={collapsed}
|
||||
className={ctx.isVisible ? 'visible' : ''}
|
||||
data-testid="quick-panel">
|
||||
<QuickPanelBody
|
||||
@@ -561,17 +575,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
size={listHeight}
|
||||
estimateSize={estimateSize}
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
pointerEvents: isMouseOver ? 'auto' : 'none'
|
||||
}}>
|
||||
{rowRenderer}
|
||||
</DynamicVirtualList>
|
||||
{!collapsed && (
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
size={listHeight}
|
||||
estimateSize={estimateSize}
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
pointerEvents: isMouseOver ? 'auto' : 'none'
|
||||
}}>
|
||||
{rowRenderer}
|
||||
</DynamicVirtualList>
|
||||
)}
|
||||
<QuickPanelFooter ref={footerRef}>
|
||||
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
|
||||
<QuickPanelFooterTips $footerWidth={footerWidth}>
|
||||
@@ -615,6 +631,7 @@ const QuickPanelContainer = styled.div<{
|
||||
$pageSize: number
|
||||
$selectedColor: string
|
||||
$selectedColorHover: string
|
||||
$collapsed?: boolean
|
||||
}>`
|
||||
--focused-color: rgba(0, 0, 0, 0.06);
|
||||
--selected-color: ${(props) => props.$selectedColor};
|
||||
@@ -633,8 +650,8 @@ const QuickPanelContainer = styled.div<{
|
||||
pointer-events: none;
|
||||
|
||||
&.visible {
|
||||
pointer-events: auto;
|
||||
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px;
|
||||
pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')};
|
||||
max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px;
|
||||
}
|
||||
body[theme-mode='dark'] & {
|
||||
--focused-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Tooltip } from 'antd'
|
||||
import {
|
||||
FileSearch,
|
||||
Folder,
|
||||
Hammer,
|
||||
Home,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkle,
|
||||
SquareTerminal,
|
||||
Sun,
|
||||
Terminal,
|
||||
X
|
||||
@@ -53,7 +53,7 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => {
|
||||
case 'knowledge':
|
||||
return <FileSearch size={14} />
|
||||
case 'mcp':
|
||||
return <SquareTerminal size={14} />
|
||||
return <Hammer size={14} />
|
||||
case 'files':
|
||||
return <Folder size={14} />
|
||||
case 'settings':
|
||||
@@ -206,8 +206,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
-webkit-app-region: drag;
|
||||
height: var(--navbar-height);
|
||||
position: relative;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
/* 确保交互元素在拖拽区域之上 */
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
@@ -220,7 +228,6 @@ const Tab = styled.div<{ active?: boolean }>`
|
||||
border-radius: var(--list-item-border-radius);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-app-region: none;
|
||||
height: 30px;
|
||||
min-width: 90px;
|
||||
transition: background 0.2s;
|
||||
@@ -273,7 +280,6 @@ const AddTabButton = styled.div`
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-2);
|
||||
-webkit-app-region: none;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
&.active {
|
||||
background: var(--color-list-item);
|
||||
@@ -298,7 +304,6 @@ const ThemeButton = styled.div`
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-list-item);
|
||||
@@ -314,7 +319,6 @@ const SettingsButton = styled.div<{ $active: boolean }>`
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
border-radius: 8px;
|
||||
background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')};
|
||||
&:hover {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
|
||||
import { useAppInit } from '@renderer/hooks/useAppInit'
|
||||
import { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { message, Modal } from 'antd'
|
||||
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
@@ -24,6 +26,8 @@ type ElementItem = {
|
||||
element: React.FC | React.ReactNode
|
||||
}
|
||||
|
||||
// const logger = loggerService.withContext('TopView')
|
||||
|
||||
const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
const [elements, setElements] = useState<ElementItem[]>([])
|
||||
const elementsRef = useRef<ElementItem[]>([])
|
||||
@@ -31,6 +35,8 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
|
||||
const [messageApi, messageContextHolder] = message.useMessage()
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
const { shortcuts } = useShortcuts()
|
||||
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
|
||||
|
||||
useAppInit()
|
||||
|
||||
@@ -72,6 +78,21 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// logger.debug('keydown', e)
|
||||
if (!enableQuitFullScreen) return
|
||||
|
||||
if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
window.api.setFullScreen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
@@ -9,13 +9,12 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getSidebarIconLabel, getThemeModeLabel } from '@renderer/i18n/label'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Tooltip } from 'antd'
|
||||
import {
|
||||
CircleHelp,
|
||||
Code,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Languages,
|
||||
@@ -37,8 +36,8 @@ import UserPopup from '../Popups/UserPopup'
|
||||
import { SidebarOpenedMinappTabs, SidebarPinnedApps } from './PinnedMinapps'
|
||||
|
||||
const Sidebar: FC = () => {
|
||||
const { hideMinappPopup, openMinapp } = useMinappPopup()
|
||||
const { minappShow, currentMinappId } = useRuntime()
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { minappShow } = useRuntime()
|
||||
const { sidebarIcons } = useSettings()
|
||||
const { pinned } = useMinapps()
|
||||
|
||||
@@ -60,17 +59,6 @@ const Sidebar: FC = () => {
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const docsId = 'cherrystudio-docs'
|
||||
const onOpenDocs = () => {
|
||||
const isChinese = i18n.language.startsWith('zh')
|
||||
openMinapp({
|
||||
id: docsId,
|
||||
name: t('docs.title'),
|
||||
url: isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us',
|
||||
logo: AppLogo
|
||||
})
|
||||
}
|
||||
|
||||
const isFullscreen = useFullscreen()
|
||||
|
||||
return (
|
||||
@@ -100,11 +88,6 @@ const Sidebar: FC = () => {
|
||||
)}
|
||||
</MainMenusContainer>
|
||||
<Menus>
|
||||
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
|
||||
<CircleHelp size={20} className="icon" />
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
|
||||
mouseEnterDelay={0.8}
|
||||
@@ -153,7 +136,8 @@ const MainMenus: FC = () => {
|
||||
translate: <Languages size={18} className="icon" />,
|
||||
minapp: <LayoutGrid size={18} className="icon" />,
|
||||
knowledge: <FileSearch size={18} className="icon" />,
|
||||
files: <Folder size={17} className="icon" />
|
||||
files: <Folder size={17} className="icon" />,
|
||||
code_tools: <Code size={18} className="icon" />
|
||||
}
|
||||
|
||||
const pathMap = {
|
||||
@@ -163,7 +147,8 @@ const MainMenus: FC = () => {
|
||||
translate: '/translate',
|
||||
minapp: '/apps',
|
||||
knowledge: '/knowledge',
|
||||
files: '/files'
|
||||
files: '/files',
|
||||
code_tools: '/code'
|
||||
}
|
||||
|
||||
return sidebarIcons.visible.map((icon) => {
|
||||
|
||||
110
src/renderer/src/components/dnd/ItemRenderer.tsx
Normal file
110
src/renderer/src/components/dnd/ItemRenderer.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { DraggableSyntheticListeners } from '@dnd-kit/core'
|
||||
import { Transform } from '@dnd-kit/utilities'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ItemRendererProps<T> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
item: T
|
||||
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||
dragging?: boolean
|
||||
dragOverlay?: boolean
|
||||
ghost?: boolean
|
||||
transform?: Transform | null
|
||||
transition?: string | null
|
||||
listeners?: DraggableSyntheticListeners
|
||||
}
|
||||
|
||||
export function ItemRenderer<T>({
|
||||
ref,
|
||||
item,
|
||||
renderItem,
|
||||
dragging,
|
||||
dragOverlay,
|
||||
ghost,
|
||||
transform,
|
||||
transition,
|
||||
listeners,
|
||||
...props
|
||||
}: ItemRendererProps<T>) {
|
||||
useEffect(() => {
|
||||
if (!dragOverlay) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
return () => {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}, [dragOverlay])
|
||||
|
||||
const wrapperStyle = {
|
||||
transition,
|
||||
'--translate-x': transform ? `${Math.round(transform.x)}px` : undefined,
|
||||
'--translate-y': transform ? `${Math.round(transform.y)}px` : undefined,
|
||||
'--scale-x': transform?.scaleX ? `${transform.scaleX}` : undefined,
|
||||
'--scale-y': transform?.scaleY ? `${transform.scaleY}` : undefined
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<ItemWrapper ref={ref} className={classNames({ dragOverlay: dragOverlay })} style={{ ...wrapperStyle }}>
|
||||
<DraggableItem
|
||||
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
|
||||
{...listeners}
|
||||
{...props}>
|
||||
{renderItem(item, { dragging: !!dragging })}
|
||||
</DraggableItem>
|
||||
</ItemWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemWrapper = styled.div`
|
||||
box-sizing: border-box;
|
||||
transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1))
|
||||
scaleY(var(--scale-y, 1));
|
||||
transform-origin: 0 0;
|
||||
touch-action: manipulation;
|
||||
|
||||
&.dragOverlay {
|
||||
--scale: 1.02;
|
||||
z-index: 999;
|
||||
position: relative;
|
||||
}
|
||||
`
|
||||
|
||||
const DraggableItem = styled.div`
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer; /* default cursor for items */
|
||||
touch-action: manipulation;
|
||||
transform-origin: 50% 50%;
|
||||
transform: scale(var(--scale, 1));
|
||||
|
||||
&.dragging:not(.dragOverlay) {
|
||||
z-index: 0;
|
||||
opacity: 0.25;
|
||||
|
||||
&:not(.ghost) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.dragOverlay {
|
||||
cursor: inherit;
|
||||
animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22);
|
||||
transform: scale(var(--scale));
|
||||
opacity: 1;
|
||||
pointer-events: none; /* prevent pointer events on drag overlay */
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(var(--scale));
|
||||
}
|
||||
}
|
||||
`
|
||||
192
src/renderer/src/components/dnd/Sortable.tsx
Normal file
192
src/renderer/src/components/dnd/Sortable.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Active,
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
Over,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import { restrictToHorizontalAxis, restrictToVerticalAxis } from '@dnd-kit/modifiers'
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { ItemRenderer } from './ItemRenderer'
|
||||
import { SortableItem } from './SortableItem'
|
||||
import { PortalSafePointerSensor } from './utils'
|
||||
|
||||
interface SortableProps<T> {
|
||||
/** Array of sortable items */
|
||||
items: T[]
|
||||
/** Function or key to get unique identifier for each item */
|
||||
itemKey: keyof T | ((item: T) => string | number)
|
||||
/** Callback when sorting is complete, receives old and new indices */
|
||||
onSortEnd: (event: { oldIndex: number; newIndex: number }) => void
|
||||
/** Callback when drag starts, will be passed to dnd-kit's onDragStart */
|
||||
onDragStart?: (event: { active: Active }) => void
|
||||
/** Callback when drag ends, will be passed to dnd-kit's onDragEnd */
|
||||
onDragEnd?: (event: { over: Over }) => void
|
||||
/** Function to render individual item, receives item data and drag state */
|
||||
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||
/** Layout type - 'list' for vertical/horizontal list, 'grid' for grid layout */
|
||||
layout?: 'list' | 'grid'
|
||||
/** Whether sorting is horizontal */
|
||||
horizontal?: boolean
|
||||
/** Whether to use drag overlay
|
||||
* If you want to hide ghost item, set showGhost to false rather than useDragOverlay.
|
||||
*/
|
||||
useDragOverlay?: boolean
|
||||
/** Whether to show ghost item, only works when useDragOverlay is true */
|
||||
showGhost?: boolean
|
||||
/** Item list class name */
|
||||
className?: string
|
||||
/** Item list style */
|
||||
listStyle?: React.CSSProperties
|
||||
/** Ghost item style */
|
||||
ghostItemStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
function Sortable<T>({
|
||||
items,
|
||||
itemKey,
|
||||
onSortEnd,
|
||||
onDragStart: customOnDragStart,
|
||||
onDragEnd: customOnDragEnd,
|
||||
renderItem,
|
||||
layout = 'list',
|
||||
horizontal = false,
|
||||
useDragOverlay = true,
|
||||
showGhost = false,
|
||||
className,
|
||||
listStyle
|
||||
}: SortableProps<T>) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PortalSafePointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
}
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 5
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
)
|
||||
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as string | number)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
const itemIds = useMemo(() => items.map(getId), [items, getId])
|
||||
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
|
||||
const activeItem = activeId ? items.find((item) => getId(item) === activeId) : null
|
||||
|
||||
const getIndex = (id: UniqueIdentifier) => itemIds.indexOf(id)
|
||||
|
||||
const activeIndex = activeId ? getIndex(activeId) : -1
|
||||
|
||||
const handleDragStart = ({ active }) => {
|
||||
customOnDragStart?.({ active })
|
||||
if (active) {
|
||||
setActiveId(active.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ over }) => {
|
||||
setActiveId(null)
|
||||
|
||||
customOnDragEnd?.({ over })
|
||||
if (over) {
|
||||
const overIndex = getIndex(over.id)
|
||||
if (activeIndex !== overIndex) {
|
||||
onSortEnd({ oldIndex: activeIndex, newIndex: overIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
const strategy =
|
||||
layout === 'list' ? (horizontal ? horizontalListSortingStrategy : verticalListSortingStrategy) : rectSortingStrategy
|
||||
const modifiers = layout === 'list' ? (horizontal ? [restrictToHorizontalAxis] : [restrictToVerticalAxis]) : []
|
||||
|
||||
const dropAnimation: DropAnimation = useMemo(
|
||||
() => ({
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: { opacity: showGhost ? '0.25' : '0' }
|
||||
}
|
||||
})
|
||||
}),
|
||||
[showGhost]
|
||||
)
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
modifiers={modifiers}>
|
||||
<SortableContext items={itemIds} strategy={strategy}>
|
||||
<ListWrapper className={className} data-layout={layout} style={listStyle}>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={itemIds[index]}
|
||||
item={item}
|
||||
getId={getId}
|
||||
renderItem={renderItem}
|
||||
useDragOverlay={useDragOverlay}
|
||||
showGhost={showGhost}
|
||||
/>
|
||||
))}
|
||||
</ListWrapper>
|
||||
</SortableContext>
|
||||
|
||||
{useDragOverlay
|
||||
? createPortal(
|
||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
const ListWrapper = styled.div`
|
||||
&[data-layout='grid'] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default Sortable
|
||||
41
src/renderer/src/components/dnd/SortableItem.tsx
Normal file
41
src/renderer/src/components/dnd/SortableItem.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import React from 'react'
|
||||
|
||||
import { ItemRenderer } from './ItemRenderer'
|
||||
|
||||
interface SortableItemProps<T> {
|
||||
item: T
|
||||
getId: (item: T) => string | number
|
||||
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||
useDragOverlay?: boolean
|
||||
showGhost?: boolean
|
||||
}
|
||||
|
||||
export function SortableItem<T>({
|
||||
item,
|
||||
getId,
|
||||
renderItem,
|
||||
useDragOverlay = true,
|
||||
showGhost = true
|
||||
}: SortableItemProps<T>) {
|
||||
const id = getId(item)
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id
|
||||
})
|
||||
|
||||
return (
|
||||
<ItemRenderer
|
||||
ref={setNodeRef}
|
||||
item={item}
|
||||
renderItem={renderItem}
|
||||
dragging={isDragging}
|
||||
dragOverlay={!useDragOverlay && isDragging}
|
||||
ghost={showGhost && useDragOverlay && isDragging}
|
||||
transform={transform}
|
||||
transition={transition}
|
||||
listeners={listeners}
|
||||
{...attributes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
3
src/renderer/src/components/dnd/index.ts
Normal file
3
src/renderer/src/components/dnd/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Sortable } from './Sortable'
|
||||
export * from './useDndReorder'
|
||||
export * from './useDndState'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user