mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
Merge branch 'main' of https://github.com/CherryHQ/cherry-studio into wip/data-refactor
This commit is contained in:
commit
83fea49ed2
12
.github/workflows/pr-ci.yml
vendored
12
.github/workflows/pr-ci.yml
vendored
@ -45,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 {
|
||||
43
package.json
43
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.5.7-rc.1",
|
||||
"version": "1.5.7-rc.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -79,7 +79,9 @@
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"selection-hook": "^1.0.9",
|
||||
"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": {
|
||||
@ -104,7 +106,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",
|
||||
@ -146,7 +151,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",
|
||||
@ -154,9 +159,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",
|
||||
@ -183,7 +188,7 @@
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.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",
|
||||
@ -207,6 +212,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",
|
||||
@ -230,6 +236,7 @@
|
||||
"proxy-agent": "^6.5.0",
|
||||
"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",
|
||||
@ -277,21 +284,25 @@
|
||||
"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": {
|
||||
|
||||
@ -156,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',
|
||||
@ -306,5 +308,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'
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,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'
|
||||
@ -72,7 +73,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)
|
||||
@ -445,6 +446,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) => {
|
||||
@ -469,6 +471,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))
|
||||
@ -710,6 +713,9 @@ 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))
|
||||
|
||||
// Preference handlers
|
||||
PreferenceService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -201,20 +201,14 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
*/
|
||||
private async putFile(filePath: string, url: string): Promise<void> {
|
||||
try {
|
||||
// 获取文件大小用于设置 Content-Length
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
const fileSize = stats.size
|
||||
|
||||
// 创建可读流
|
||||
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()
|
||||
}
|
||||
})
|
||||
duplex: 'half'
|
||||
} as any) // TypeScript 类型转换,net.fetch 需要 duplex 选项
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
||||
|
||||
import BraveSearchServer from './brave-search'
|
||||
import DifyKnowledgeServer from './dify-knowledge'
|
||||
@ -11,30 +12,34 @@ import ThinkingServer from './sequentialthinking'
|
||||
|
||||
const logger = loggerService.withContext('MCPFactory')
|
||||
|
||||
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
|
||||
export function createInMemoryMCPServer(
|
||||
name: BuiltinMCPServerName,
|
||||
args: string[] = [],
|
||||
envs: Record<string, string> = {}
|
||||
): Server {
|
||||
logger.debug(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
|
||||
switch (name) {
|
||||
case '@cherry/memory': {
|
||||
case BuiltinMCPServerNames.memory: {
|
||||
const envPath = envs.MEMORY_FILE_PATH
|
||||
return new MemoryServer(envPath).server
|
||||
}
|
||||
case '@cherry/sequentialthinking': {
|
||||
case BuiltinMCPServerNames.sequentialThinking: {
|
||||
return new ThinkingServer().server
|
||||
}
|
||||
case '@cherry/brave-search': {
|
||||
case BuiltinMCPServerNames.braveSearch: {
|
||||
return new BraveSearchServer(envs.BRAVE_API_KEY).server
|
||||
}
|
||||
case '@cherry/fetch': {
|
||||
case BuiltinMCPServerNames.fetch: {
|
||||
return new FetchServer().server
|
||||
}
|
||||
case '@cherry/filesystem': {
|
||||
case BuiltinMCPServerNames.filesystem: {
|
||||
return new FileSystemServer(args).server
|
||||
}
|
||||
case '@cherry/dify-knowledge': {
|
||||
case BuiltinMCPServerNames.difyKnowledge: {
|
||||
const difyKey = envs.DIFY_KEY
|
||||
return new DifyKnowledgeServer(difyKey, args).server
|
||||
}
|
||||
case '@cherry/python': {
|
||||
case BuiltinMCPServerNames.python: {
|
||||
return new PythonServer().server
|
||||
}
|
||||
default:
|
||||
|
||||
@ -7,6 +7,7 @@ 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'
|
||||
|
||||
@ -41,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> {
|
||||
@ -192,7 +203,7 @@ class CodeToolsService {
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const updateCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
|
||||
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
logger.info(`Executing update command: ${updateCommand}`)
|
||||
|
||||
await execAsync(updateCommand, { timeout: 60000 })
|
||||
@ -296,7 +307,7 @@ class CodeToolsService {
|
||||
}
|
||||
|
||||
// Build command to execute
|
||||
let baseCommand = isWin ? `${executablePath}` : `${bunPath} ${executablePath}`
|
||||
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
if (isInstalled) {
|
||||
@ -326,8 +337,9 @@ class CodeToolsService {
|
||||
terminalArgs = [
|
||||
'-e',
|
||||
`tell application "Terminal"
|
||||
set newTab to do script "cd '${directory.replace(/'/g, "\\'")}' && clear"
|
||||
activate
|
||||
do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}"
|
||||
do script "${command.replace(/"/g, '\\"')}" in newTab
|
||||
end tell`
|
||||
]
|
||||
break
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { net } from 'electron'
|
||||
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')
|
||||
|
||||
// 配置常量,集中管理
|
||||
@ -28,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'
|
||||
}
|
||||
|
||||
// 接口定义移到顶部,便于查阅
|
||||
@ -67,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,6 +107,7 @@ class CopilotService {
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
accept: 'application/json',
|
||||
authorization: `token ${token}`
|
||||
}
|
||||
})
|
||||
@ -204,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)
|
||||
@ -221,7 +242,7 @@ 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 response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
|
||||
@ -249,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,7 +1,8 @@
|
||||
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,
|
||||
@ -15,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'
|
||||
@ -630,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,14 +21,22 @@ import {
|
||||
CancelledNotificationSchema,
|
||||
type GetPromptResult,
|
||||
LoggingMessageNotificationSchema,
|
||||
ProgressNotificationSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
ToolListChangedNotificationSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types'
|
||||
import {
|
||||
BuiltinMCPServerNames,
|
||||
type GetResourceResponse,
|
||||
isBuiltinMCPServer,
|
||||
type MCPCallToolResponse,
|
||||
type MCPPrompt,
|
||||
type MCPResource,
|
||||
type MCPServer,
|
||||
type MCPTool
|
||||
} from '@types'
|
||||
import { app, net } from 'electron'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
@ -163,7 +171,7 @@ class McpService {
|
||||
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
> => {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.type === 'inMemory') {
|
||||
if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) {
|
||||
logger.debug(`Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
@ -432,15 +440,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 +628,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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { loggerService } from '@logger'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
const logger = loggerService.withContext('SearchService')
|
||||
|
||||
export class SearchService {
|
||||
private static instance: SearchService | null = null
|
||||
private searchWindows: Record<string, BrowserWindow> = {}
|
||||
@ -55,6 +58,7 @@ export class SearchService {
|
||||
|
||||
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
|
||||
let window = this.searchWindows[uid]
|
||||
logger.debug(`Searching with URL: ${url}`)
|
||||
if (window) {
|
||||
await window.loadURL(url)
|
||||
} else {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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'
|
||||
|
||||
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)
|
||||
}
|
||||
@ -18,9 +18,12 @@ import {
|
||||
MemoryConfig,
|
||||
MemoryListOptions,
|
||||
MemorySearchOptions,
|
||||
OcrProvider,
|
||||
OcrResult,
|
||||
Provider,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
SupportedOcrFile,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
@ -134,14 +137,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 文件名
|
||||
@ -171,10 +175,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)
|
||||
@ -407,6 +413,10 @@ const api = {
|
||||
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)
|
||||
},
|
||||
preference: {
|
||||
get: <K extends PreferenceKeyType>(key: K): Promise<PreferenceDefaultScopeType[K]> =>
|
||||
ipcRenderer.invoke(IpcChannel.Preference_Get, key),
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}, [])
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import {
|
||||
Assistant,
|
||||
EFFORT_RATIO,
|
||||
FileTypes,
|
||||
GenerateImageParams,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
@ -53,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'
|
||||
@ -683,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' })
|
||||
|
||||
@ -52,6 +52,7 @@ import {
|
||||
GeminiSdkRawOutput,
|
||||
GeminiSdkToolCall
|
||||
} from '@renderer/types/sdk'
|
||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import {
|
||||
geminiFunctionCallToMcpTool,
|
||||
isEnabledToolUse,
|
||||
@ -428,8 +429,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
private getGenerateImageParameter(): Partial<GenerateContentConfig> {
|
||||
return {
|
||||
systemInstruction: undefined,
|
||||
responseModalities: [Modality.TEXT, Modality.IMAGE],
|
||||
responseMimeType: 'text/plain'
|
||||
responseModalities: [Modality.TEXT, Modality.IMAGE]
|
||||
}
|
||||
}
|
||||
|
||||
@ -476,16 +476,20 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
if (enableWebSearch) {
|
||||
tools.push({
|
||||
googleSearch: {}
|
||||
})
|
||||
}
|
||||
if (tools.length === 0 || !isToolUseModeFunction(assistant)) {
|
||||
if (enableWebSearch) {
|
||||
tools.push({
|
||||
googleSearch: {}
|
||||
})
|
||||
}
|
||||
|
||||
if (enableUrlContext) {
|
||||
tools.push({
|
||||
urlContext: {}
|
||||
})
|
||||
if (enableUrlContext) {
|
||||
tools.push({
|
||||
urlContext: {}
|
||||
})
|
||||
}
|
||||
} else if (enableWebSearch || enableUrlContext) {
|
||||
logger.warn('Native tools cannot be used with function calling for now.')
|
||||
}
|
||||
|
||||
if (isGemmaModel(model) && assistant.prompt) {
|
||||
|
||||
@ -6,11 +6,13 @@ import {
|
||||
getOpenAIWebSearchParams,
|
||||
getThinkModelType,
|
||||
isClaudeReasoningModel,
|
||||
isDeepSeekHybridInferenceModel,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isGeminiReasoningModel,
|
||||
isGPT5SeriesModel,
|
||||
isGrokReasoningModel,
|
||||
isNotSupportSystemMessageModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isOpenAIReasoningModel,
|
||||
isQwenAlwaysThinkModel,
|
||||
isQwenMTModel,
|
||||
@ -26,7 +28,8 @@ import {
|
||||
isSupportedThinkingTokenQwenModel,
|
||||
isSupportedThinkingTokenZhipuModel,
|
||||
isVisionModel,
|
||||
MODEL_SUPPORTED_REASONING_EFFORT
|
||||
MODEL_SUPPORTED_REASONING_EFFORT,
|
||||
ZHIPU_RESULT_TOKENS
|
||||
} from '@renderer/config/models'
|
||||
import {
|
||||
isSupportArrayContentProvider,
|
||||
@ -42,6 +45,7 @@ import {
|
||||
Assistant,
|
||||
EFFORT_RATIO,
|
||||
FileTypes,
|
||||
isSystemProvider,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
@ -111,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 {}
|
||||
}
|
||||
|
||||
@ -120,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' } }
|
||||
@ -144,7 +132,14 @@ 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 {}
|
||||
@ -156,17 +151,22 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
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 {
|
||||
@ -195,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: {
|
||||
@ -206,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 = {
|
||||
@ -213,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
|
||||
@ -530,12 +582,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// 1. 处理系统消息
|
||||
const systemMessage = { role: 'system', content: assistant.prompt || '' }
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) {
|
||||
@ -559,6 +611,9 @@ 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)
|
||||
@ -566,11 +621,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
|
||||
if (lastUserMsg) {
|
||||
if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) {
|
||||
const postsuffix = '/no_think'
|
||||
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 参数了,可以删掉这部分
|
||||
@ -586,8 +640,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// 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[]
|
||||
}
|
||||
@ -818,7 +881,9 @@ 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 !== ''))
|
||||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '') ||
|
||||
((choice.delta as OpenAISdkRawContentSource).images &&
|
||||
Array.isArray((choice.delta as OpenAISdkRawContentSource).images)))
|
||||
) {
|
||||
contentSource = choice.delta
|
||||
} else if ('message' in choice) {
|
||||
@ -896,27 +961,59 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
accumulatingText = true
|
||||
}
|
||||
// logger.silly('enqueue TEXT_DELTA')
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
})
|
||||
// 处理特殊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
|
||||
}
|
||||
|
||||
// 处理图片内容 (e.g. from OpenRouter Gemini image generation models)
|
||||
if (contentSource.images && Array.isArray(contentSource.images)) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.IMAGE_CREATED
|
||||
})
|
||||
controller.enqueue({
|
||||
type: ChunkType.IMAGE_COMPLETE,
|
||||
image: {
|
||||
type: 'base64',
|
||||
images: contentSource.images.map((image) => image.image_url?.url || '')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (contentSource.tool_calls) {
|
||||
for (const toolCall of contentSource.tool_calls) {
|
||||
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. 设置工具
|
||||
|
||||
@ -112,7 +112,7 @@ export default class AiProvider {
|
||||
builder.remove(ToolUseExtractionMiddlewareName)
|
||||
logger.silly('ToolUseExtractionMiddleware is removed')
|
||||
}
|
||||
if (params.callType !== 'chat') {
|
||||
if (params.callType !== 'chat' && params.callType !== 'check' && params.callType !== 'translate') {
|
||||
logger.silly('AbortHandlerMiddleware is removed')
|
||||
builder.remove(AbortHandlerMiddlewareName)
|
||||
}
|
||||
|
||||
@ -21,32 +21,38 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
|
||||
return result
|
||||
}
|
||||
|
||||
// 获取当前消息的ID用于abort管理
|
||||
// 优先使用处理过的消息,如果没有则使用原始消息
|
||||
let messageId: string | undefined
|
||||
|
||||
if (typeof params.messages === 'string') {
|
||||
messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
} else {
|
||||
const processedMessages = params.messages
|
||||
const lastUserMessage = processedMessages.findLast((m) => m.role === 'user')
|
||||
messageId = lastUserMessage?.id
|
||||
}
|
||||
|
||||
if (!messageId) {
|
||||
logger.warn(`No messageId found, abort functionality will not be available.`)
|
||||
return next(ctx, params)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
const abortFn = (): void => abortController.abort()
|
||||
|
||||
addAbortController(messageId, abortFn)
|
||||
|
||||
let abortSignal: AbortSignal | null = abortController.signal
|
||||
let abortKey: string
|
||||
|
||||
// 如果参数中传入了abortKey则优先使用
|
||||
if (params.abortKey) {
|
||||
abortKey = params.abortKey
|
||||
} else {
|
||||
// 获取当前消息的ID用于abort管理
|
||||
// 优先使用处理过的消息,如果没有则使用原始消息
|
||||
let messageId: string | undefined
|
||||
|
||||
if (typeof params.messages === 'string') {
|
||||
messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
} else {
|
||||
const processedMessages = params.messages
|
||||
const lastUserMessage = processedMessages.findLast((m) => m.role === 'user')
|
||||
messageId = lastUserMessage?.id
|
||||
}
|
||||
|
||||
if (!messageId) {
|
||||
logger.warn(`No messageId found, abort functionality will not be available.`)
|
||||
return next(ctx, params)
|
||||
}
|
||||
|
||||
abortKey = messageId
|
||||
}
|
||||
|
||||
addAbortController(abortKey, abortFn)
|
||||
const cleanup = (): void => {
|
||||
removeAbortController(messageId as string, abortFn)
|
||||
removeAbortController(abortKey, abortFn)
|
||||
if (ctx._internal?.flowControl) {
|
||||
ctx._internal.flowControl.abortController = undefined
|
||||
ctx._internal.flowControl.abortSignal = undefined
|
||||
|
||||
@ -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> 标签
|
||||
}
|
||||
|
||||
@ -59,6 +59,9 @@ export interface CompletionsParams {
|
||||
contextCount?: number
|
||||
topicId?: string // 主题ID,用于关联上下文
|
||||
|
||||
// abort 控制
|
||||
abortKey?: string
|
||||
|
||||
_internal?: ProcessingState
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
@ -106,6 +106,10 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.katex span {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
p code,
|
||||
li code {
|
||||
background: var(--color-background-mute);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -157,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) =>
|
||||
@ -177,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`
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal, Splitter, Tooltip } from 'antd'
|
||||
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'
|
||||
@ -43,7 +43,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
const renderHeader = () => (
|
||||
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
<TitleText ellipsis={{ tooltip: true }}>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderCenter>
|
||||
@ -266,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`
|
||||
|
||||
@ -18,8 +18,8 @@ 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 } from '@renderer/utils/markdown'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -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,16 +55,16 @@ 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
|
||||
@ -71,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
|
||||
@ -83,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>
|
||||
|
||||
@ -29,20 +29,6 @@ vi.mock('@hello-pangea/dnd', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// mock antd list 只做简单渲染
|
||||
vi.mock('antd', () => ({
|
||||
__esModule: true,
|
||||
List: ({ dataSource, renderItem }: any) => (
|
||||
<div data-testid="virtual-list">
|
||||
{dataSource.map((item: any, idx: number) => (
|
||||
<div key={item.id || item} data-testid="virtual-list-item">
|
||||
{renderItem(item, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
triggerOnDragEnd: (result?: any, provided?: any) => void
|
||||
@ -73,14 +59,15 @@ describe('DraggableList', () => {
|
||||
const list = [{ id: 'a', name: 'A' }]
|
||||
const style = { background: 'red' }
|
||||
const listStyle = { color: 'blue' }
|
||||
render(
|
||||
const { container } = render(
|
||||
<DraggableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DraggableList>
|
||||
)
|
||||
// 检查 style 是否传递到外层容器
|
||||
const virtualList = screen.getByTestId('virtual-list')
|
||||
expect(virtualList.parentElement).toHaveStyle({ background: 'red' })
|
||||
const listContainer = container.querySelector('.draggable-list-container')
|
||||
expect(listContainer).not.toBeNull()
|
||||
expect(listContainer?.parentElement).toHaveStyle({ background: 'red' })
|
||||
})
|
||||
|
||||
it('should render nothing when list is empty', () => {
|
||||
|
||||
@ -32,7 +32,7 @@ vi.mock('@hello-pangea/dnd', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count }) => ({
|
||||
useVirtualizer: ({ count, getScrollElement }) => ({
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
@ -41,7 +41,13 @@ vi.mock('@tanstack/react-virtual', () => ({
|
||||
size: 50
|
||||
})),
|
||||
getTotalSize: () => count * 50,
|
||||
measureElement: vi.fn()
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
scrollToOffset: vi.fn(),
|
||||
scrollElement: getScrollElement(),
|
||||
measure: vi.fn(),
|
||||
resizeItem: vi.fn(),
|
||||
getVirtualIndexes: () => Array.from({ length: count }, (_, i) => i)
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@ -10,56 +10,44 @@ exports[`DraggableList > snapshot > should match snapshot 1`] = `
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
data-testid="virtual-list"
|
||||
class="draggable-list-container"
|
||||
>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
data-testid="draggable-a-0"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-a-0"
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
data-testid="item"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
A
|
||||
</div>
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
data-testid="draggable-b-1"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-b-1"
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
data-testid="item"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
B
|
||||
</div>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
data-testid="draggable-c-2"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-c-2"
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
data-testid="item"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
C
|
||||
</div>
|
||||
C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
export { default as DraggableList } from './list'
|
||||
export { useDraggableReorder } from './useDraggableReorder'
|
||||
export { default as DraggableVirtualList } from './virtual-list'
|
||||
export {
|
||||
default as DraggableVirtualList,
|
||||
type DraggableVirtualListProps,
|
||||
type DraggableVirtualListRef
|
||||
} from './virtual-list'
|
||||
|
||||
@ -9,13 +9,13 @@ import {
|
||||
ResponderProvided
|
||||
} from '@hello-pangea/dnd'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import { List } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, HTMLAttributes } from 'react'
|
||||
|
||||
interface Props<T> {
|
||||
list: T[]
|
||||
style?: React.CSSProperties
|
||||
listStyle?: React.CSSProperties
|
||||
listProps?: HTMLAttributes<HTMLDivElement>
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
onUpdate: (list: T[]) => void
|
||||
onDragStart?: OnDragStartResponder
|
||||
@ -28,6 +28,7 @@ const DraggableList: FC<Props<any>> = ({
|
||||
list,
|
||||
style,
|
||||
listStyle,
|
||||
listProps,
|
||||
droppableProps,
|
||||
onDragStart,
|
||||
onUpdate,
|
||||
@ -50,9 +51,8 @@ const DraggableList: FC<Props<any>> = ({
|
||||
<Droppable droppableId="droppable" {...droppableProps}>
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
|
||||
<List
|
||||
dataSource={list}
|
||||
renderItem={(item, index) => {
|
||||
<div {...listProps} className="draggable-list-container">
|
||||
{list.map((item, index) => {
|
||||
const id = item.id || item
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
@ -71,8 +71,8 @@ const DraggableList: FC<Props<any>> = ({
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -10,8 +10,19 @@ import {
|
||||
} from '@hello-pangea/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type Key, memo, useCallback, useRef } from 'react'
|
||||
import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
|
||||
import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react'
|
||||
|
||||
export interface DraggableVirtualListRef {
|
||||
measure: () => void
|
||||
scrollElement: () => HTMLDivElement | null
|
||||
scrollToOffset: (offset: number, options?: ScrollToOptions) => void
|
||||
scrollToIndex: (index: number, options?: ScrollToOptions) => void
|
||||
resizeItem: (index: number, size: number) => void
|
||||
getTotalSize: () => number
|
||||
getVirtualItems: () => VirtualItem[]
|
||||
getVirtualIndexes: () => number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 泛型 Props,用于配置 DraggableVirtualList。
|
||||
@ -31,8 +42,8 @@ import { type Key, memo, useCallback, useRef } from 'react'
|
||||
* @property {React.ReactNode} [header] 列表头部内容
|
||||
* @property {(item: T, index: number) => React.ReactNode} children 列表项渲染函数
|
||||
*/
|
||||
interface DraggableVirtualListProps<T> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
export interface DraggableVirtualListProps<T> {
|
||||
ref?: React.Ref<DraggableVirtualListRef>
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
scrollerStyle?: React.CSSProperties
|
||||
@ -100,9 +111,23 @@ function DraggableVirtualList<T>({
|
||||
overscan
|
||||
})
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
measure: () => virtualizer.measure(),
|
||||
scrollElement: () => virtualizer.scrollElement,
|
||||
scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options),
|
||||
scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options),
|
||||
resizeItem: (index, size) => virtualizer.resizeItem(index, size),
|
||||
getTotalSize: () => virtualizer.getTotalSize(),
|
||||
getVirtualItems: () => virtualizer.getVirtualItems(),
|
||||
getVirtualIndexes: () => virtualizer.getVirtualIndexes()
|
||||
}),
|
||||
[virtualizer]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${className} draggable-virtual-list`}
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
|
||||
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 }
|
||||
@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@ -193,7 +193,7 @@ export function ExaLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@ -211,30 +211,75 @@ 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
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
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"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
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
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
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
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,20 +8,19 @@ import {
|
||||
} from '@renderer/config/models'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { Model } from '@renderer/types'
|
||||
import { isFreeModel } from '@renderer/utils'
|
||||
import { isFreeModel } from '@renderer/utils/model'
|
||||
import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CustomTag from './Tags/CustomTag'
|
||||
import {
|
||||
EmbeddingTag,
|
||||
FreeTag,
|
||||
ReasoningTag,
|
||||
RerankerTag,
|
||||
ToolsCallingTag,
|
||||
VisionTag,
|
||||
WebSearchTag
|
||||
} from './Tags/ModelCapabilities'
|
||||
} from './Tags/Model'
|
||||
|
||||
interface ModelTagsProps {
|
||||
model: Model
|
||||
@ -44,7 +43,6 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
showTooltip = true,
|
||||
style
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [shouldShowLabel, setShouldShowLabel] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const resizeObserver = useRef<ResizeObserver | null>(null)
|
||||
@ -86,7 +84,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
<ToolsCallingTag size={size} showTooltip={showTooltip} showLabel={shouldShowLabel} />
|
||||
)}
|
||||
{isEmbeddingModel(model) && <EmbeddingTag size={size} />}
|
||||
{showFree && isFreeModel(model) && <CustomTag size={size} color="#7cb305" icon={t('models.type.free')} />}
|
||||
{showFree && isFreeModel(model) && <FreeTag size={size} />}
|
||||
{isRerankModel(model) && <RerankerTag size={size} />}
|
||||
</Container>
|
||||
)
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,16 +1,36 @@
|
||||
import { PushpinOutlined } from '@ant-design/icons'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import {
|
||||
EmbeddingTag,
|
||||
FreeTag,
|
||||
ReasoningTag,
|
||||
RerankerTag,
|
||||
ToolsCallingTag,
|
||||
VisionTag,
|
||||
WebSearchTag
|
||||
} from '@renderer/components/Tags/Model'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
|
||||
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import {
|
||||
getModelLogo,
|
||||
isEmbeddingModel,
|
||||
isFunctionCallingModel,
|
||||
isReasoningModel,
|
||||
isRerankModel,
|
||||
isVisionModel,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types'
|
||||
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
|
||||
import { Avatar, Divider, Empty, Modal } from 'antd'
|
||||
import { getModelTags, isFreeModel } from '@renderer/utils/model'
|
||||
import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import React, {
|
||||
ReactNode,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
@ -24,22 +44,28 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelSearchBar from './searchbar'
|
||||
import { FlatListItem } from './types'
|
||||
import { FlatListItem, FlatListModel } from './types'
|
||||
|
||||
const PAGE_SIZE = 11
|
||||
const PAGE_SIZE = 12
|
||||
const ITEM_HEIGHT = 36
|
||||
|
||||
type ModelPredict = (m: Model) => boolean
|
||||
|
||||
interface PopupParams {
|
||||
model?: Model
|
||||
modelFilter?: (model: Model) => boolean
|
||||
userFilterDisabled?: boolean
|
||||
}
|
||||
|
||||
interface Props extends PopupParams {
|
||||
resolve: (value: Model | undefined) => void
|
||||
modelFilter?: (model: Model) => boolean
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
export type FilterType = Exclude<ModelType, 'text'> | 'free'
|
||||
|
||||
// const logger = loggerService.withContext('SelectModelPopup')
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilterDisabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
|
||||
@ -48,6 +74,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
|
||||
const allModels: Model[] = useMemo(
|
||||
() => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)),
|
||||
[modelFilter, providers]
|
||||
)
|
||||
|
||||
// 当前选中的模型ID
|
||||
const currentModelId = model ? getModelUniqId(model) : ''
|
||||
|
||||
@ -62,10 +93,99 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 管理用户筛选状态
|
||||
/** 从模型列表获取的需要显示的标签 */
|
||||
const availableTags = useMemo(
|
||||
() =>
|
||||
objectEntries(getModelTags(allModels))
|
||||
.filter(([, state]) => state)
|
||||
.map(([tag]) => tag),
|
||||
[allModels]
|
||||
)
|
||||
|
||||
const filterConfig: Record<ModelTag, ModelPredict> = useMemo(
|
||||
() => ({
|
||||
vision: isVisionModel,
|
||||
embedding: isEmbeddingModel,
|
||||
reasoning: isReasoningModel,
|
||||
function_calling: isFunctionCallingModel,
|
||||
web_search: isWebSearchModel,
|
||||
rerank: isRerankModel,
|
||||
free: isFreeModel
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
/** 当前选择的标签,表示是否启用特定tag的筛选 */
|
||||
const [filterTags, setFilterTags] = useState<Record<ModelTag, boolean>>({
|
||||
vision: false,
|
||||
embedding: false,
|
||||
reasoning: false,
|
||||
function_calling: false,
|
||||
web_search: false,
|
||||
rerank: false,
|
||||
free: false
|
||||
})
|
||||
const selectedFilterTags = useMemo(
|
||||
() =>
|
||||
objectEntries(filterTags)
|
||||
.filter(([, state]) => state)
|
||||
.map(([tag]) => tag),
|
||||
[filterTags]
|
||||
)
|
||||
|
||||
const userFilter = useCallback(
|
||||
(model: Model) => {
|
||||
return selectedFilterTags
|
||||
.map((tag) => [tag, filterConfig[tag]] as const)
|
||||
.reduce((prev, [tag, predict]) => {
|
||||
return prev && (!filterTags[tag] || predict(model))
|
||||
}, true)
|
||||
},
|
||||
[filterConfig, filterTags, selectedFilterTags]
|
||||
)
|
||||
|
||||
const onClickTag = useCallback((type: ModelTag) => {
|
||||
startTransition(() => {
|
||||
setFilterTags((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 筛选项列表
|
||||
const tagsItems: Record<ModelTag, ReactNode> = useMemo(
|
||||
() => ({
|
||||
vision: <VisionTag showLabel inactive={!filterTags.vision} onClick={() => onClickTag('vision')} />,
|
||||
embedding: <EmbeddingTag inactive={!filterTags.embedding} onClick={() => onClickTag('embedding')} />,
|
||||
reasoning: <ReasoningTag showLabel inactive={!filterTags.reasoning} onClick={() => onClickTag('reasoning')} />,
|
||||
function_calling: (
|
||||
<ToolsCallingTag
|
||||
showLabel
|
||||
inactive={!filterTags.function_calling}
|
||||
onClick={() => onClickTag('function_calling')}
|
||||
/>
|
||||
),
|
||||
web_search: <WebSearchTag showLabel inactive={!filterTags.web_search} onClick={() => onClickTag('web_search')} />,
|
||||
rerank: <RerankerTag inactive={!filterTags.rerank} onClick={() => onClickTag('rerank')} />,
|
||||
free: <FreeTag inactive={!filterTags.free} onClick={() => onClickTag('free')} />
|
||||
}),
|
||||
[
|
||||
filterTags.embedding,
|
||||
filterTags.free,
|
||||
filterTags.function_calling,
|
||||
filterTags.reasoning,
|
||||
filterTags.rerank,
|
||||
filterTags.vision,
|
||||
filterTags.web_search,
|
||||
onClickTag
|
||||
]
|
||||
)
|
||||
|
||||
// 要显示的筛选项
|
||||
const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems])
|
||||
// 根据输入的文本筛选模型
|
||||
const getFilteredModels = useCallback(
|
||||
const searchFilter = useCallback(
|
||||
(provider: Provider) => {
|
||||
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
let models = provider.models
|
||||
|
||||
if (searchText.trim()) {
|
||||
models = filterModelsByKeywords(searchText, models, provider)
|
||||
@ -78,7 +198,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
|
||||
// 创建模型列表项
|
||||
const createModelItem = useCallback(
|
||||
(model: Model, provider: Provider, isPinned: boolean): FlatListItem => {
|
||||
(model: Model, provider: Provider, isPinned: boolean): FlatListModel => {
|
||||
const modelId = getModelUniqId(model)
|
||||
const groupName = getFancyProviderName(provider)
|
||||
|
||||
@ -113,7 +233,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
const { listItems, modelItems } = useMemo(() => {
|
||||
const items: FlatListItem[] = []
|
||||
const pinnedModelIds = new Set(pinnedModels)
|
||||
const finalModelFilter = modelFilter || (() => true)
|
||||
const finalModelFilter = (model: Model) => {
|
||||
const _userFilter = userFilterDisabled || userFilter(model)
|
||||
const _modelFilter = modelFilter === undefined || modelFilter(model)
|
||||
return _userFilter && _modelFilter
|
||||
}
|
||||
|
||||
// 添加置顶模型分组(仅在无搜索文本时)
|
||||
if (searchText.length === 0 && pinnedModelIds.size > 0) {
|
||||
@ -139,7 +263,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
|
||||
// 添加常规模型分组
|
||||
providers.forEach((p) => {
|
||||
const filteredModels = getFilteredModels(p)
|
||||
const filteredModels = searchFilter(p)
|
||||
.filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m)))
|
||||
.filter(finalModelFilter)
|
||||
|
||||
@ -150,6 +274,22 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
key: `provider-${p.id}`,
|
||||
type: 'group',
|
||||
name: getFancyProviderName(p),
|
||||
actions: (
|
||||
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<SettingsIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
resolve(undefined)
|
||||
window.navigate(`/settings/provider?id=${p.id}`)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
isSelected: false
|
||||
})
|
||||
|
||||
@ -157,9 +297,20 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
})
|
||||
|
||||
// 获取可选择的模型项(过滤掉分组标题)
|
||||
const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[]
|
||||
const modelItems = items.filter((item) => item.type === 'model')
|
||||
return { listItems: items, modelItems }
|
||||
}, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels])
|
||||
}, [
|
||||
pinnedModels,
|
||||
searchText.length,
|
||||
providers,
|
||||
userFilterDisabled,
|
||||
userFilter,
|
||||
modelFilter,
|
||||
createModelItem,
|
||||
t,
|
||||
searchFilter,
|
||||
resolve
|
||||
])
|
||||
|
||||
const listHeight = useMemo(() => {
|
||||
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
|
||||
@ -307,7 +458,12 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
(item: FlatListItem) => {
|
||||
const isFocused = item.key === focusedItemKey
|
||||
if (item.type === 'group') {
|
||||
return <GroupItem>{item.name}</GroupItem>
|
||||
return (
|
||||
<GroupItem>
|
||||
{item.name}
|
||||
{item.actions}
|
||||
</GroupItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ModelItem
|
||||
@ -352,7 +508,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 16
|
||||
paddingBottom: 16,
|
||||
// 需要稳定高度避免布局偏移
|
||||
height: userFilterDisabled ? undefined : 530
|
||||
},
|
||||
body: {
|
||||
maxHeight: 'inherit',
|
||||
@ -364,6 +522,17 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
{/* 搜索框 */}
|
||||
<SelectModelSearchBar onSearch={setSearchText} />
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
{!userFilterDisabled && (
|
||||
<>
|
||||
<FilterContainer>
|
||||
<Flex wrap="wrap" gap={4}>
|
||||
<FilterText>{t('models.filter.by_tag')}</FilterText>
|
||||
{displayedTags.map((item) => item)}
|
||||
</Flex>
|
||||
</FilterContainer>
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{listItems.length > 0 ? (
|
||||
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
|
||||
@ -389,6 +558,16 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const FilterContainer = styled.div`
|
||||
padding: 8px;
|
||||
padding-left: 18px;
|
||||
`
|
||||
|
||||
const FilterText = styled.span`
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const ListContainer = styled.div`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@ -397,11 +576,12 @@ const ListContainer = styled.div`
|
||||
const GroupItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: normal;
|
||||
height: ${ITEM_HEIGHT}px;
|
||||
padding: 5px 10px 5px 18px;
|
||||
padding: 5px 12px 5px 18px;
|
||||
color: var(--color-text-3);
|
||||
z-index: 1;
|
||||
background: var(--modal-background);
|
||||
|
||||
@ -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,20 +1,46 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
// 列表项类型,组名也作为列表项
|
||||
export type ListItemType = 'group' | 'model'
|
||||
|
||||
// 滚动触发来源类型
|
||||
/**
|
||||
* 滚动触发来源类型
|
||||
*/
|
||||
export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
|
||||
|
||||
// 扁平化列表项接口
|
||||
export interface FlatListItem {
|
||||
/**
|
||||
* 列表项分类,组名也作为列表项
|
||||
*/
|
||||
export type ListItemType = 'group' | 'model'
|
||||
|
||||
/**
|
||||
* 扁平化列表项基础类型
|
||||
*/
|
||||
export type FlatListBaseItem = {
|
||||
key: string
|
||||
type: ListItemType
|
||||
icon?: ReactNode
|
||||
name: ReactNode
|
||||
tags?: ReactNode
|
||||
model?: Model
|
||||
isPinned?: boolean
|
||||
icon?: ReactNode
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型分组列表项
|
||||
*/
|
||||
export type FlatListGroup = FlatListBaseItem & {
|
||||
type: 'group'
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型列表项
|
||||
*/
|
||||
export type FlatListModel = FlatListBaseItem & {
|
||||
type: 'model'
|
||||
model: Model
|
||||
tags?: ReactNode
|
||||
isPinned?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 扁平化列表项
|
||||
*/
|
||||
export type FlatListItem = FlatListGroup | FlatListModel
|
||||
|
||||
@ -17,8 +17,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['style', 'defs', 'foreignObject']
|
||||
ADD_TAGS: ['foreignObject']
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
|
||||
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
|
||||
@ -54,6 +54,12 @@ export type QuickPanelListItem = {
|
||||
isSelected?: boolean
|
||||
isMenu?: boolean
|
||||
disabled?: boolean
|
||||
/**
|
||||
* 固定显示项:不参与过滤,始终出现在列表顶部。
|
||||
* 例如“清除”按钮可设置为 alwaysVisible,从而在有匹配项时始终可见;
|
||||
* 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。
|
||||
*/
|
||||
alwaysVisible?: boolean
|
||||
action?: (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,76 +142,52 @@ 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(
|
||||
@ -310,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')
|
||||
}
|
||||
}
|
||||
|
||||
@ -333,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])
|
||||
@ -349,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) {
|
||||
@ -487,7 +483,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
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)
|
||||
|
||||
@ -507,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, [])
|
||||
|
||||
@ -554,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
|
||||
@ -564,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}>
|
||||
@ -618,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};
|
||||
@ -636,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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
20
src/renderer/src/components/Tags/Model/FreeTag.tsx
Normal file
20
src/renderer/src/components/Tags/Model/FreeTag.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import CustomTag, { CustomTagProps } from '../CustomTag'
|
||||
|
||||
type Props = {
|
||||
size?: number
|
||||
showTooltip?: boolean
|
||||
} & Omit<CustomTagProps, 'size' | 'tooltip' | 'icon' | 'color' | 'children'>
|
||||
|
||||
export const FreeTag = ({ size, showTooltip, ...restProps }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<CustomTag
|
||||
size={size}
|
||||
color="#7cb305"
|
||||
icon={t('models.type.free')}
|
||||
tooltip={showTooltip ? t('models.type.free') : undefined}
|
||||
{...restProps}></CustomTag>
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { EmbeddingTag } from './EmbeddingTag'
|
||||
import { FreeTag } from './FreeTag'
|
||||
import { ReasoningTag } from './ReasoningTag'
|
||||
import { RerankerTag } from './RerankerTag'
|
||||
import { ToolsCallingTag } from './ToolsCallingTag'
|
||||
import { VisionTag } from './VisionTag'
|
||||
import { WebSearchTag } from './WebSearchTag'
|
||||
|
||||
export { EmbeddingTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag }
|
||||
export { EmbeddingTag, FreeTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag }
|
||||
@ -1,4 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
// import { loggerService } from '@logger'
|
||||
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
|
||||
import { useAppInit } from '@renderer/hooks/useAppInit'
|
||||
import { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
@ -26,7 +26,7 @@ type ElementItem = {
|
||||
element: React.FC | React.ReactNode
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('TopView')
|
||||
// const logger = loggerService.withContext('TopView')
|
||||
|
||||
const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
const [elements, setElements] = useState<ElementItem[]>([])
|
||||
@ -80,7 +80,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
logger.debug('keydown', e)
|
||||
// logger.debug('keydown', e)
|
||||
if (!enableQuitFullScreen) return
|
||||
|
||||
if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
|
||||
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'
|
||||
74
src/renderer/src/components/dnd/useDndReorder.ts
Normal file
74
src/renderer/src/components/dnd/useDndReorder.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Key, useCallback, useMemo } from 'react'
|
||||
|
||||
interface UseDndReorderParams<T> {
|
||||
/** 原始的、完整的数据列表 */
|
||||
originalList: T[]
|
||||
/** 当前在界面上渲染的、可能被过滤的列表 */
|
||||
filteredList: T[]
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强拖拽排序能力,处理“过滤后列表”与“原始列表”的索引映射问题。
|
||||
*
|
||||
* @template T 列表项的类型
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调
|
||||
*/
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
const map = new Map<Key, number>()
|
||||
originalList.forEach((item, index) => {
|
||||
map.set(getId(item), index)
|
||||
})
|
||||
return map
|
||||
}, [originalList, getId])
|
||||
|
||||
// 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引
|
||||
const getItemKey = useCallback(
|
||||
(index: number): Key => {
|
||||
const item = filteredList[index]
|
||||
// 如果找不到item,返回视图索引兜底
|
||||
if (!item) return index
|
||||
|
||||
const originalIndex = itemIndexMap.get(getId(item))
|
||||
return originalIndex ?? index
|
||||
},
|
||||
[filteredList, itemIndexMap, getId]
|
||||
)
|
||||
|
||||
// 创建 onSortEnd 回调,封装了所有重排逻辑
|
||||
const onSortEnd = useCallback(
|
||||
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
|
||||
// 使用 getItemKey 将视图索引转换为数据索引
|
||||
const sourceOriginalIndex = getItemKey(oldIndex) as number
|
||||
const destOriginalIndex = getItemKey(newIndex) as number
|
||||
|
||||
// 如果索引转换失败,不进行任何操作
|
||||
if (sourceOriginalIndex === undefined || destOriginalIndex === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceOriginalIndex === destOriginalIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// 操作原始列表的副本
|
||||
const newList = [...originalList]
|
||||
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
|
||||
newList.splice(destOriginalIndex, 0, movedItem)
|
||||
|
||||
// 调用外部更新函数
|
||||
onUpdate(newList)
|
||||
},
|
||||
[getItemKey, originalList, onUpdate]
|
||||
)
|
||||
|
||||
return { onSortEnd, itemKey: getItemKey }
|
||||
}
|
||||
28
src/renderer/src/components/dnd/useDndState.ts
Normal file
28
src/renderer/src/components/dnd/useDndState.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useDndContext } from '@dnd-kit/core'
|
||||
|
||||
interface DndState {
|
||||
/** 是否有元素正在拖拽 */
|
||||
isDragging: boolean
|
||||
/** 当前拖拽元素的ID */
|
||||
draggedId: string | number | null
|
||||
/** 当前悬停位置的ID */
|
||||
overId: string | number | null
|
||||
/** 是否正在悬停在某个可放置区域 */
|
||||
isOver: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供 dnd-kit 的全局拖拽状态管理
|
||||
*
|
||||
* @returns 当前拖拽状态信息
|
||||
*/
|
||||
export function useDndState(): DndState {
|
||||
const { active, over } = useDndContext()
|
||||
|
||||
return {
|
||||
isDragging: active !== null,
|
||||
draggedId: active?.id ?? null,
|
||||
overId: over?.id ?? null,
|
||||
isOver: over !== null
|
||||
}
|
||||
}
|
||||
45
src/renderer/src/components/dnd/utils.ts
Normal file
45
src/renderer/src/components/dnd/utils.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { defaultDropAnimationSideEffects, type DropAnimation, PointerSensor } from '@dnd-kit/core'
|
||||
|
||||
export const PORTAL_NO_DND_SELECTORS = [
|
||||
'.ant-dropdown',
|
||||
'.ant-select-dropdown',
|
||||
'.ant-popover',
|
||||
'.ant-tooltip',
|
||||
'.ant-modal'
|
||||
].join(',')
|
||||
|
||||
/**
|
||||
* Default drop animation config.
|
||||
* The opacity is set so to match the drag overlay case.
|
||||
*/
|
||||
export const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: '0.25'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent drag on elements with specific classes or data-no-dnd attribute
|
||||
*/
|
||||
export class PortalSafePointerSensor extends PointerSensor {
|
||||
static activators = [
|
||||
{
|
||||
eventName: 'onPointerDown',
|
||||
handler: ({ nativeEvent: event }) => {
|
||||
let target = event.target as HTMLElement
|
||||
|
||||
while (target) {
|
||||
if (target.closest(PORTAL_NO_DND_SELECTORS) || target.dataset?.noDnd) {
|
||||
return false
|
||||
}
|
||||
target = target.parentElement as HTMLElement
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
] as (typeof PointerSensor)['activators']
|
||||
}
|
||||
@ -150,6 +150,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
|
||||
import NomicLogo from '@renderer/assets/images/providers/nomic.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import {
|
||||
isSystemProviderId,
|
||||
Model,
|
||||
ReasoningEffortConfig,
|
||||
SystemProviderId,
|
||||
@ -290,6 +291,7 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||
)
|
||||
|
||||
// 模型类型到支持的reasoning_effort的映射表
|
||||
// TODO: refactor this. too many identical options
|
||||
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
default: ['low', 'medium', 'high'] as const,
|
||||
o: ['low', 'medium', 'high'] as const,
|
||||
@ -303,7 +305,8 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
doubao_no_auto: ['high'] as const,
|
||||
hunyuan: ['auto'] as const,
|
||||
zhipu: ['auto'] as const,
|
||||
perplexity: ['low', 'medium', 'high'] as const
|
||||
perplexity: ['low', 'medium', 'high'] as const,
|
||||
deepseek_hybrid: ['auto'] as const
|
||||
} as const
|
||||
|
||||
// 模型类型到支持选项的映射表
|
||||
@ -320,7 +323,8 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
|
||||
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
||||
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity
|
||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
|
||||
deepseek_hybrid: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
|
||||
} as const
|
||||
|
||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
@ -350,11 +354,12 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
} else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
|
||||
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
|
||||
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
|
||||
else if (isDeepSeekHybridInferenceModel(model)) thinkingModelType = 'deepseek_hybrid'
|
||||
return thinkingModelType
|
||||
}
|
||||
|
||||
export function isFunctionCallingModel(model?: Model): boolean {
|
||||
if (!model || isEmbeddingModel(model) || isRerankModel(model)) {
|
||||
if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -372,11 +377,21 @@ export function isFunctionCallingModel(model?: Model): boolean {
|
||||
return FUNCTION_CALLING_REGEX.test(modelId) || FUNCTION_CALLING_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
if (['deepseek', 'anthropic'].includes(model.provider)) {
|
||||
if (['deepseek', 'anthropic', 'kimi', 'moonshot'].includes(model.provider)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (['kimi', 'moonshot'].includes(model.provider)) {
|
||||
// 2025/08/26 百炼与火山引擎均不支持 v3.1 函数调用
|
||||
// 先默认支持
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
if (isSystemProviderId(model.provider)) {
|
||||
switch (model.provider) {
|
||||
case 'dashscope':
|
||||
case 'doubao':
|
||||
// case 'nvidia': // nvidia api 太烂了 测不了能不能用 先假设能用
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1401,7 +1416,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
dashscope: [
|
||||
{ id: 'qwen-vl-plus', name: 'qwen-vl-plus', provider: 'dashscope', group: 'qwen-vl', owned_by: 'system' },
|
||||
{ id: 'qwen-coder-plus', name: 'qwen-coder-plus', provider: 'dashscope', group: 'qwen-coder', owned_by: 'system' },
|
||||
{ id: 'qwen-turbo', name: 'qwen-turbo', provider: 'dashscope', group: 'qwen-turbo', owned_by: 'system' },
|
||||
{ id: 'qwen-flash', name: 'qwen-flash', provider: 'dashscope', group: 'qwen-flash', owned_by: 'system' },
|
||||
{ id: 'qwen-plus', name: 'qwen-plus', provider: 'dashscope', group: 'qwen-plus', owned_by: 'system' },
|
||||
{ id: 'qwen-max', name: 'qwen-max', provider: 'dashscope', group: 'qwen-max', owned_by: 'system' }
|
||||
],
|
||||
@ -2456,6 +2471,7 @@ export const SUPPORTED_DISABLE_GENERATION_MODELS = [
|
||||
export const GENERATE_IMAGE_MODELS = [
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-2.5-flash-image-preview',
|
||||
'grok-2-image-1212',
|
||||
'grok-2-image',
|
||||
'grok-2-image-latest',
|
||||
@ -2627,6 +2643,13 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Specifically for DeepSeek V3.1. White list for now
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
return (['openrouter', 'dashscope', 'doubao', 'silicon', 'nvidia'] satisfies SystemProviderId[]).some(
|
||||
(id) => id === model.provider
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
isSupportedThinkingTokenGeminiModel(model) ||
|
||||
isSupportedThinkingTokenQwenModel(model) ||
|
||||
@ -2701,7 +2724,14 @@ export function isGeminiReasoningModel(model?: Model): boolean {
|
||||
|
||||
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
return modelId.includes('gemini-2.5')
|
||||
if (modelId.includes('gemini-2.5')) {
|
||||
if (modelId.includes('image') || modelId.includes('tts')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为Qwen推理模型 */
|
||||
@ -2764,7 +2794,9 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
|
||||
'qwen-turbo-0428',
|
||||
'qwen-turbo-2025-04-28',
|
||||
'qwen-turbo-0715',
|
||||
'qwen-turbo-2025-07-15'
|
||||
'qwen-turbo-2025-07-15',
|
||||
'qwen-flash',
|
||||
'qwen-flash-2025-07-28'
|
||||
].includes(modelId)
|
||||
}
|
||||
|
||||
@ -2838,6 +2870,15 @@ export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
|
||||
return modelId.includes('glm-4.5')
|
||||
}
|
||||
|
||||
export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||
return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId === 'deepseek-chat-v3.1'
|
||||
}
|
||||
|
||||
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||
|
||||
export const isZhipuReasoningModel = (model?: Model): boolean => {
|
||||
if (!model) {
|
||||
return false
|
||||
@ -2870,6 +2911,8 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
REASONING_REGEX.test(modelId) ||
|
||||
REASONING_REGEX.test(model.name) ||
|
||||
isSupportedThinkingTokenDoubaoModel(model) ||
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
isDeepSeekHybridInferenceModel({ ...model, id: model.name }) ||
|
||||
false
|
||||
)
|
||||
}
|
||||
@ -2884,6 +2927,7 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
isPerplexityReasoningModel(model) ||
|
||||
isZhipuReasoningModel(model) ||
|
||||
isStepReasoningModel(model) ||
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
modelId.includes('magistral') ||
|
||||
modelId.includes('minimax-m1') ||
|
||||
modelId.includes('pangu-pro-moe')
|
||||
@ -2909,7 +2953,11 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isOpenAIReasoningModel(model) || isOpenAIChatCompletionOnlyModel(model) || isQwenMTModel(model)) {
|
||||
if (
|
||||
(isOpenAIReasoningModel(model) && !isOpenAIOpenWeightModel(model)) ||
|
||||
isOpenAIChatCompletionOnlyModel(model) ||
|
||||
isQwenMTModel(model)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -2917,7 +2965,7 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean {
|
||||
}
|
||||
|
||||
export function isWebSearchModel(model: Model): boolean {
|
||||
if (!model || isEmbeddingModel(model) || isRerankModel(model)) {
|
||||
if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -2988,7 +3036,7 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
if (provider.id === 'dashscope') {
|
||||
const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq']
|
||||
const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq', 'qwen-flash']
|
||||
// matches id like qwen-max-0919, qwen-max-latest
|
||||
return models.some((i) => modelId.startsWith(i))
|
||||
}
|
||||
@ -3004,6 +3052,26 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isMandatoryWebSearchModel(model: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
const provider = getProviderByModel(model)
|
||||
|
||||
if (!provider) {
|
||||
return false
|
||||
}
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
|
||||
if (provider.id === 'perplexity' || provider.id === 'openrouter') {
|
||||
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
@ -3172,6 +3240,7 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
'qwen3-0\\.6b$': { min: 0, max: 30_720 },
|
||||
'qwen-plus.*$': { min: 0, max: 38_912 },
|
||||
'qwen-turbo.*$': { min: 0, max: 38_912 },
|
||||
'qwen-flash.*$': { min: 0, max: 81_920 },
|
||||
'qwen3-.*$': { min: 1024, max: 38_912 },
|
||||
|
||||
// Claude models
|
||||
@ -3238,3 +3307,16 @@ export const isGPT5SeriesModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gpt-5')
|
||||
}
|
||||
|
||||
export const isGeminiModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gemini')
|
||||
}
|
||||
|
||||
export const isOpenAIOpenWeightModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gpt-oss')
|
||||
}
|
||||
|
||||
// zhipu 视觉推理模型用这组 special token 标记推理结果
|
||||
export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const
|
||||
|
||||
32
src/renderer/src/config/ocr.ts
Normal file
32
src/renderer/src/config/ocr.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
BuiltinOcrProvider,
|
||||
BuiltinOcrProviderId,
|
||||
ImageOcrProvider,
|
||||
OcrProviderCapability,
|
||||
OcrTesseractProvider
|
||||
} from '@renderer/types'
|
||||
|
||||
const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider = {
|
||||
id: 'tesseract',
|
||||
name: 'Tesseract',
|
||||
capabilities: {
|
||||
image: true
|
||||
},
|
||||
config: {
|
||||
langs: {
|
||||
chi_sim: true,
|
||||
chi_tra: true,
|
||||
eng: true
|
||||
}
|
||||
}
|
||||
} as const satisfies OcrTesseractProvider
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS_MAP = {
|
||||
tesseract
|
||||
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
|
||||
|
||||
export const DEFAULT_OCR_PROVIDER = {
|
||||
image: tesseract
|
||||
} as const satisfies Record<OcrProviderCapability, BuiltinOcrProvider>
|
||||
@ -1,12 +0,0 @@
|
||||
import MacOSLogo from '@renderer/assets/images/providers/macos.svg'
|
||||
|
||||
export function getOcrProviderLogo(providerId: string) {
|
||||
switch (providerId) {
|
||||
case 'system':
|
||||
return MacOSLogo
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const OCR_PROVIDER_CONFIG = {}
|
||||
@ -166,7 +166,7 @@ export const SEARCH_SUMMARY_PROMPT = `
|
||||
</knowledge>
|
||||
\`
|
||||
|
||||
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
Rephrased question: \`
|
||||
<websearch>
|
||||
<question>
|
||||
@ -279,7 +279,7 @@ export const SEARCH_SUMMARY_PROMPT_WEB_ONLY = `
|
||||
</websearch>
|
||||
\`
|
||||
|
||||
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
Rephrased question: \`
|
||||
<websearch>
|
||||
<question>
|
||||
@ -374,7 +374,7 @@ export const SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY = `
|
||||
</knowledge>
|
||||
\`
|
||||
|
||||
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
Rephrased question: \`
|
||||
<knowledge>
|
||||
<rewrite>
|
||||
|
||||
@ -38,7 +38,6 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
|
||||
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
|
||||
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
|
||||
import Ph8ProviderLogo from '@renderer/assets/images/providers/ph8.png'
|
||||
import PoeProviderLogo from '@renderer/assets/images/providers/poe.svg'
|
||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||
@ -57,6 +56,7 @@ import {
|
||||
isSystemProvider,
|
||||
OpenAIServiceTiers,
|
||||
Provider,
|
||||
ProviderType,
|
||||
SystemProvider,
|
||||
SystemProviderId
|
||||
} from '@renderer/types'
|
||||
@ -594,7 +594,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
|
||||
export const SYSTEM_PROVIDERS: SystemProvider[] = Object.values(SYSTEM_PROVIDERS_CONFIG)
|
||||
|
||||
const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
ph8: Ph8ProviderLogo,
|
||||
'302ai': Ai302ProviderLogo,
|
||||
openai: OpenAiProviderLogo,
|
||||
@ -649,7 +649,7 @@ const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
vertexai: VertexAIProviderLogo,
|
||||
'new-api': NewAPIProviderLogo,
|
||||
'aws-bedrock': AwsProviderLogo,
|
||||
poe: PoeProviderLogo
|
||||
poe: 'svg' // use svg icon component
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@ -1277,7 +1277,11 @@ export const isSupportStreamOptionsProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = ['ollama', 'lmstudio'] as const satisfies SystemProviderId[]
|
||||
const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = [
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
'nvidia'
|
||||
] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
* 判断提供商是否支持使用 enable_thinking 参数来控制 Qwen3 等模型的思考。 Only for OpenAI Chat Completions API.
|
||||
@ -1300,3 +1304,16 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
|
||||
(isSystemProvider(provider) && !NOT_SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id))
|
||||
)
|
||||
}
|
||||
|
||||
const SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES = ['gemini', 'vertexai'] as const satisfies ProviderType[]
|
||||
|
||||
export const isSupportUrlContextProvider = (provider: Provider) => {
|
||||
return SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
|
||||
}
|
||||
|
||||
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
|
||||
|
||||
/** 判断是否是使用 Gemini 原生搜索工具的 provider. 目前假设只有官方 API 使用原生工具 */
|
||||
export const isGeminiWebSearchProvider = (provider: Provider) => {
|
||||
return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id)
|
||||
}
|
||||
|
||||
23
src/renderer/src/config/sidebar.ts
Normal file
23
src/renderer/src/config/sidebar.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { SidebarIcon } from '@renderer/types'
|
||||
|
||||
/**
|
||||
* 默认显示的侧边栏图标
|
||||
* 这些图标会在侧边栏中默认显示
|
||||
*/
|
||||
export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
|
||||
'assistants',
|
||||
'agents',
|
||||
'paintings',
|
||||
'translate',
|
||||
'minapp',
|
||||
'knowledge',
|
||||
'files',
|
||||
'code_tools'
|
||||
]
|
||||
|
||||
/**
|
||||
* 必须显示的侧边栏图标(不能被隐藏)
|
||||
* 这些图标必须始终在侧边栏中可见
|
||||
* 抽取为参数方便未来扩展
|
||||
*/
|
||||
export const REQUIRED_SIDEBAR_ICONS: SidebarIcon[] = ['assistants']
|
||||
@ -20,7 +20,6 @@ interface CodeStyleContextType {
|
||||
activeShikiTheme: string
|
||||
isShikiThemeDark: boolean
|
||||
activeCmTheme: any
|
||||
languageMap: Record<string, string>
|
||||
}
|
||||
|
||||
const defaultCodeStyleContext: CodeStyleContextType = {
|
||||
@ -33,8 +32,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
|
||||
themeNames: ['auto'],
|
||||
activeShikiTheme: 'auto',
|
||||
isShikiThemeDark: false,
|
||||
activeCmTheme: null,
|
||||
languageMap: {}
|
||||
activeCmTheme: null
|
||||
}
|
||||
|
||||
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
|
||||
@ -93,8 +91,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
return cmThemes[themeName as keyof typeof cmThemes] || themeName
|
||||
}, [theme, codeEditor, themeNames])
|
||||
|
||||
// 一些语言的别名
|
||||
const languageMap = useMemo(() => {
|
||||
// 自定义 shiki 语言别名
|
||||
const languageAliases = useMemo(() => {
|
||||
return {
|
||||
bash: 'shell',
|
||||
'objective-c++': 'objective-cpp',
|
||||
@ -114,10 +112,10 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
// 流式代码高亮,返回已高亮的 token lines
|
||||
const highlightCodeChunk = useCallback(
|
||||
async (trunk: string, language: string, callerId: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
const normalizedLang = languageAliases[language as keyof typeof languageAliases] || language.toLowerCase()
|
||||
return shikiStreamService.highlightCodeChunk(trunk, normalizedLang, activeShikiTheme, callerId)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
[activeShikiTheme, languageAliases]
|
||||
)
|
||||
|
||||
// 清理代码高亮资源
|
||||
@ -128,19 +126,19 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
// 高亮流式输出的代码
|
||||
const highlightStreamingCode = useCallback(
|
||||
async (fullContent: string, language: string, callerId: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
const normalizedLang = languageAliases[language as keyof typeof languageAliases] || language.toLowerCase()
|
||||
return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
[activeShikiTheme, languageAliases]
|
||||
)
|
||||
|
||||
// 获取 Shiki pre 标签属性
|
||||
const getShikiPreProperties = useCallback(
|
||||
async (language: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
const normalizedLang = languageAliases[language as keyof typeof languageAliases] || language.toLowerCase()
|
||||
return shikiStreamService.getShikiPreProperties(normalizedLang, activeShikiTheme)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
[activeShikiTheme, languageAliases]
|
||||
)
|
||||
|
||||
const highlightCode = useCallback(
|
||||
@ -176,8 +174,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
themeNames,
|
||||
activeShikiTheme,
|
||||
isShikiThemeDark,
|
||||
activeCmTheme,
|
||||
languageMap
|
||||
activeCmTheme
|
||||
}),
|
||||
[
|
||||
highlightCodeChunk,
|
||||
@ -189,8 +186,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
themeNames,
|
||||
activeShikiTheme,
|
||||
isShikiThemeDark,
|
||||
activeCmTheme,
|
||||
languageMap
|
||||
activeCmTheme
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@ -66,7 +66,7 @@ db.version(6).stores({
|
||||
// --- NEW VERSION 7 ---
|
||||
db.version(7)
|
||||
.stores({
|
||||
// Re-declare all tables for the new version
|
||||
// Redeclare all tables for the new version
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id', // Correct index for topics
|
||||
settings: '&id, value',
|
||||
@ -79,7 +79,7 @@ db.version(7)
|
||||
|
||||
db.version(8)
|
||||
.stores({
|
||||
// Re-declare all tables for the new version
|
||||
// Redeclare all tables for the new version
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id', // Correct index for topics
|
||||
settings: '&id, value',
|
||||
@ -91,7 +91,7 @@ db.version(8)
|
||||
.upgrade((tx) => upgradeToV8(tx))
|
||||
|
||||
db.version(9).stores({
|
||||
// Re-declare all tables for the new version
|
||||
// Redeclare all tables for the new version
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id', // Correct index for topics
|
||||
settings: '&id, value',
|
||||
|
||||
@ -3,7 +3,8 @@ import {
|
||||
getThinkModelType,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
MODEL_SUPPORTED_OPTIONS
|
||||
MODEL_SUPPORTED_OPTIONS,
|
||||
MODEL_SUPPORTED_REASONING_EFFORT
|
||||
} from '@renderer/config/models'
|
||||
import { db } from '@renderer/databases'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
@ -24,9 +25,9 @@ import {
|
||||
updateTopics
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopicManager } from './useTopic'
|
||||
@ -84,6 +85,12 @@ export function useAssistant(id: string) {
|
||||
|
||||
const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model])
|
||||
|
||||
const settingsRef = useRef(assistant?.settings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = assistant.settings
|
||||
}, [assistant?.settings])
|
||||
|
||||
const updateAssistantSettings = useCallback(
|
||||
(settings: Partial<AssistantSettings>) => {
|
||||
assistant?.id && dispatch(_updateAssistantSettings({ assistantId: assistant.id, settings }))
|
||||
@ -93,28 +100,46 @@ export function useAssistant(id: string) {
|
||||
|
||||
// 当model变化时,同步reasoning effort为模型支持的合法值
|
||||
useEffect(() => {
|
||||
if (assistant?.settings) {
|
||||
const settings = settingsRef.current
|
||||
if (settings) {
|
||||
const currentReasoningEffort = settings.reasoning_effort
|
||||
if (isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) {
|
||||
const currentReasoningEffort = assistant?.settings?.reasoning_effort
|
||||
const supportedOptions = MODEL_SUPPORTED_OPTIONS[getThinkModelType(model)]
|
||||
if (currentReasoningEffort && !supportedOptions.includes(currentReasoningEffort)) {
|
||||
// 选项不支持时,回退到第一个支持的值
|
||||
// 注意:这里假设可用的options不会为空
|
||||
const fallbackOption = supportedOptions[0]
|
||||
const modelType = getThinkModelType(model)
|
||||
const supportedOptions = MODEL_SUPPORTED_OPTIONS[modelType]
|
||||
if (supportedOptions.every((option) => option !== currentReasoningEffort)) {
|
||||
const cache = settings.reasoning_effort_cache
|
||||
let fallbackOption: ThinkingOption
|
||||
|
||||
// 选项不支持时,首先尝试恢复到上次使用的值
|
||||
if (cache && supportedOptions.includes(cache)) {
|
||||
fallbackOption = cache
|
||||
} else {
|
||||
// 灵活回退到支持的值
|
||||
// 注意:这里假设可用的options不会为空
|
||||
const enableThinking = currentReasoningEffort !== undefined
|
||||
fallbackOption = enableThinking
|
||||
? MODEL_SUPPORTED_REASONING_EFFORT[modelType][0]
|
||||
: MODEL_SUPPORTED_OPTIONS[modelType][0]
|
||||
}
|
||||
|
||||
updateAssistantSettings({
|
||||
reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption,
|
||||
qwenThinkMode: fallbackOption === 'off'
|
||||
reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption,
|
||||
qwenThinkMode: fallbackOption === 'off' ? undefined : true
|
||||
})
|
||||
} else {
|
||||
// 对于支持的选项, 不再更新 cache.
|
||||
}
|
||||
} else {
|
||||
// 切换到非思考模型时保留cache
|
||||
updateAssistantSettings({
|
||||
reasoning_effort: undefined,
|
||||
reasoning_effort_cache: currentReasoningEffort,
|
||||
qwenThinkMode: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [assistant?.settings, model, updateAssistantSettings])
|
||||
}, [model, updateAssistantSettings])
|
||||
|
||||
return {
|
||||
assistant: assistantWithModel,
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
setSelectedModel
|
||||
} from '@renderer/store/codeTools'
|
||||
import { Model } from '@renderer/types'
|
||||
import { codeTools } from '@shared/config/constant'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export const useCodeTools = () => {
|
||||
@ -20,7 +21,7 @@ export const useCodeTools = () => {
|
||||
|
||||
// 设置选择的 CLI 工具
|
||||
const setCliTool = useCallback(
|
||||
(tool: string) => {
|
||||
(tool: codeTools) => {
|
||||
dispatch(setSelectedCliTool(tool))
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
43
src/renderer/src/hooks/useDrag.ts
Normal file
43
src/renderer/src/hooks/useDrag.ts
Normal file
@ -0,0 +1,43 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
// const logger = loggerService.withContext('useDrag')
|
||||
|
||||
export const useDrag = <T extends HTMLElement>(onDrop?: (e: React.DragEvent<T>) => Promise<void> | void) => {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 确保是离开当前元素,而不是进入子元素
|
||||
// logger.debug('drag leave', { currentTarget: e.currentTarget, relatedTarget: e.relatedTarget })
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
await onDrop?.(e)
|
||||
},
|
||||
[onDrop]
|
||||
)
|
||||
|
||||
return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop }
|
||||
}
|
||||
97
src/renderer/src/hooks/useFiles.ts
Normal file
97
src/renderer/src/hooks/useFiles.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { filterSupportedFiles } from '@renderer/utils'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
/** 支持选择的扩展名 */
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
export const useFiles = (props?: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [files, setFiles] = useState<FileMetadata[]>([])
|
||||
const [selecting, setSelecting] = useState<boolean>(false)
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (props?.extensions) {
|
||||
return props.extensions
|
||||
} else {
|
||||
return ['*']
|
||||
}
|
||||
}, [props?.extensions])
|
||||
|
||||
/**
|
||||
* 选择文件的回调函数
|
||||
* @param multipleSelections - 是否允许多选文件,默认为 true
|
||||
* @returns 返回选中的文件元数据数组
|
||||
* @description
|
||||
* 1. 打开系统文件选择对话框
|
||||
* 2. 根据扩展名过滤文件
|
||||
* 3. 更新内部文件状态
|
||||
* 4. 当选择了不支持的文件类型时,会显示提示信息
|
||||
*/
|
||||
const onSelectFile = useCallback(
|
||||
async ({ multipleSelections = true }: { multipleSelections?: boolean }): Promise<FileMetadata[]> => {
|
||||
if (selecting) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selectProps: Electron.OpenDialogOptions['properties'] = multipleSelections
|
||||
? ['openFile', 'multiSelections']
|
||||
: ['openFile']
|
||||
|
||||
// when the number of extensions is greater than 20, use *.* to avoid selecting window lag
|
||||
const useAllFiles = extensions.length > 20
|
||||
|
||||
setSelecting(true)
|
||||
const _files = await window.api.file.select({
|
||||
properties: selectProps,
|
||||
filters: [
|
||||
{
|
||||
name: 'Files',
|
||||
extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', ''))
|
||||
}
|
||||
]
|
||||
})
|
||||
setSelecting(false)
|
||||
|
||||
if (_files) {
|
||||
if (!useAllFiles) {
|
||||
setFiles([...files, ..._files])
|
||||
return _files
|
||||
}
|
||||
const supportedFiles = await filterSupportedFiles(_files, extensions)
|
||||
if (supportedFiles.length > 0) {
|
||||
setFiles([...files, ...supportedFiles])
|
||||
}
|
||||
|
||||
if (supportedFiles.length !== _files.length) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported_count', {
|
||||
count: _files.length - supportedFiles.length
|
||||
})
|
||||
})
|
||||
}
|
||||
return supportedFiles
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
[extensions, files, selecting, t]
|
||||
)
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
files,
|
||||
selecting,
|
||||
setFiles,
|
||||
onSelectFile,
|
||||
clearFiles
|
||||
}
|
||||
}
|
||||
@ -26,13 +26,22 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
|
||||
const [originalValue, setOriginalValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const editTimerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(editTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startEdit = useCallback(
|
||||
(initialValue: string) => {
|
||||
setIsEditing(true)
|
||||
setEditValue(initialValue)
|
||||
setOriginalValue(initialValue)
|
||||
|
||||
setTimeout(() => {
|
||||
clearTimeout(editTimerRef.current)
|
||||
editTimerRef.current = setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
if (autoSelectOnStart) {
|
||||
inputRef.current?.select()
|
||||
|
||||
@ -20,7 +20,7 @@ import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@r
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { useAgents } from './useAgents'
|
||||
@ -29,6 +29,7 @@ import { useAssistants } from './useAssistant'
|
||||
export const useKnowledge = (baseId: string) => {
|
||||
const dispatch = useAppDispatch()
|
||||
const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId))
|
||||
const checkTimerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
// 重命名知识库
|
||||
const renameKnowledgeBase = (name: string) => {
|
||||
@ -40,34 +41,46 @@ export const useKnowledge = (baseId: string) => {
|
||||
dispatch(updateBase(base))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(checkTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 检查知识库
|
||||
const checkAllBases = () => {
|
||||
clearTimeout(checkTimerRef.current)
|
||||
checkTimerRef.current = setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
}
|
||||
|
||||
// 批量添加文件
|
||||
const addFiles = (files: FileMetadata[]) => {
|
||||
dispatch(addFilesThunk(baseId, files))
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
// 添加笔记
|
||||
const addNote = async (content: string) => {
|
||||
await dispatch(addNoteThunk(baseId, content))
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
// 添加URL
|
||||
const addUrl = (url: string) => {
|
||||
dispatch(addItemThunk(baseId, 'url', url))
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
// 添加 Sitemap
|
||||
const addSitemap = (url: string) => {
|
||||
dispatch(addItemThunk(baseId, 'sitemap', url))
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
// Add directory support
|
||||
const addDirectory = (path: string) => {
|
||||
dispatch(addItemThunk(baseId, 'directory', path))
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
checkAllBases()
|
||||
}
|
||||
// 更新笔记内容
|
||||
const updateNoteContent = async (noteId: string, content: string) => {
|
||||
@ -133,7 +146,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
uniqueId: undefined,
|
||||
updated_at: Date.now()
|
||||
})
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
checkAllBases()
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,7 +242,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
throw new Error(`Failed to migrate files ${files}: ${error}`)
|
||||
}
|
||||
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
const fileItems = base?.items.filter((item) => item.type === 'file') || []
|
||||
|
||||
54
src/renderer/src/hooks/useOcr.ts
Normal file
54
src/renderer/src/hooks/useOcr.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { loggerService } from '@logger'
|
||||
import * as OcrService from '@renderer/services/ocr/OcrService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { ImageFileMetadata, isImageFile, SupportedOcrFile } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('useOcr')
|
||||
|
||||
export const useOcr = () => {
|
||||
const { t } = useTranslation()
|
||||
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
|
||||
|
||||
/**
|
||||
* 对图片文件进行OCR识别
|
||||
* @param image 图片文件元数据
|
||||
* @returns OCR识别结果的Promise
|
||||
* @throws OCR失败时抛出错误
|
||||
*/
|
||||
const ocrImage = async (image: ImageFileMetadata) => {
|
||||
return OcrService.ocr(image, imageProvider)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对支持的文件进行OCR识别.
|
||||
* @param file 支持OCR的文件
|
||||
* @returns OCR识别结果的Promise
|
||||
* @throws 当文件类型不支持或OCR失败时抛出错误
|
||||
*/
|
||||
const ocr = async (file: SupportedOcrFile) => {
|
||||
const key = uuid()
|
||||
window.message.loading({ content: t('ocr.processing'), key, duration: 0 })
|
||||
// await to keep show loading message
|
||||
try {
|
||||
if (isImageFile(file)) {
|
||||
return await ocrImage(file)
|
||||
} else {
|
||||
// @ts-expect-error all types should be covered
|
||||
throw new Error(t('ocr.file.not_supported', { type: file.type }))
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to ocr.', e as Error)
|
||||
window.message.error(t('ocr.error.unknown') + ': ' + formatErrorMessage(e))
|
||||
throw e
|
||||
} finally {
|
||||
window.message.destroy(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ocr
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user