Merge branch 'main' of https://github.com/CherryHQ/cherry-studio into wip/data-refactor

This commit is contained in:
fullex 2025-08-28 10:52:05 +08:00
commit 83fea49ed2
244 changed files with 8203 additions and 2188 deletions

View File

@ -45,8 +45,14 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: yarn install run: yarn install
- name: Build Check
run: yarn build:check
- name: Lint Check - name: Lint Check
run: yarn test:lint 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
View File

@ -1,39 +1,40 @@
{ {
"version": "0.2.0", "compounds": [
{
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"name": "Debug All",
"presentation": {
"order": 1
}
}
],
"configurations": [ "configurations": [
{ {
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}", "cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--inspect", "--sourcemap"],
"env": { "env": {
"REMOTE_DEBUGGING_PORT": "9222" "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", "name": "Debug Renderer Process",
"port": 9222, "port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 3000000,
"presentation": { "presentation": {
"hidden": true "hidden": true
} },
"request": "attach",
"timeout": 3000000,
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer"
} }
], ],
"compounds": [ "version": "0.2.0"
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
} }

View 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 {

View File

@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.5.7-rc.1", "version": "1.5.7-rc.2",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@ -79,7 +79,9 @@
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0", "officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2", "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" "turndown": "7.2.0"
}, },
"devDependencies": { "devDependencies": {
@ -104,7 +106,10 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^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-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
@ -146,7 +151,7 @@
"@types/lodash": "^4.17.5", "@types/lodash": "^4.17.5",
"@types/markdown-it": "^14", "@types/markdown-it": "^14",
"@types/md5": "^2.3.5", "@types/md5": "^2.3.5",
"@types/node": "^18.19.9", "@types/node": "^22.17.1",
"@types/pako": "^1.0.2", "@types/pako": "^1.0.2",
"@types/react": "^19.0.12", "@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@ -154,9 +159,9 @@
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@types/word-extractor": "^1", "@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.23.14", "@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.23.14", "@uiw/codemirror-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.23.14", "@uiw/react-codemirror": "^4.25.1",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/browser": "^3.2.4", "@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
@ -183,7 +188,7 @@
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.2", "drizzle-orm": "^0.44.2",
"electron": "37.2.3", "electron": "37.3.1",
"electron-builder": "26.0.15", "electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
@ -207,6 +212,7 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"isbinaryfile": "5.0.4",
"jaison": "^2.0.2", "jaison": "^2.0.2",
"jest-styled-components": "^7.2.0", "jest-styled-components": "^7.2.0",
"linguist-languages": "^8.0.0", "linguist-languages": "^8.0.0",
@ -230,6 +236,7 @@
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2", "react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0", "react-infinite-scroll-component": "^6.1.0",
@ -277,21 +284,25 @@
"zod": "^3.25.74" "zod": "^3.25.74"
}, },
"resolutions": { "resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "@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.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", "@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.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", "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", "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", "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.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", "packageManager": "yarn@4.9.1",
"lint-staged": { "lint-staged": {

View File

@ -156,7 +156,9 @@ export enum IpcChannel {
File_Base64File = 'file:base64File', File_Base64File = 'file:base64File',
File_GetPdfInfo = 'file:getPdfInfo', File_GetPdfInfo = 'file:getPdfInfo',
Fs_Read = 'fs:read', Fs_Read = 'fs:read',
Fs_ReadText = 'fs:readText',
File_OpenWithRelativePath = 'file:openWithRelativePath', File_OpenWithRelativePath = 'file:openWithRelativePath',
File_IsTextFile = 'file:isTextFile',
// file service // file service
FileService_Upload = 'file-service:upload', FileService_Upload = 'file-service:upload',
@ -306,5 +308,8 @@ export enum IpcChannel {
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage', TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
// CodeTools // CodeTools
CodeTools_Run = 'code-tools:run' CodeTools_Run = 'code-tools:run',
// OCR
OCR_ocr = 'ocr:ocr'
} }

View File

@ -211,3 +211,10 @@ export const MIN_WINDOW_WIDTH = 1080
export const SECOND_MIN_WINDOW_WIDTH = 520 export const SECOND_MIN_WINDOW_WIDTH = 520
export const MIN_WINDOW_HEIGHT = 600 export const MIN_WINDOW_HEIGHT = 600
export const defaultByPassRules = 'localhost,127.0.0.1,::1' export const defaultByPassRules = 'localhost,127.0.0.1,::1'
export enum codeTools {
qwenCode = 'qwen-code',
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex'
}

View File

@ -1,7 +1,7 @@
import { isDev, isWin } from '@main/constant'
import { app } from 'electron' import { app } from 'electron'
import { getDataPath } from './utils' import { getDataPath } from './utils'
const isDev = process.env.NODE_ENV === 'development'
if (isDev) { if (isDev) {
app.setPath('userData', app.getPath('userData') + 'Dev') app.setPath('userData', app.getPath('userData') + 'Dev')
@ -11,7 +11,7 @@ export const DATA_PATH = getDataPath()
export const titleBarOverlayDark = { export const titleBarOverlayDark = {
height: 42, height: 42,
color: 'rgba(255,255,255,0)', color: isWin ? 'rgba(0,0,0,0.02)' : 'rgba(255,255,255,0)',
symbolColor: '#fff' symbolColor: '#fff'
} }

View File

@ -31,6 +31,7 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic
import NotificationService from './services/NotificationService' import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService' import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService' import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import { proxyManager } from './services/ProxyManager' import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService' import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager' import { FileServiceManager } from './services/remotefile/FileServiceManager'
@ -72,7 +73,7 @@ const dxtService = new DxtService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater() const appUpdater = new AppUpdater()
const notificationService = new NotificationService(mainWindow) const notificationService = new NotificationService()
// Initialize Python service with main window // Initialize Python service with main window
pythonService.setMainWindow(mainWindow) 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_Copy, fileManager.copyFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
// file service // file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => { ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@ -469,6 +471,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// fs // fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService)) ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService))
ipcMain.handle(IpcChannel.Fs_ReadText, FileService.readTextFileWithAutoEncoding.bind(FileService))
// export // export
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService)) ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
@ -710,6 +713,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// CodeTools // CodeTools
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
// OCR
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
// Preference handlers // Preference handlers
PreferenceService.registerIpcHandler() PreferenceService.registerIpcHandler()
} }

View File

@ -91,7 +91,7 @@ export default abstract class BasePreprocessProvider {
} }
public async readPdf(buffer: Buffer) { public async readPdf(buffer: Buffer) {
const pdfDoc = await PDFDocument.load(buffer) const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true })
return { return {
numPages: pdfDoc.getPageCount() numPages: pdfDoc.getPageCount()
} }

View File

@ -201,20 +201,14 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
*/ */
private async putFile(filePath: string, url: string): Promise<void> { private async putFile(filePath: string, url: string): Promise<void> {
try { try {
// 获取文件大小用于设置 Content-Length
const stats = await fs.promises.stat(filePath)
const fileSize = stats.size
// 创建可读流 // 创建可读流
const fileStream = fs.createReadStream(filePath) const fileStream = fs.createReadStream(filePath)
const response = await net.fetch(url, { const response = await net.fetch(url, {
method: 'PUT', method: 'PUT',
body: fileStream as any, // TypeScript 类型转换net.fetch 支持 ReadableStream body: fileStream as any, // TypeScript 类型转换net.fetch 支持 ReadableStream
headers: { duplex: 'half'
'Content-Length': fileSize.toString() } as any) // TypeScript 类型转换net.fetch 需要 duplex 选项
}
})
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`) throw new Error(`HTTP ${response.status}: ${response.statusText}`)

View File

@ -1,5 +1,6 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search' import BraveSearchServer from './brave-search'
import DifyKnowledgeServer from './dify-knowledge' import DifyKnowledgeServer from './dify-knowledge'
@ -11,30 +12,34 @@ import ThinkingServer from './sequentialthinking'
const logger = loggerService.withContext('MCPFactory') 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)}`) logger.debug(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
switch (name) { switch (name) {
case '@cherry/memory': { case BuiltinMCPServerNames.memory: {
const envPath = envs.MEMORY_FILE_PATH const envPath = envs.MEMORY_FILE_PATH
return new MemoryServer(envPath).server return new MemoryServer(envPath).server
} }
case '@cherry/sequentialthinking': { case BuiltinMCPServerNames.sequentialThinking: {
return new ThinkingServer().server return new ThinkingServer().server
} }
case '@cherry/brave-search': { case BuiltinMCPServerNames.braveSearch: {
return new BraveSearchServer(envs.BRAVE_API_KEY).server return new BraveSearchServer(envs.BRAVE_API_KEY).server
} }
case '@cherry/fetch': { case BuiltinMCPServerNames.fetch: {
return new FetchServer().server return new FetchServer().server
} }
case '@cherry/filesystem': { case BuiltinMCPServerNames.filesystem: {
return new FileSystemServer(args).server return new FileSystemServer(args).server
} }
case '@cherry/dify-knowledge': { case BuiltinMCPServerNames.difyKnowledge: {
const difyKey = envs.DIFY_KEY const difyKey = envs.DIFY_KEY
return new DifyKnowledgeServer(difyKey, args).server return new DifyKnowledgeServer(difyKey, args).server
} }
case '@cherry/python': { case BuiltinMCPServerNames.python: {
return new PythonServer().server return new PythonServer().server
} }
default: default:

View File

@ -7,6 +7,7 @@ import { isWin } from '@main/constant'
import { removeEnvProxy } from '@main/utils' import { removeEnvProxy } from '@main/utils'
import { isUserInChina } from '@main/utils/ipService' import { isUserInChina } from '@main/utils/ipService'
import { getBinaryName } from '@main/utils/process' import { getBinaryName } from '@main/utils/process'
import { codeTools } from '@shared/config/constant'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
@ -41,23 +42,33 @@ class CodeToolsService {
} }
public async getPackageName(cliTool: string) { public async getPackageName(cliTool: string) {
if (cliTool === 'claude-code') { switch (cliTool) {
return '@anthropic-ai/claude-code' 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) { public async getCliExecutableName(cliTool: string) {
if (cliTool === 'claude-code') { switch (cliTool) {
return 'claude' 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> { private async isPackageInstalled(cliTool: string): Promise<boolean> {
@ -192,7 +203,7 @@ class CodeToolsService {
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export 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}`) logger.info(`Executing update command: ${updateCommand}`)
await execAsync(updateCommand, { timeout: 60000 }) await execAsync(updateCommand, { timeout: 60000 })
@ -296,7 +307,7 @@ class CodeToolsService {
} }
// Build command to execute // Build command to execute
let baseCommand = isWin ? `${executablePath}` : `${bunPath} ${executablePath}` let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
const bunInstallPath = path.join(os.homedir(), '.cherrystudio') const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) { if (isInstalled) {
@ -326,8 +337,9 @@ class CodeToolsService {
terminalArgs = [ terminalArgs = [
'-e', '-e',
`tell application "Terminal" `tell application "Terminal"
set newTab to do script "cd '${directory.replace(/'/g, "\\'")}' && clear"
activate activate
do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}" do script "${command.replace(/"/g, '\\"')}" in newTab
end tell` end tell`
] ]
break break

View File

@ -1,9 +1,10 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { net } from 'electron' import { app, net, safeStorage } from 'electron'
import { app, safeStorage } from 'electron' import fs from 'fs'
import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getConfigDir } from '../utils/file'
const logger = loggerService.withContext('CopilotService') const logger = loggerService.withContext('CopilotService')
// 配置常量,集中管理 // 配置常量,集中管理
@ -28,7 +29,8 @@ const CONFIG = {
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code', GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token', GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token',
COPILOT_TOKEN: 'https://api.github.com/copilot_internal/v2/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> private headers: Record<string, string>
constructor() { constructor() {
this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token') this.tokenFilePath = this.getTokenFilePath()
this.headers = { ...CONFIG.DEFAULT_HEADERS } 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-Site': 'none',
'Sec-Fetch-Mode': 'no-cors', 'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Dest': 'empty', 'Sec-Fetch-Dest': 'empty',
accept: 'application/json',
authorization: `token ${token}` authorization: `token ${token}`
} }
}) })
@ -204,7 +219,13 @@ class CopilotService {
public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => { public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => {
try { try {
const encryptedToken = safeStorage.encryptString(token) 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) { } catch (error) {
logger.error('Failed to save token:', error as Error) logger.error('Failed to save token:', error as Error)
throw new CopilotServiceError('无法保存访问令牌', error) throw new CopilotServiceError('无法保存访问令牌', error)
@ -221,7 +242,7 @@ class CopilotService {
try { try {
this.updateHeaders(headers) 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 access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, { const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
@ -249,8 +270,8 @@ class CopilotService {
public logout = async (): Promise<void> => { public logout = async (): Promise<void> => {
try { try {
try { try {
await fs.access(this.tokenFilePath) await fs.promises.access(this.tokenFilePath)
await fs.unlink(this.tokenFilePath) await fs.promises.unlink(this.tokenFilePath)
logger.debug('Successfully logged out from Copilot') logger.debug('Successfully logged out from Copilot')
} catch (error) { } catch (error) {
// 文件不存在不是错误,只是记录一下 // 文件不存在不是错误,只是记录一下

View File

@ -1,7 +1,8 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file' 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 { FileMetadata } from '@types'
import chardet from 'chardet'
import * as crypto from 'crypto' import * as crypto from 'crypto'
import { import {
dialog, dialog,
@ -15,6 +16,7 @@ import {
import * as fs from 'fs' import * as fs from 'fs'
import { writeFileSync } from 'fs' import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import { isBinaryFile } from 'isbinaryfile'
import officeParser from 'officeparser' import officeParser from 'officeparser'
import * as path from 'path' import * as path from 'path'
import { PDFDocument } from 'pdf-lib' import { PDFDocument } from 'pdf-lib'
@ -630,6 +632,34 @@ class FileStorage {
public getFilePathById(file: FileMetadata): string { public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext) 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() export const fileStorage = new FileStorage()

View File

@ -1,3 +1,4 @@
import { readTextFileWithAutoEncoding } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core' import { TraceMethod } from '@mcp-trace/trace-core'
import fs from 'fs/promises' import fs from 'fs/promises'
@ -8,4 +9,15 @@ export default class FileService {
if (encoding) return fs.readFile(path, { encoding }) if (encoding) return fs.readFile(path, { encoding })
return fs.readFile(path) 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)
}
} }

View File

@ -21,14 +21,22 @@ import {
CancelledNotificationSchema, CancelledNotificationSchema,
type GetPromptResult, type GetPromptResult,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
ProgressNotificationSchema,
PromptListChangedNotificationSchema, PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema, ResourceListChangedNotificationSchema,
ResourceUpdatedNotificationSchema, ResourceUpdatedNotificationSchema,
ToolListChangedNotificationSchema ToolListChangedNotificationSchema
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
import { nanoid } from '@reduxjs/toolkit' 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 { app, net } from 'electron'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { memoize } from 'lodash' import { memoize } from 'lodash'
@ -163,7 +171,7 @@ class McpService {
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
> => { > => {
// Create appropriate transport based on configuration // 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}`) logger.debug(`Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables // start the in-memory server with the given name and environment variables
@ -432,15 +440,6 @@ class McpService {
this.clearResourceCaches(serverKey) 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 // Set up cancelled notification handler
client.setNotificationHandler(CancelledNotificationSchema, async (notification) => { client.setNotificationHandler(CancelledNotificationSchema, async (notification) => {
logger.debug(`Operation cancelled for server: ${server.name}`, notification.params) 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, { const result = await client.callTool({ name, arguments: args }, undefined, {
onprogress: (process) => { onprogress: (process) => {
logger.debug(`Progress: ${process.progress / (process.total || 1)}`) 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, timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute,
// 需要服务端支持: https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#timeouts // 需要服务端支持: https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#timeouts

View File

@ -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 { Notification } from 'src/renderer/src/types/notification'
import { windowService } from './WindowService'
class NotificationService { class NotificationService {
private window: BrowserWindow
constructor(window: BrowserWindow) {
// Initialize the service
this.window = window
}
public async sendNotification(notification: Notification) { public async sendNotification(notification: Notification) {
// 使用 Electron Notification API // 使用 Electron Notification API
const electronNotification = new ElectronNotification({ const electronNotification = new ElectronNotification({
@ -17,8 +12,8 @@ class NotificationService {
}) })
electronNotification.on('click', () => { electronNotification.on('click', () => {
this.window.show() windowService.getMainWindow()?.show()
this.window.webContents.send('notification-click', notification) windowService.getMainWindow()?.webContents.send('notification-click', notification)
}) })
electronNotification.show() electronNotification.show()

View File

@ -11,14 +11,42 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher
const logger = loggerService.withContext('ProxyManager') const logger = loggerService.withContext('ProxyManager')
let byPassRules: string[] = [] let byPassRules: string[] = []
const isByPass = (hostname: string) => { const isByPass = (url: string) => {
if (byPassRules.length === 0) { if (byPassRules.length === 0) {
return false 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 { class SelectiveDispatcher extends Dispatcher {
private proxyDispatcher: Dispatcher private proxyDispatcher: Dispatcher
private directDispatcher: Dispatcher private directDispatcher: Dispatcher
@ -31,9 +59,7 @@ class SelectiveDispatcher extends Dispatcher {
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) { dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
if (opts.origin) { if (opts.origin) {
const url = new URL(opts.origin) if (isByPass(opts.origin.toString())) {
// 检查是否为 localhost 或本地地址
if (isByPass(url.hostname)) {
return this.directDispatcher.dispatch(opts, handler) return this.directDispatcher.dispatch(opts, handler)
} }
} }
@ -93,15 +119,20 @@ export class ProxyManager {
// Set new interval // Set new interval
this.systemProxyInterval = setInterval(async () => { this.systemProxyInterval = setInterval(async () => {
const currentProxy = await getSystemProxy() 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 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({ await this.configureProxy({
mode: 'system', mode: 'system',
proxyRules: currentProxy?.proxyUrl.toLowerCase(), proxyRules: currentProxy?.proxyUrl.toLowerCase(),
proxyBypassRules: undefined proxyBypassRules: currentProxy?.noProxy.join(',')
}) })
}, 1000 * 60) }, 1000 * 60)
} }
@ -151,6 +182,7 @@ export class ProxyManager {
delete process.env.grpc_proxy delete process.env.grpc_proxy
delete process.env.http_proxy delete process.env.http_proxy
delete process.env.https_proxy delete process.env.https_proxy
delete process.env.no_proxy
delete process.env.SOCKS_PROXY delete process.env.SOCKS_PROXY
delete process.env.ALL_PROXY delete process.env.ALL_PROXY
@ -162,6 +194,7 @@ export class ProxyManager {
process.env.HTTPS_PROXY = url process.env.HTTPS_PROXY = url
process.env.http_proxy = url process.env.http_proxy = url
process.env.https_proxy = url process.env.https_proxy = url
process.env.no_proxy = byPassRules.join(',')
if (url.startsWith('socks')) { if (url.startsWith('socks')) {
process.env.SOCKS_PROXY = url process.env.SOCKS_PROXY = url
@ -229,8 +262,7 @@ export class ProxyManager {
// filter localhost // filter localhost
if (url) { if (url) {
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname if (isByPass(url.toString())) {
if (isByPass(hostname)) {
return originalMethod(url, options, callback) return originalMethod(url, options, callback)
} }
} }

View File

@ -1,6 +1,9 @@
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { loggerService } from '@logger'
import { BrowserWindow } from 'electron' import { BrowserWindow } from 'electron'
const logger = loggerService.withContext('SearchService')
export class SearchService { export class SearchService {
private static instance: SearchService | null = null private static instance: SearchService | null = null
private searchWindows: Record<string, BrowserWindow> = {} private searchWindows: Record<string, BrowserWindow> = {}
@ -55,6 +58,7 @@ export class SearchService {
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> { public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
let window = this.searchWindows[uid] let window = this.searchWindows[uid]
logger.debug(`Searching with URL: ${url}`)
if (window) { if (window) {
await window.loadURL(url) await window.loadURL(url)
} else { } else {

View File

@ -204,7 +204,7 @@ export function registerShortcuts(window: BrowserWindow) {
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut) selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
break break
//the following ZOOMs will register shortcuts seperately, so will return //the following ZOOMs will register shortcuts separately, so will return
case 'zoom_in': case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => handler(window)) globalShortcut.register('CommandOrControl+=', () => handler(window))
globalShortcut.register('CommandOrControl+numadd', () => handler(window)) globalShortcut.register('CommandOrControl+numadd', () => handler(window))

View File

@ -555,9 +555,9 @@ export class WindowService {
// [Windows] hacky fix // [Windows] hacky fix
// the window is minimized only when in Windows platform // 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()) { 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) this.miniWindow?.setOpacity(0)
// DO NOT use `restore()` here, Electron has the bug with screens of different scale factor // 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 // We have to use `show()` here, then set the position and bounds

View 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))

View 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()

View File

@ -168,6 +168,7 @@ export function getMcpDir() {
* *
* @param filePath - * @param filePath -
* @returns * @returns
* @throws
*/ */
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> { export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8' const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8'

27
src/main/utils/ocr.ts Normal file
View 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)
}

View File

@ -18,9 +18,12 @@ import {
MemoryConfig, MemoryConfig,
MemoryListOptions, MemoryListOptions,
MemorySearchOptions, MemorySearchOptions,
OcrProvider,
OcrResult,
Provider, Provider,
S3Config, S3Config,
Shortcut, Shortcut,
SupportedOcrFile,
ThemeMode, ThemeMode,
WebDavConfig WebDavConfig
} from '@types' } from '@types'
@ -134,14 +137,15 @@ const api = {
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
}, },
file: { 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), upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
read: (fileId: string, detectEncoding?: boolean) => read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding), ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext), 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 * @param fileName
@ -171,10 +175,12 @@ const api = {
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId), pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file), getPathForFile: (file: File) => webUtils.getPathForFile(file),
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: { 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: { export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName) toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
@ -407,6 +413,10 @@ const api = {
options?: { autoUpdateToLatest?: boolean } options?: { autoUpdateToLatest?: boolean }
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options) ) => 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: { preference: {
get: <K extends PreferenceKeyType>(key: K): Promise<PreferenceDefaultScopeType[K]> => get: <K extends PreferenceKeyType>(key: K): Promise<PreferenceDefaultScopeType[K]> =>
ipcRenderer.invoke(IpcChannel.Preference_Get, key), ipcRenderer.invoke(IpcChannel.Preference_Get, key),

View File

@ -4,6 +4,7 @@ import { FC, useMemo } from 'react'
import { HashRouter, Route, Routes } from 'react-router-dom' import { HashRouter, Route, Routes } from 'react-router-dom'
import Sidebar from './components/app/Sidebar' import Sidebar from './components/app/Sidebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import TabsContainer from './components/Tab/TabContainer' import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler' import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useSettings' import { useNavbarPosition } from './hooks/useSettings'
@ -23,18 +24,20 @@ const Router: FC = () => {
const routes = useMemo(() => { const routes = useMemo(() => {
return ( return (
<Routes> <ErrorBoundary>
<Route path="/" element={<HomePage />} /> <Routes>
<Route path="/agents" element={<AgentsPage />} /> <Route path="/" element={<HomePage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} /> <Route path="/agents" element={<AgentsPage />} />
<Route path="/translate" element={<TranslatePage />} /> <Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/files" element={<FilesPage />} /> <Route path="/translate" element={<TranslatePage />} />
<Route path="/knowledge" element={<KnowledgePage />} /> <Route path="/files" element={<FilesPage />} />
<Route path="/apps" element={<MinAppsPage />} /> <Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/code" element={<CodeToolsPage />} /> <Route path="/apps" element={<MinAppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} /> <Route path="/code" element={<CodeToolsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} /> <Route path="/settings/*" element={<SettingsPage />} />
</Routes> <Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
</ErrorBoundary>
) )
}, []) }, [])

View File

@ -19,6 +19,7 @@ import { estimateTextTokens } from '@renderer/services/TokenService'
import { import {
Assistant, Assistant,
EFFORT_RATIO, EFFORT_RATIO,
FileTypes,
GenerateImageParams, GenerateImageParams,
MCPCallToolResponse, MCPCallToolResponse,
MCPTool, MCPTool,
@ -53,7 +54,7 @@ import {
mcpToolCallResponseToAwsBedrockMessage, mcpToolCallResponseToAwsBedrockMessage,
mcpToolsToAwsBedrockTools mcpToolsToAwsBedrockTools
} from '@renderer/utils/mcp-tools' } 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 { t } from 'i18next'
import { BaseApiClient } from '../BaseApiClient' 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) { if (parts.length === 0) {
parts.push({ text: 'No content provided' }) parts.push({ text: 'No content provided' })

View File

@ -52,6 +52,7 @@ import {
GeminiSdkRawOutput, GeminiSdkRawOutput,
GeminiSdkToolCall GeminiSdkToolCall
} from '@renderer/types/sdk' } from '@renderer/types/sdk'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { import {
geminiFunctionCallToMcpTool, geminiFunctionCallToMcpTool,
isEnabledToolUse, isEnabledToolUse,
@ -428,8 +429,7 @@ export class GeminiAPIClient extends BaseApiClient<
private getGenerateImageParameter(): Partial<GenerateContentConfig> { private getGenerateImageParameter(): Partial<GenerateContentConfig> {
return { return {
systemInstruction: undefined, systemInstruction: undefined,
responseModalities: [Modality.TEXT, Modality.IMAGE], responseModalities: [Modality.TEXT, Modality.IMAGE]
responseMimeType: 'text/plain'
} }
} }
@ -476,16 +476,20 @@ export class GeminiAPIClient extends BaseApiClient<
} }
} }
if (enableWebSearch) { if (tools.length === 0 || !isToolUseModeFunction(assistant)) {
tools.push({ if (enableWebSearch) {
googleSearch: {} tools.push({
}) googleSearch: {}
} })
}
if (enableUrlContext) { if (enableUrlContext) {
tools.push({ tools.push({
urlContext: {} urlContext: {}
}) })
}
} else if (enableWebSearch || enableUrlContext) {
logger.warn('Native tools cannot be used with function calling for now.')
} }
if (isGemmaModel(model) && assistant.prompt) { if (isGemmaModel(model) && assistant.prompt) {

View File

@ -6,11 +6,13 @@ import {
getOpenAIWebSearchParams, getOpenAIWebSearchParams,
getThinkModelType, getThinkModelType,
isClaudeReasoningModel, isClaudeReasoningModel,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel, isDoubaoThinkingAutoModel,
isGeminiReasoningModel, isGeminiReasoningModel,
isGPT5SeriesModel, isGPT5SeriesModel,
isGrokReasoningModel, isGrokReasoningModel,
isNotSupportSystemMessageModel, isNotSupportSystemMessageModel,
isOpenAIOpenWeightModel,
isOpenAIReasoningModel, isOpenAIReasoningModel,
isQwenAlwaysThinkModel, isQwenAlwaysThinkModel,
isQwenMTModel, isQwenMTModel,
@ -26,7 +28,8 @@ import {
isSupportedThinkingTokenQwenModel, isSupportedThinkingTokenQwenModel,
isSupportedThinkingTokenZhipuModel, isSupportedThinkingTokenZhipuModel,
isVisionModel, isVisionModel,
MODEL_SUPPORTED_REASONING_EFFORT MODEL_SUPPORTED_REASONING_EFFORT,
ZHIPU_RESULT_TOKENS
} from '@renderer/config/models' } from '@renderer/config/models'
import { import {
isSupportArrayContentProvider, isSupportArrayContentProvider,
@ -42,6 +45,7 @@ import {
Assistant, Assistant,
EFFORT_RATIO, EFFORT_RATIO,
FileTypes, FileTypes,
isSystemProvider,
MCPCallToolResponse, MCPCallToolResponse,
MCPTool, MCPTool,
MCPToolResponse, MCPToolResponse,
@ -111,7 +115,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
*/ */
// Method for reasoning effort, moved from OpenAIProvider // Method for reasoning effort, moved from OpenAIProvider
override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams { override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams {
if (this.provider.id === 'groq') { if (this.provider.id === SystemProviderIds.groq) {
return {} return {}
} }
@ -120,22 +124,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
} }
const reasoningEffort = assistant?.settings?.reasoning_effort 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 (isSupportedThinkingTokenZhipuModel(model)) {
if (!reasoningEffort) { if (!reasoningEffort) {
return { thinking: { type: 'disabled' } } return { thinking: { type: 'disabled' } }
@ -144,7 +132,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
} }
if (!reasoningEffort) { 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 // Don't disable reasoning for Gemini models that support thinking tokens
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) { if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return {} return {}
@ -156,17 +151,22 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
return { reasoning: { enabled: false, exclude: true } } return { reasoning: { enabled: false, exclude: true } }
} }
// providers that use enable_thinking
if ( if (
isSupportEnableThinkingProvider(this.provider) && isSupportEnableThinkingProvider(this.provider) &&
(isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model)) (isSupportedThinkingTokenQwenModel(model) ||
isSupportedThinkingTokenHunyuanModel(model) ||
(this.provider.id === SystemProviderIds.dashscope && isDeepSeekHybridInferenceModel(model)))
) { ) {
return { enable_thinking: false } return { enable_thinking: false }
} }
// claude
if (isSupportedThinkingTokenClaudeModel(model)) { if (isSupportedThinkingTokenClaudeModel(model)) {
return {} return {}
} }
// gemini
if (isSupportedThinkingTokenGeminiModel(model)) { if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return { return {
@ -195,8 +195,48 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min! (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 // OpenRouter models
if (model.provider === 'openrouter') { if (model.provider === SystemProviderIds.openrouter) {
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) { if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return { return {
reasoning: { 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 // Qwen models
if (isQwenReasoningModel(model)) { if (isQwenReasoningModel(model)) {
const thinkConfig = { const thinkConfig = {
@ -213,7 +265,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true, isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true,
thinking_budget: budgetTokens thinking_budget: budgetTokens
} }
if (this.provider.id === 'dashscope') { if (this.provider.id === SystemProviderIds.dashscope) {
return { return {
...thinkConfig, ...thinkConfig,
incremental_output: true incremental_output: true
@ -530,12 +582,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// 1. 处理系统消息 // 1. 处理系统消息
const systemMessage = { role: 'system', content: assistant.prompt || '' } const systemMessage = { role: 'system', content: assistant.prompt || '' }
if (isSupportedReasoningEffortOpenAIModel(model)) { if (
if (isSupportDeveloperRoleProvider(this.provider)) { isSupportedReasoningEffortOpenAIModel(model) &&
systemMessage.role = 'developer' isSupportDeveloperRoleProvider(this.provider) &&
} else { !isOpenAIOpenWeightModel(model)
systemMessage.role = 'system' ) {
} systemMessage.role = 'developer'
} }
if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) { 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)) userMessages.push(await this.convertMessageToSdkParam(message, model))
} }
} }
if (userMessages.length === 0) {
logger.warn('No user message. Some providers may not support.')
}
// poe 需要通过用户消息传递 reasoningEffort // poe 需要通过用户消息传递 reasoningEffort
const reasoningEffort = this.getReasoningEffort(assistant, model) const reasoningEffort = this.getReasoningEffort(assistant, model)
@ -566,11 +621,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
const lastUserMsg = userMessages.findLast((m) => m.role === 'user') const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
if (lastUserMsg) { if (lastUserMsg) {
if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) { if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) {
const postsuffix = '/no_think'
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
const currentContent = lastUserMsg.content const currentContent = lastUserMsg.content
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, postsuffix, qwenThinkModeEnabled) as any lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled)
} }
if (this.provider.id === SystemProviderIds.poe) { if (this.provider.id === SystemProviderIds.poe) {
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分 // 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
@ -586,8 +640,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// 4. 最终请求消息 // 4. 最终请求消息
let reqMessages: OpenAISdkMessageParam[] let reqMessages: OpenAISdkMessageParam[]
if (!systemMessage.content || isNotSupportSystemMessageModel(model)) { if (!systemMessage.content) {
reqMessages = [...userMessages] 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 { } else {
reqMessages = [systemMessage, ...userMessages].filter(Boolean) as OpenAISdkMessageParam[] 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.content === 'string' && choice.delta.content !== '') ||
(typeof (choice.delta as any).reasoning_content === 'string' && (typeof (choice.delta as any).reasoning_content === 'string' &&
(choice.delta as any).reasoning_content !== '') || (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 contentSource = choice.delta
} else if ('message' in choice) { } else if ('message' in choice) {
@ -896,27 +961,59 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
accumulatingText = true accumulatingText = true
} }
// logger.silly('enqueue TEXT_DELTA') // logger.silly('enqueue TEXT_DELTA')
controller.enqueue({ // 处理特殊token
type: ChunkType.TEXT_DELTA, // 智谱api的一个chunk中只会输出一个token因而使用 ===,避免正常内容被误判
text: contentSource.content 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 { } else {
accumulatingText = false 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) { if (contentSource.tool_calls) {
for (const toolCall of contentSource.tool_calls) { for (const toolCall of contentSource.tool_calls) {
if ('index' in toolCall) { if ('index' in toolCall) {
const { id, index, function: fun } = toolCall const { id, index, function: fun } = toolCall
if (fun?.name) { if (fun?.name) {
toolCalls[index] = { const toolCallObject = {
id: id || '', id: id || '',
function: { function: {
name: fun.name, name: fun.name,
arguments: fun.arguments || '' arguments: fun.arguments || ''
}, },
type: 'function' type: 'function' as const
}
if (index === -1) {
toolCalls.push(toolCallObject)
} else {
toolCalls[index] = toolCallObject
} }
} else if (fun?.arguments) { } else if (fun?.arguments) {
if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) { if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) {

View File

@ -5,6 +5,7 @@ import {
isGPT5SeriesModel, isGPT5SeriesModel,
isOpenAIChatCompletionOnlyModel, isOpenAIChatCompletionOnlyModel,
isOpenAILLMModel, isOpenAILLMModel,
isOpenAIOpenWeightModel,
isSupportedReasoningEffortOpenAIModel, isSupportedReasoningEffortOpenAIModel,
isSupportVerbosityModel, isSupportVerbosityModel,
isVisionModel isVisionModel
@ -374,12 +375,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
text: assistant.prompt || '', text: assistant.prompt || '',
type: 'input_text' type: 'input_text'
} }
if (isSupportedReasoningEffortOpenAIModel(model)) { if (
if (isSupportDeveloperRoleProvider(this.provider)) { isSupportedReasoningEffortOpenAIModel(model) &&
systemMessage.role = 'developer' isSupportDeveloperRoleProvider(this.provider) &&
} else { isOpenAIOpenWeightModel(model)
systemMessage.role = 'system' ) {
} systemMessage.role = 'developer'
} }
// 2. 设置工具 // 2. 设置工具

View File

@ -112,7 +112,7 @@ export default class AiProvider {
builder.remove(ToolUseExtractionMiddlewareName) builder.remove(ToolUseExtractionMiddlewareName)
logger.silly('ToolUseExtractionMiddleware is removed') logger.silly('ToolUseExtractionMiddleware is removed')
} }
if (params.callType !== 'chat') { if (params.callType !== 'chat' && params.callType !== 'check' && params.callType !== 'translate') {
logger.silly('AbortHandlerMiddleware is removed') logger.silly('AbortHandlerMiddleware is removed')
builder.remove(AbortHandlerMiddlewareName) builder.remove(AbortHandlerMiddlewareName)
} }

View File

@ -21,32 +21,38 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
return result 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 abortController = new AbortController()
const abortFn = (): void => abortController.abort() const abortFn = (): void => abortController.abort()
addAbortController(messageId, abortFn)
let abortSignal: AbortSignal | null = abortController.signal 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 => { const cleanup = (): void => {
removeAbortController(messageId as string, abortFn) removeAbortController(abortKey, abortFn)
if (ctx._internal?.flowControl) { if (ctx._internal?.flowControl) {
ctx._internal.flowControl.abortController = undefined ctx._internal.flowControl.abortController = undefined
ctx._internal.flowControl.abortSignal = undefined ctx._internal.flowControl.abortSignal = undefined

View File

@ -1,5 +1,4 @@
import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient' import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient'
import { isAnthropicModel } from '@renderer/config/models'
import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk' import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk'
import { AnthropicStreamListener } from '../../clients/types' import { AnthropicStreamListener } from '../../clients/types'
@ -16,9 +15,8 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
// 在这里可以监听到从SDK返回的最原始流 // 在这里可以监听到从SDK返回的最原始流
if (result.rawOutput) { if (result.rawOutput) {
const model = params.assistant.model
// TODO: 后面下放到AnthropicAPIClient // TODO: 后面下放到AnthropicAPIClient
if (isAnthropicModel(model)) { if (ctx.apiClientInstance instanceof AnthropicAPIClient) {
const anthropicListener: AnthropicStreamListener<AnthropicSdkRawChunk> = { const anthropicListener: AnthropicStreamListener<AnthropicSdkRawChunk> = {
onMessage: (message) => { onMessage: (message) => {
if (ctx._internal?.toolProcessingState) { if (ctx._internal?.toolProcessingState) {

View File

@ -7,6 +7,7 @@ import {
ThinkingDeltaChunk, ThinkingDeltaChunk,
ThinkingStartChunk ThinkingStartChunk
} from '@renderer/types/chunk' } from '@renderer/types/chunk'
import { getLowerBaseModelName } from '@renderer/utils'
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction' import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
@ -22,13 +23,16 @@ const reasoningTags: TagConfig[] = [
{ openingTag: '<thought>', closingTag: '</thought>', separator: '\n' }, { openingTag: '<thought>', closingTag: '</thought>', separator: '\n' },
{ openingTag: '###Thinking', closingTag: '###Response', separator: '\n' }, { openingTag: '###Thinking', closingTag: '###Response', separator: '\n' },
{ openingTag: '◁think▷', closingTag: '◁/think▷', 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 => { const getAppropriateTag = (model?: Model): TagConfig => {
if (model?.id?.includes('qwen3')) return reasoningTags[0] const modelId = model?.id ? getLowerBaseModelName(model.id) : undefined
if (model?.id?.includes('gemini-2.5')) return reasoningTags[1] if (modelId?.includes('qwen3')) return reasoningTags[0]
if (model?.id?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3] 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> 标签 return reasoningTags[0] // 默认使用 <think> 标签
} }

View File

@ -59,6 +59,9 @@ export interface CompletionsParams {
contextCount?: number contextCount?: number
topicId?: string // 主题ID用于关联上下文 topicId?: string // 主题ID用于关联上下文
// abort 控制
abortKey?: string
_internal?: ProcessingState _internal?: ProcessingState
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -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

View File

@ -68,3 +68,23 @@
transform-origin: center; transform-origin: center;
animation: animation-rotate 0.75s linear infinite; 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;
}

View File

@ -1,5 +1,26 @@
@use './container.scss'; @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 { #inputbar {
resize: none; resize: none;
} }
@ -66,6 +87,7 @@
} }
.ant-drawer-header { .ant-drawer-header {
/* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
@ -76,7 +98,7 @@
} }
.ant-dropdown-menu .ant-dropdown-menu-sub { .ant-dropdown-menu .ant-dropdown-menu-sub {
max-height: 50vh; max-height: 80vh;
width: max-content; width: max-content;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@ -88,7 +110,7 @@
border-radius: var(--ant-border-radius-lg); border-radius: var(--ant-border-radius-lg);
user-select: none; user-select: none;
.ant-dropdown-menu { .ant-dropdown-menu {
max-height: 50vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
@ -148,6 +170,7 @@
border-radius: 10px; border-radius: 10px;
} }
.ant-modal-body { .ant-modal-body {
/* 保持 body 在视口内,使用标准的最大高度 */
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
padding: 0 16px 0 16px; padding: 0 16px 0 16px;

View File

@ -106,6 +106,10 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.katex span {
white-space: pre;
}
p code, p code,
li code { li code {
background: var(--color-background-mute); background: var(--color-background-mute);

View File

@ -6,10 +6,8 @@ html {
:root { :root {
// Basic Colors // Basic Colors
--color-primary: #00b96b;
--color-error: #f44336; --color-error: #f44336;
--selection-toolbar-color-primary: var(--color-primary);
--selection-toolbar-color-error: var(--color-error); --selection-toolbar-color-error: var(--color-error);
// Toolbar // Toolbar
@ -54,8 +52,6 @@ html {
--selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9); --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-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: transparent; // default: transparent
--selection-toolbar-button-bgcolor-hover: #333333; --selection-toolbar-button-bgcolor-hover: #333333;
} }
@ -72,7 +68,5 @@ html {
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1); --selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04); --selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
} }

View File

@ -157,6 +157,7 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
width: 44px; width: 44px;
height: 44px; height: 44px;
background: ${(props) => background: ${(props) =>
@ -177,13 +178,16 @@ const TitleSection = styled.div`
gap: 6px; gap: 6px;
` `
const Title = styled.h3` const Title = styled.span`
margin: 0 !important; font-size: 14px;
font-size: 14px !important; font-weight: bold;
font-weight: 600; color: var(--color-text-1);
color: var(--color-text);
line-height: 1.4; line-height: 1.4;
font-family: 'Ubuntu'; font-family: 'Ubuntu';
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
` `
const TypeBadge = styled.div` const TypeBadge = styled.div`

View File

@ -1,7 +1,7 @@
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { isLinux, isMac, isWin } from '@renderer/config/constant' import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { classNames } from '@renderer/utils' 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 { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -43,7 +43,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
const renderHeader = () => ( const renderHeader = () => (
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}> <ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
<HeaderLeft $isFullscreen={isFullscreen}> <HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText> <TitleText ellipsis={{ tooltip: true }}>{title}</TitleText>
</HeaderLeft> </HeaderLeft>
<HeaderCenter> <HeaderCenter>
@ -266,13 +266,13 @@ const HeaderRight = styled.div<{ $isFullscreen?: boolean }>`
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')}; padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
` `
const TitleText = styled.span` const TitleText = styled(Typography.Text)`
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: bold;
color: var(--color-text); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; width: 50%;
` `
const ViewControls = styled.div` const ViewControls = styled.div`

View File

@ -18,8 +18,8 @@ import { BasicPreviewHandles } from '@renderer/components/Preview'
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService' import { pyodideService } from '@renderer/services/PyodideService'
import { getExtensionByLanguage } from '@renderer/utils/code-language'
import { extractTitle } from '@renderer/utils/formats' import { extractTitle } from '@renderer/utils/formats'
import { getExtensionByLanguage } from '@renderer/utils/markdown'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -1,10 +1,11 @@
import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Extension, keymap } from '@uiw/react-codemirror' import { Extension, keymap } from '@uiw/react-codemirror'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
const logger = loggerService.withContext('CodeEditorHooks') const logger = loggerService.withContext('CodeEditorHooks')
// 语言对应的 linter 加载器 // 语言对应的 linter 加载器
@ -17,32 +18,33 @@ const linterLoaders: Record<string, () => Promise<any>> = {
/** /**
* *
* key: 语言文件扩展名 `.`
*/ */
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = { const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
dot: async () => { dot: async () => {
const mod = await import('@viz-js/lang-dot') const mod = await import('@viz-js/lang-dot')
return mod.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> { async function loadLanguageExtension(language: string): Promise<Extension | null> {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() const fileExt = await getNormalizedExtension(language)
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
// 尝试加载特殊语言 // 尝试加载特殊语言
const specialLoader = specialLanguageLoaders[normalizedLang] const specialLoader = specialLanguageLoaders[fileExt]
if (specialLoader) { if (specialLoader) {
try { try {
return await specialLoader() return await specialLoader()
} catch (error) { } 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 return null
} }
} }
@ -50,10 +52,10 @@ async function loadLanguageExtension(language: string, languageMap: Record<strin
// 回退到 uiw/codemirror 包含的语言 // 回退到 uiw/codemirror 包含的语言
try { try {
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs') const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
const extension = loadLanguage(normalizedLang as any) const extension = loadLanguage(fileExt as any)
return extension || null return extension || null
} catch (error) { } 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 return null
} }
} }
@ -77,7 +79,6 @@ async function loadLinterExtension(language: string): Promise<Extension | null>
* *
*/ */
export const useLanguageExtensions = (language: string, lint?: boolean) => { export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([]) const [extensions, setExtensions] = useState<Extension[]>([])
useEffect(() => { useEffect(() => {
@ -87,7 +88,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
try { try {
// 加载所有扩展 // 加载所有扩展
const [languageResult, linterResult] = await Promise.allSettled([ const [languageResult, linterResult] = await Promise.allSettled([
loadLanguageExtension(language, languageMap), loadLanguageExtension(language),
lint ? loadLinterExtension(language) : Promise.resolve(null) lint ? loadLinterExtension(language) : Promise.resolve(null)
]) ])
@ -119,7 +120,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [language, lint, languageMap]) }, [language, lint])
return extensions return extensions
} }

View 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
}

View File

@ -1,21 +1,30 @@
import i18n from '@renderer/i18n'
import { Input, InputRef, Tooltip } from 'antd' import { Input, InputRef, Tooltip } from 'antd'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface CollapsibleSearchBarProps { interface CollapsibleSearchBarProps {
onSearch: (text: string) => void onSearch: (text: string) => void
placeholder?: string
tooltip?: string
icon?: React.ReactNode icon?: React.ReactNode
maxWidth?: string | number maxWidth?: string | number
style?: React.CSSProperties
} }
/** /**
* A collapsible search bar for list headers * A collapsible search bar for list headers
* Renders as an icon initially, expands to full search input when clicked * Renders as an icon initially, expands to full search input when clicked
*/ */
const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, icon, maxWidth }) => { const CollapsibleSearchBar = ({
const { t } = useTranslation() 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 [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null) const inputRef = useRef<InputRef>(null)
@ -46,16 +55,16 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
initial="collapsed" initial="collapsed"
animate={searchVisible ? 'expanded' : 'collapsed'} animate={searchVisible ? 'expanded' : 'collapsed'}
variants={{ 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' } } collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
}} }}
style={{ overflow: 'hidden', flex: 1 }}> style={{ overflow: 'hidden', flex: 1 }}>
<Input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder={t('models.search')} placeholder={placeholder}
size="small" size="small"
suffix={icon || <Search size={14} color="var(--color-icon)" />} suffix={icon}
value={searchText} value={searchText}
autoFocus autoFocus
allowClear allowClear
@ -71,7 +80,7 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
if (!searchText) setSearchVisible(false) if (!searchText) setSearchVisible(false)
}} }}
onClear={handleClear} onClear={handleClear}
style={{ width: '100%' }} style={{ width: '100%', ...style }}
/> />
</motion.div> </motion.div>
<motion.div <motion.div
@ -83,8 +92,8 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
}} }}
style={{ cursor: 'pointer', display: 'flex' }} style={{ cursor: 'pointer', display: 'flex' }}
onClick={() => setSearchVisible(true)}> onClick={() => setSearchVisible(true)}>
<Tooltip title={t('models.search')} mouseLeaveDelay={0}> <Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
{icon || <Search size={14} color="var(--color-icon)" />} {icon}
</Tooltip> </Tooltip>
</motion.div> </motion.div>
</div> </div>

View File

@ -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 { declare global {
interface Window { interface Window {
triggerOnDragEnd: (result?: any, provided?: any) => void triggerOnDragEnd: (result?: any, provided?: any) => void
@ -73,14 +59,15 @@ describe('DraggableList', () => {
const list = [{ id: 'a', name: 'A' }] const list = [{ id: 'a', name: 'A' }]
const style = { background: 'red' } const style = { background: 'red' }
const listStyle = { color: 'blue' } const listStyle = { color: 'blue' }
render( const { container } = render(
<DraggableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}> <DraggableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>} {(item) => <div data-testid="item">{item.name}</div>}
</DraggableList> </DraggableList>
) )
// 检查 style 是否传递到外层容器 // 检查 style 是否传递到外层容器
const virtualList = screen.getByTestId('virtual-list') const listContainer = container.querySelector('.draggable-list-container')
expect(virtualList.parentElement).toHaveStyle({ background: 'red' }) expect(listContainer).not.toBeNull()
expect(listContainer?.parentElement).toHaveStyle({ background: 'red' })
}) })
it('should render nothing when list is empty', () => { it('should render nothing when list is empty', () => {

View File

@ -32,7 +32,7 @@ vi.mock('@hello-pangea/dnd', () => ({
})) }))
vi.mock('@tanstack/react-virtual', () => ({ vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }) => ({ useVirtualizer: ({ count, getScrollElement }) => ({
getVirtualItems: () => getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({ Array.from({ length: count }, (_, index) => ({
index, index,
@ -41,7 +41,13 @@ vi.mock('@tanstack/react-virtual', () => ({
size: 50 size: 50
})), })),
getTotalSize: () => count * 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)
}) })
})) }))

View File

@ -10,56 +10,44 @@ exports[`DraggableList > snapshot > should match snapshot 1`] = `
> >
<div> <div>
<div <div
data-testid="virtual-list" class="draggable-list-container"
> >
<div <div
data-testid="virtual-list-item" data-testid="draggable-a-0"
> >
<div <div
data-testid="draggable-a-0" style="margin-bottom: 8px;"
> >
<div <div
style="margin-bottom: 8px;" data-testid="item"
> >
<div A
data-testid="item"
>
A
</div>
</div> </div>
</div> </div>
</div> </div>
<div <div
data-testid="virtual-list-item" data-testid="draggable-b-1"
> >
<div <div
data-testid="draggable-b-1" style="margin-bottom: 8px;"
> >
<div <div
style="margin-bottom: 8px;" data-testid="item"
> >
<div B
data-testid="item"
>
B
</div>
</div> </div>
</div> </div>
</div> </div>
<div <div
data-testid="virtual-list-item" data-testid="draggable-c-2"
> >
<div <div
data-testid="draggable-c-2" style="margin-bottom: 8px;"
> >
<div <div
style="margin-bottom: 8px;" data-testid="item"
> >
<div C
data-testid="item"
>
C
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,3 +1,7 @@
export { default as DraggableList } from './list' export { default as DraggableList } from './list'
export { useDraggableReorder } from './useDraggableReorder' export { useDraggableReorder } from './useDraggableReorder'
export { default as DraggableVirtualList } from './virtual-list' export {
default as DraggableVirtualList,
type DraggableVirtualListProps,
type DraggableVirtualListRef
} from './virtual-list'

View File

@ -9,13 +9,13 @@ import {
ResponderProvided ResponderProvided
} from '@hello-pangea/dnd' } from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils' import { droppableReorder } from '@renderer/utils'
import { List } from 'antd' import { FC, HTMLAttributes } from 'react'
import { FC } from 'react'
interface Props<T> { interface Props<T> {
list: T[] list: T[]
style?: React.CSSProperties style?: React.CSSProperties
listStyle?: React.CSSProperties listStyle?: React.CSSProperties
listProps?: HTMLAttributes<HTMLDivElement>
children: (item: T, index: number) => React.ReactNode children: (item: T, index: number) => React.ReactNode
onUpdate: (list: T[]) => void onUpdate: (list: T[]) => void
onDragStart?: OnDragStartResponder onDragStart?: OnDragStartResponder
@ -28,6 +28,7 @@ const DraggableList: FC<Props<any>> = ({
list, list,
style, style,
listStyle, listStyle,
listProps,
droppableProps, droppableProps,
onDragStart, onDragStart,
onUpdate, onUpdate,
@ -50,9 +51,8 @@ const DraggableList: FC<Props<any>> = ({
<Droppable droppableId="droppable" {...droppableProps}> <Droppable droppableId="droppable" {...droppableProps}>
{(provided) => ( {(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={style}> <div {...provided.droppableProps} ref={provided.innerRef} style={style}>
<List <div {...listProps} className="draggable-list-container">
dataSource={list} {list.map((item, index) => {
renderItem={(item, index) => {
const id = item.id || item const id = item.id || item
return ( return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}> <Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
@ -71,8 +71,8 @@ const DraggableList: FC<Props<any>> = ({
)} )}
</Draggable> </Draggable>
) )
}} })}
/> </div>
{provided.placeholder} {provided.placeholder}
</div> </div>
)} )}

View File

@ -10,8 +10,19 @@ import {
} from '@hello-pangea/dnd' } from '@hello-pangea/dnd'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { droppableReorder } from '@renderer/utils' import { droppableReorder } from '@renderer/utils'
import { useVirtualizer } from '@tanstack/react-virtual' import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
import { type Key, memo, useCallback, useRef } from 'react' 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 * Props DraggableVirtualList
@ -31,8 +42,8 @@ import { type Key, memo, useCallback, useRef } from 'react'
* @property {React.ReactNode} [header] * @property {React.ReactNode} [header]
* @property {(item: T, index: number) => React.ReactNode} children * @property {(item: T, index: number) => React.ReactNode} children
*/ */
interface DraggableVirtualListProps<T> { export interface DraggableVirtualListProps<T> {
ref?: React.Ref<HTMLDivElement> ref?: React.Ref<DraggableVirtualListRef>
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
scrollerStyle?: React.CSSProperties scrollerStyle?: React.CSSProperties
@ -100,9 +111,23 @@ function DraggableVirtualList<T>({
overscan 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 ( return (
<div <div
ref={ref}
className={`${className} draggable-virtual-list`} className={`${className} draggable-virtual-list`}
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}> style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}> <DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>

View 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 }

View File

@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
fill="currentColor" fill="currentColor"
fill-rule="evenodd" fillRule="evenodd"
width="1em" width="1em"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -193,7 +193,7 @@ export function ExaLogo(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
fill="currentColor" fill="currentColor"
fill-rule="evenodd" fillRule="evenodd"
width="1em" width="1em"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -211,30 +211,75 @@ export function BochaLogo(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg width="1em" height="1em" viewBox="0 0 135 116" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg width="1em" height="1em" viewBox="0 0 135 116" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path <path
fill-rule="evenodd" fillRule="evenodd"
clip-rule="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" 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" fill="currentColor"
/> />
<path <path
opacity="0.64774" opacity="0.64774"
fill-rule="evenodd" fillRule="evenodd"
clip-rule="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" 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" fill="currentColor"
/> />
<path <path
fill-rule="evenodd" fillRule="evenodd"
clip-rule="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" 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" fill="currentColor"
/> />
<path <path
fill-rule="evenodd" fillRule="evenodd"
clip-rule="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" 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" fill="currentColor"
/> />
</svg> </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>
)
}

View File

@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
} }
return ( return (
<div ref={hostRef}> <div ref={hostRef} style={{ display: 'none' }}>
{createPortal( {createPortal(
<StyleSheetManager target={shadowRoot}> <StyleSheetManager target={shadowRoot}>
<StyleProvider container={shadowRoot} layer> <StyleProvider container={shadowRoot} layer>

View File

@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings' import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types' import { MinAppType } from '@renderer/types'
@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => {
const isInDevelopment = process.env.NODE_ENV === 'development' const isInDevelopment = process.env.NODE_ENV === 'development'
const { setTimeoutTimer } = useTimer()
useBridge() useBridge()
/** set the popup display status */ /** set the popup display status */
@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
} }
if (appid == currentMinappId) { if (appid == currentMinappId) {
setTimeout(() => setIsReady(true), 200) setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
} }
} }

View File

@ -8,20 +8,19 @@ import {
} from '@renderer/config/models' } from '@renderer/config/models'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { Model } from '@renderer/types' 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 { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import CustomTag from './Tags/CustomTag'
import { import {
EmbeddingTag, EmbeddingTag,
FreeTag,
ReasoningTag, ReasoningTag,
RerankerTag, RerankerTag,
ToolsCallingTag, ToolsCallingTag,
VisionTag, VisionTag,
WebSearchTag WebSearchTag
} from './Tags/ModelCapabilities' } from './Tags/Model'
interface ModelTagsProps { interface ModelTagsProps {
model: Model model: Model
@ -44,7 +43,6 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
showTooltip = true, showTooltip = true,
style style
}) => { }) => {
const { t } = useTranslation()
const [shouldShowLabel, setShouldShowLabel] = useState(false) const [shouldShowLabel, setShouldShowLabel] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const resizeObserver = useRef<ResizeObserver | null>(null) const resizeObserver = useRef<ResizeObserver | null>(null)
@ -86,7 +84,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
<ToolsCallingTag size={size} showTooltip={showTooltip} showLabel={shouldShowLabel} /> <ToolsCallingTag size={size} showTooltip={showTooltip} showLabel={shouldShowLabel} />
)} )}
{isEmbeddingModel(model) && <EmbeddingTag size={size} />} {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} />} {isRerankModel(model) && <RerankerTag size={size} />}
</Container> </Container>
) )

View File

@ -1,6 +1,7 @@
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents' import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { useSystemAgents } from '@renderer/pages/agents' import { useSystemAgents } from '@renderer/pages/agents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@ -33,6 +34,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const loadingRef = useRef(false) const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const { setTimeoutTimer } = useTimer()
const agents = useMemo(() => { const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[] const allAgents = [...userAgents, ...systemAgents] as Agent[]
@ -80,11 +82,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
assistant = await createAssistantFromAgent(agent) assistant = await createAssistantFromAgent(agent)
} }
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant) resolve(assistant)
setOpen(false) setOpen(false)
}, },
[resolve, addAssistant, setOpen] [setTimeoutTimer, resolve, addAssistant]
) // 添加函数内使用的依赖项 ) // 添加函数内使用的依赖项
// 键盘导航处理 // 键盘导航处理
useEffect(() => { useEffect(() => {

View File

@ -1,6 +1,6 @@
import { Input, Modal } from 'antd' import { Input, Modal } from 'antd'
import { TextAreaProps } from 'antd/es/input' import { TextAreaProps } from 'antd/es/input'
import { useRef, useState } from 'react' import { ReactNode, useRef, useState } from 'react'
import { Box } from '../Layout' import { Box } from '../Layout'
import { TopView } from '../TopView' import { TopView } from '../TopView'
@ -11,6 +11,7 @@ interface PromptPopupShowParams {
defaultValue?: string defaultValue?: string
inputPlaceholder?: string inputPlaceholder?: string
inputProps?: TextAreaProps inputProps?: TextAreaProps
extraNode?: ReactNode
} }
interface Props extends PromptPopupShowParams { interface Props extends PromptPopupShowParams {
@ -23,6 +24,7 @@ const PromptPopupContainer: React.FC<Props> = ({
defaultValue = '', defaultValue = '',
inputPlaceholder = '', inputPlaceholder = '',
inputProps = {}, inputProps = {},
extraNode = null,
resolve resolve
}) => { }) => {
const [value, setValue] = useState(defaultValue) const [value, setValue] = useState(defaultValue)
@ -88,6 +90,7 @@ const PromptPopupContainer: React.FC<Props> = ({
rows={1} rows={1}
{...inputProps} {...inputProps}
/> />
{extraNode}
</Modal> </Modal>
) )
} }

View File

@ -1,16 +1,36 @@
import { PushpinOutlined } from '@ant-design/icons' import { PushpinOutlined } from '@ant-design/icons'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' 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 { TopView } from '@renderer/components/TopView'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' 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 { usePinnedModels } from '@renderer/hooks/usePinnedModels'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService' 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 { 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 { first, sortBy } from 'lodash'
import { SettingsIcon } from 'lucide-react'
import React, { import React, {
ReactNode,
startTransition, startTransition,
useCallback, useCallback,
useDeferredValue, useDeferredValue,
@ -24,22 +44,28 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import SelectModelSearchBar from './searchbar' 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 const ITEM_HEIGHT = 36
type ModelPredict = (m: Model) => boolean
interface PopupParams { interface PopupParams {
model?: Model model?: Model
modelFilter?: (model: Model) => boolean modelFilter?: (model: Model) => boolean
userFilterDisabled?: boolean
} }
interface Props extends PopupParams { interface Props extends PopupParams {
resolve: (value: Model | undefined) => void 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 { t } = useTranslation()
const { providers } = useProviders() const { providers } = useProviders()
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
@ -48,6 +74,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const [_searchText, setSearchText] = useState('') const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText) const searchText = useDeferredValue(_searchText)
const allModels: Model[] = useMemo(
() => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)),
[modelFilter, providers]
)
// 当前选中的模型ID // 当前选中的模型ID
const currentModelId = model ? getModelUniqId(model) : '' 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) => { (provider: Provider) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) let models = provider.models
if (searchText.trim()) { if (searchText.trim()) {
models = filterModelsByKeywords(searchText, models, provider) models = filterModelsByKeywords(searchText, models, provider)
@ -78,7 +198,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 创建模型列表项 // 创建模型列表项
const createModelItem = useCallback( const createModelItem = useCallback(
(model: Model, provider: Provider, isPinned: boolean): FlatListItem => { (model: Model, provider: Provider, isPinned: boolean): FlatListModel => {
const modelId = getModelUniqId(model) const modelId = getModelUniqId(model)
const groupName = getFancyProviderName(provider) const groupName = getFancyProviderName(provider)
@ -113,7 +233,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const { listItems, modelItems } = useMemo(() => { const { listItems, modelItems } = useMemo(() => {
const items: FlatListItem[] = [] const items: FlatListItem[] = []
const pinnedModelIds = new Set(pinnedModels) 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) { if (searchText.length === 0 && pinnedModelIds.size > 0) {
@ -139,7 +263,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 添加常规模型分组 // 添加常规模型分组
providers.forEach((p) => { providers.forEach((p) => {
const filteredModels = getFilteredModels(p) const filteredModels = searchFilter(p)
.filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m))) .filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m)))
.filter(finalModelFilter) .filter(finalModelFilter)
@ -150,6 +274,22 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
key: `provider-${p.id}`, key: `provider-${p.id}`,
type: 'group', type: 'group',
name: getFancyProviderName(p), 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 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 } 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(() => { const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
@ -307,7 +458,12 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
(item: FlatListItem) => { (item: FlatListItem) => {
const isFocused = item.key === focusedItemKey const isFocused = item.key === focusedItemKey
if (item.type === 'group') { if (item.type === 'group') {
return <GroupItem>{item.name}</GroupItem> return (
<GroupItem>
{item.name}
{item.actions}
</GroupItem>
)
} }
return ( return (
<ModelItem <ModelItem
@ -352,7 +508,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
borderRadius: 20, borderRadius: 20,
padding: 0, padding: 0,
overflow: 'hidden', overflow: 'hidden',
paddingBottom: 16 paddingBottom: 16,
// 需要稳定高度避免布局偏移
height: userFilterDisabled ? undefined : 530
}, },
body: { body: {
maxHeight: 'inherit', maxHeight: 'inherit',
@ -364,6 +522,17 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
{/* 搜索框 */} {/* 搜索框 */}
<SelectModelSearchBar onSearch={setSearchText} /> <SelectModelSearchBar onSearch={setSearchText} />
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} /> <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 ? ( {listItems.length > 0 ? (
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}> <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` const ListContainer = styled.div`
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -397,11 +576,12 @@ const ListContainer = styled.div`
const GroupItem = styled.div` const GroupItem = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
position: relative; position: relative;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: normal;
height: ${ITEM_HEIGHT}px; height: ${ITEM_HEIGHT}px;
padding: 5px 10px 5px 18px; padding: 5px 12px 5px 18px;
color: var(--color-text-3); color: var(--color-text-3);
z-index: 1; z-index: 1;
background: var(--modal-background); background: var(--modal-background);

View File

@ -41,7 +41,7 @@ const SelectModelSearchBar: React.FC<SelectModelSearchBarProps> = ({ onSearch })
</SearchIcon> </SearchIcon>
} }
ref={inputRef} ref={inputRef}
placeholder={t('models.search')} placeholder={t('models.search.placeholder')}
value={searchText} value={searchText}
onChange={(e) => handleTextChange(e.target.value)} onChange={(e) => handleTextChange(e.target.value)}
onClear={handleClear} onClear={handleClear}

View File

@ -1,20 +1,46 @@
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { ReactNode } from 'react' import { ReactNode } from 'react'
// 列表项类型,组名也作为列表项 /**
export type ListItemType = 'group' | 'model' *
*/
// 滚动触发来源类型
export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none' export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
// 扁平化列表项接口 /**
export interface FlatListItem { *
*/
export type ListItemType = 'group' | 'model'
/**
*
*/
export type FlatListBaseItem = {
key: string key: string
type: ListItemType type: ListItemType
icon?: ReactNode
name: ReactNode name: ReactNode
tags?: ReactNode icon?: ReactNode
model?: Model
isPinned?: boolean
isSelected?: boolean 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

View File

@ -17,8 +17,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
// Sanitize the SVG content // Sanitize the SVG content
const sanitizedContent = DOMPurify.sanitize(svgContent, { const sanitizedContent = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject']
ADD_TAGS: ['style', 'defs', 'foreignObject']
}) })
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' }) const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })

View 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

View File

@ -54,6 +54,12 @@ export type QuickPanelListItem = {
isSelected?: boolean isSelected?: boolean
isMenu?: boolean isMenu?: boolean
disabled?: boolean disabled?: boolean
/**
*
* alwaysVisible
*
*/
alwaysVisible?: boolean
action?: (options: QuickPanelCallBackOptions) => void action?: (options: QuickPanelCallBackOptions) => void
} }

View File

@ -1,10 +1,12 @@
import { RightOutlined } from '@ant-design/icons' import { RightOutlined } from '@ant-design/icons'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useTimer } from '@renderer/hooks/useTimer'
import useUserTheme from '@renderer/hooks/useUserTheme' import useUserTheme from '@renderer/hooks/useUserTheme'
import { classNames } from '@renderer/utils' import { classNames } from '@renderer/utils'
import { Flex } from 'antd' import { Flex } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import { debounce } from 'lodash'
import { Check } from 'lucide-react' import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@ -62,18 +64,32 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText) const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('') const searchTextRef = useRef('')
// 缓存:按 item 缓存拼音文本,避免重复转换
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
// 轻量防抖:减少高频输入时的过滤调用
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index // 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('') const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('') const prevSymbolRef = useRef('')
const { setTimeoutTimer } = useTimer()
// 无匹配项自动关闭的定时器 // 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 处理搜索,过滤列表
const list = useMemo(() => { const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return [] 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 if (!_searchText) return true
let filterText = item.filterText || '' let filterText = item.filterText || ''
@ -85,29 +101,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
} }
const lowerFilterText = filterText.toLowerCase() const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = _searchText.toLowerCase()
if (lowerFilterText.includes(lowerSearchText)) { if (lowerFilterText.includes(lowerSearchText)) {
return true return true
} }
const pattern = lowerSearchText
.split('')
.map((char) => {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
})
.join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try { try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase() let pinyinText = pinyinCacheRef.current.get(item)
const regex = new RegExp(pattern, 'ig') if (!pinyinText) {
return regex.test(pinyinText) pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
pinyinCacheRef.current.set(item, pinyinText)
}
return fuzzyRegex.test(pinyinText)
} catch (error) { } catch (error) {
return true return true
} }
} else { } else {
const regex = new RegExp(pattern, 'ig') return fuzzyRegex.test(filterText.toLowerCase())
return regex.test(filterText.toLowerCase())
} }
}) })
@ -120,8 +131,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
} else { } else {
// 如果当前index超出范围调整到有效范围内 // 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => { setIndex((prevIndex) => {
if (prevIndex >= newList.length) { const combinedLength = pinnedItems.length + filteredNormalItems.length
return newList.length > 0 ? newList.length - 1 : -1 if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
} }
return prevIndex return prevIndex
}) })
@ -130,76 +142,52 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSearchTextRef.current = searchText prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol prevSymbolRef.current = ctx.symbol
return newList // 固定项置顶 + 过滤后的普通项
return [...pinnedItems, ...filteredNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText]) }, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
const canForwardAndBackward = useMemo(() => { const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0 return list.some((item) => item.isMenu) || historyPanel.length > 0
}, [list, historyPanel]) }, [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( const clearSearchText = useCallback(
(includeSymbol = false) => { (includeSymbol = false) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return
const cursorPosition = textArea.selectionStart ?? 0 const cursorPosition = textArea.selectionStart ?? 0
const prevChar = textArea.value[cursorPosition - 1] const textBeforeCursor = textArea.value.slice(0, cursorPosition)
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
searchTextRef.current = prevChar
}
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 if (lastSymbolIndex === -1) return
let newText = inputText
const searchPattern = new RegExp(`${_searchText}$`)
const match = inputText.slice(0, cursorPosition).match(searchPattern) // 根据 includeSymbol 决定是否删除符号
if (match) { const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1
const start = match.index || 0 const deleteEnd = cursorPosition
const end = start + match[0].length
newText = inputText.slice(0, start) + inputText.slice(end)
setInputText(newText)
setTimeout(() => { if (deleteStart >= deleteEnd) return
// 删除文本
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
setInputText(newText)
// 设置光标位置
setTimeoutTimer(
'quickpanel_focus',
() => {
textArea.focus() textArea.focus()
textArea.setSelectionRange(start, start) textArea.setSelectionRange(deleteStart, deleteStart)
}, 0) },
} 0
)
setSearchText('') setSearchText('')
}, },
[setInputText] [setInputText, setTimeoutTimer]
) )
const handleClose = useCallback( const handleClose = useCallback(
@ -310,9 +298,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (lastSymbolIndex !== -1) { if (lastSymbolIndex !== -1) {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex) const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchText(newSearchText) setSearchTextDebounced(newSearchText)
} else { } 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('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate) textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
textArea.removeEventListener('compositionend', handleCompositionEnd) textArea.removeEventListener('compositionend', handleCompositionEnd)
setTimeout(() => { setSearchTextDebounced.cancel()
setSearchText('') setTimeoutTimer(
}, 200) // 等待面板关闭动画结束后,再清空搜索词 'quickpanel_clear_search',
() => {
setSearchText('')
},
200
) // 等待面板关闭动画结束后,再清空搜索词
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible]) }, [ctx.isVisible])
@ -349,9 +343,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'none' scrollTriggerRef.current = 'none'
}, [index]) }, [index])
// 处理键盘事件 // 处理键盘事件(折叠时不拦截全局键盘)
useEffect(() => { 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) => { const handleKeyDown = (e: KeyboardEvent) => {
if (isMac ? e.metaKey : e.ctrlKey) { if (isMac ? e.metaKey : e.ctrlKey) {
@ -487,7 +483,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
window.removeEventListener('keyup', handleKeyUp, true) window.removeEventListener('keyup', handleKeyUp, true)
window.removeEventListener('click', handleClickOutside, 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) const [footerWidth, setFooterWidth] = useState(0)
@ -507,6 +513,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const listHeight = useMemo(() => { const listHeight = useMemo(() => {
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
}, [ctx.pageSize, list.length]) }, [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, []) const estimateSize = useCallback(() => ITEM_HEIGHT, [])
@ -554,6 +564,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$pageSize={ctx.pageSize} $pageSize={ctx.pageSize}
$selectedColor={selectedColor} $selectedColor={selectedColor}
$selectedColorHover={selectedColorHover} $selectedColorHover={selectedColorHover}
$collapsed={collapsed}
className={ctx.isVisible ? 'visible' : ''} className={ctx.isVisible ? 'visible' : ''}
data-testid="quick-panel"> data-testid="quick-panel">
<QuickPanelBody <QuickPanelBody
@ -564,17 +575,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true return prev ? prev : true
}) })
}> }>
<DynamicVirtualList {!collapsed && (
ref={listRef} <DynamicVirtualList
list={list} ref={listRef}
size={listHeight} list={list}
estimateSize={estimateSize} size={listHeight}
overscan={5} estimateSize={estimateSize}
scrollerStyle={{ overscan={5}
pointerEvents: isMouseOver ? 'auto' : 'none' scrollerStyle={{
}}> pointerEvents: isMouseOver ? 'auto' : 'none'
{rowRenderer} }}>
</DynamicVirtualList> {rowRenderer}
</DynamicVirtualList>
)}
<QuickPanelFooter ref={footerRef}> <QuickPanelFooter ref={footerRef}>
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle> <QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
<QuickPanelFooterTips $footerWidth={footerWidth}> <QuickPanelFooterTips $footerWidth={footerWidth}>
@ -618,6 +631,7 @@ const QuickPanelContainer = styled.div<{
$pageSize: number $pageSize: number
$selectedColor: string $selectedColor: string
$selectedColorHover: string $selectedColorHover: string
$collapsed?: boolean
}>` }>`
--focused-color: rgba(0, 0, 0, 0.06); --focused-color: rgba(0, 0, 0, 0.06);
--selected-color: ${(props) => props.$selectedColor}; --selected-color: ${(props) => props.$selectedColor};
@ -636,8 +650,8 @@ const QuickPanelContainer = styled.div<{
pointer-events: none; pointer-events: none;
&.visible { &.visible {
pointer-events: auto; pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')};
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px; max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px;
} }
body[theme-mode='dark'] & { body[theme-mode='dark'] & {
--focused-color: rgba(255, 255, 255, 0.1); --focused-color: rgba(255, 255, 255, 0.1);

View File

@ -206,8 +206,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
gap: 5px; gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')}; padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')}; padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
-webkit-app-region: drag;
height: var(--navbar-height); 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 }>` const Tab = styled.div<{ active?: boolean }>`
@ -220,7 +228,6 @@ const Tab = styled.div<{ active?: boolean }>`
border-radius: var(--list-item-border-radius); border-radius: var(--list-item-border-radius);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
-webkit-app-region: none;
height: 30px; height: 30px;
min-width: 90px; min-width: 90px;
transition: background 0.2s; transition: background 0.2s;
@ -273,7 +280,6 @@ const AddTabButton = styled.div`
height: 30px; height: 30px;
cursor: pointer; cursor: pointer;
color: var(--color-text-2); color: var(--color-text-2);
-webkit-app-region: none;
border-radius: var(--list-item-border-radius); border-radius: var(--list-item-border-radius);
&.active { &.active {
background: var(--color-list-item); background: var(--color-list-item);
@ -298,7 +304,6 @@ const ThemeButton = styled.div`
height: 30px; height: 30px;
cursor: pointer; cursor: pointer;
color: var(--color-text); color: var(--color-text);
-webkit-app-region: none;
&:hover { &:hover {
background: var(--color-list-item); background: var(--color-list-item);
@ -314,7 +319,6 @@ const SettingsButton = styled.div<{ $active: boolean }>`
height: 30px; height: 30px;
cursor: pointer; cursor: pointer;
color: var(--color-text); color: var(--color-text);
-webkit-app-region: none;
border-radius: 8px; border-radius: 8px;
background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')}; background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')};
&:hover { &:hover {

View 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>
)
}

View File

@ -1,8 +1,9 @@
import { EmbeddingTag } from './EmbeddingTag' import { EmbeddingTag } from './EmbeddingTag'
import { FreeTag } from './FreeTag'
import { ReasoningTag } from './ReasoningTag' import { ReasoningTag } from './ReasoningTag'
import { RerankerTag } from './RerankerTag' import { RerankerTag } from './RerankerTag'
import { ToolsCallingTag } from './ToolsCallingTag' import { ToolsCallingTag } from './ToolsCallingTag'
import { VisionTag } from './VisionTag' import { VisionTag } from './VisionTag'
import { WebSearchTag } from './WebSearchTag' import { WebSearchTag } from './WebSearchTag'
export { EmbeddingTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag } export { EmbeddingTag, FreeTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag }

View File

@ -1,4 +1,4 @@
import { loggerService } from '@logger' // import { loggerService } from '@logger'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer' import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit' import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts' import { useShortcuts } from '@renderer/hooks/useShortcuts'
@ -26,7 +26,7 @@ type ElementItem = {
element: React.FC | React.ReactNode element: React.FC | React.ReactNode
} }
const logger = loggerService.withContext('TopView') // const logger = loggerService.withContext('TopView')
const TopViewContainer: React.FC<Props> = ({ children }) => { const TopViewContainer: React.FC<Props> = ({ children }) => {
const [elements, setElements] = useState<ElementItem[]>([]) const [elements, setElements] = useState<ElementItem[]>([])
@ -80,7 +80,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
logger.debug('keydown', e) // logger.debug('keydown', e)
if (!enableQuitFullScreen) return if (!enableQuitFullScreen) return
if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) { if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {

View 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));
}
}
`

View 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

View 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}
/>
)
}

View File

@ -0,0 +1,3 @@
export { default as Sortable } from './Sortable'
export * from './useDndReorder'
export * from './useDndState'

View 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 }
}

View 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
}
}

View 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']
}

View File

@ -150,6 +150,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
import NomicLogo from '@renderer/assets/images/providers/nomic.png' import NomicLogo from '@renderer/assets/images/providers/nomic.png'
import { getProviderByModel } from '@renderer/services/AssistantService' import { getProviderByModel } from '@renderer/services/AssistantService'
import { import {
isSystemProviderId,
Model, Model,
ReasoningEffortConfig, ReasoningEffortConfig,
SystemProviderId, SystemProviderId,
@ -290,6 +291,7 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
) )
// 模型类型到支持的reasoning_effort的映射表 // 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
default: ['low', 'medium', 'high'] as const, default: ['low', 'medium', 'high'] as const,
o: ['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, doubao_no_auto: ['high'] as const,
hunyuan: ['auto'] as const, hunyuan: ['auto'] as const,
zhipu: ['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 } 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, doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const, hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] 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 } as const
export const getThinkModelType = (model: Model): ThinkingModelType => { export const getThinkModelType = (model: Model): ThinkingModelType => {
@ -350,11 +354,12 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
} else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan' } else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity' else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu' else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
else if (isDeepSeekHybridInferenceModel(model)) thinkingModelType = 'deepseek_hybrid'
return thinkingModelType return thinkingModelType
} }
export function isFunctionCallingModel(model?: Model): boolean { export function isFunctionCallingModel(model?: Model): boolean {
if (!model || isEmbeddingModel(model) || isRerankModel(model)) { if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) {
return false return false
} }
@ -372,11 +377,21 @@ export function isFunctionCallingModel(model?: Model): boolean {
return FUNCTION_CALLING_REGEX.test(modelId) || FUNCTION_CALLING_REGEX.test(model.name) 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 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 return true
} }
@ -1401,7 +1416,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
dashscope: [ dashscope: [
{ id: 'qwen-vl-plus', name: 'qwen-vl-plus', provider: 'dashscope', group: 'qwen-vl', owned_by: 'system' }, { 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-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-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' } { 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 = [ export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation', 'gemini-2.0-flash-preview-image-generation',
'gemini-2.5-flash-image-preview',
'grok-2-image-1212', 'grok-2-image-1212',
'grok-2-image', 'grok-2-image',
'grok-2-image-latest', 'grok-2-image-latest',
@ -2627,6 +2643,13 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
return false 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 ( return (
isSupportedThinkingTokenGeminiModel(model) || isSupportedThinkingTokenGeminiModel(model) ||
isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenQwenModel(model) ||
@ -2701,7 +2724,14 @@ export function isGeminiReasoningModel(model?: Model): boolean {
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => { export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/') 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推理模型 */ /** 是否为Qwen推理模型 */
@ -2764,7 +2794,9 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
'qwen-turbo-0428', 'qwen-turbo-0428',
'qwen-turbo-2025-04-28', 'qwen-turbo-2025-04-28',
'qwen-turbo-0715', 'qwen-turbo-0715',
'qwen-turbo-2025-07-15' 'qwen-turbo-2025-07-15',
'qwen-flash',
'qwen-flash-2025-07-28'
].includes(modelId) ].includes(modelId)
} }
@ -2838,6 +2870,15 @@ export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
return modelId.includes('glm-4.5') 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 => { export const isZhipuReasoningModel = (model?: Model): boolean => {
if (!model) { if (!model) {
return false return false
@ -2870,6 +2911,8 @@ export function isReasoningModel(model?: Model): boolean {
REASONING_REGEX.test(modelId) || REASONING_REGEX.test(modelId) ||
REASONING_REGEX.test(model.name) || REASONING_REGEX.test(model.name) ||
isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenDoubaoModel(model) ||
isDeepSeekHybridInferenceModel(model) ||
isDeepSeekHybridInferenceModel({ ...model, id: model.name }) ||
false false
) )
} }
@ -2884,6 +2927,7 @@ export function isReasoningModel(model?: Model): boolean {
isPerplexityReasoningModel(model) || isPerplexityReasoningModel(model) ||
isZhipuReasoningModel(model) || isZhipuReasoningModel(model) ||
isStepReasoningModel(model) || isStepReasoningModel(model) ||
isDeepSeekHybridInferenceModel(model) ||
modelId.includes('magistral') || modelId.includes('magistral') ||
modelId.includes('minimax-m1') || modelId.includes('minimax-m1') ||
modelId.includes('pangu-pro-moe') modelId.includes('pangu-pro-moe')
@ -2909,7 +2953,11 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean {
return true return true
} }
if (isOpenAIReasoningModel(model) || isOpenAIChatCompletionOnlyModel(model) || isQwenMTModel(model)) { if (
(isOpenAIReasoningModel(model) && !isOpenAIOpenWeightModel(model)) ||
isOpenAIChatCompletionOnlyModel(model) ||
isQwenMTModel(model)
) {
return true return true
} }
@ -2917,7 +2965,7 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean {
} }
export function isWebSearchModel(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 return false
} }
@ -2988,7 +3036,7 @@ export function isWebSearchModel(model: Model): boolean {
} }
if (provider.id === 'dashscope') { 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 // matches id like qwen-max-0919, qwen-max-latest
return models.some((i) => modelId.startsWith(i)) return models.some((i) => modelId.startsWith(i))
} }
@ -3004,6 +3052,26 @@ export function isWebSearchModel(model: Model): boolean {
return false 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 { export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
if (!model) { if (!model) {
return false 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 }, 'qwen3-0\\.6b$': { min: 0, max: 30_720 },
'qwen-plus.*$': { min: 0, max: 38_912 }, 'qwen-plus.*$': { min: 0, max: 38_912 },
'qwen-turbo.*$': { 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 }, 'qwen3-.*$': { min: 1024, max: 38_912 },
// Claude models // Claude models
@ -3238,3 +3307,16 @@ export const isGPT5SeriesModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id) const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gpt-5') 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

View 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>

View File

@ -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 = {}

View File

@ -166,7 +166,7 @@ export const SEARCH_SUMMARY_PROMPT = `
</knowledge> </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: \` Rephrased question: \`
<websearch> <websearch>
<question> <question>
@ -279,7 +279,7 @@ export const SEARCH_SUMMARY_PROMPT_WEB_ONLY = `
</websearch> </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: \` Rephrased question: \`
<websearch> <websearch>
<question> <question>
@ -374,7 +374,7 @@ export const SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY = `
</knowledge> </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: \` Rephrased question: \`
<knowledge> <knowledge>
<rewrite> <rewrite>

View File

@ -38,7 +38,6 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png' import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png' import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
import Ph8ProviderLogo from '@renderer/assets/images/providers/ph8.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 PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp' import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
@ -57,6 +56,7 @@ import {
isSystemProvider, isSystemProvider,
OpenAIServiceTiers, OpenAIServiceTiers,
Provider, Provider,
ProviderType,
SystemProvider, SystemProvider,
SystemProviderId SystemProviderId
} from '@renderer/types' } 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) 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, ph8: Ph8ProviderLogo,
'302ai': Ai302ProviderLogo, '302ai': Ai302ProviderLogo,
openai: OpenAiProviderLogo, openai: OpenAiProviderLogo,
@ -649,7 +649,7 @@ const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
vertexai: VertexAIProviderLogo, vertexai: VertexAIProviderLogo,
'new-api': NewAPIProviderLogo, 'new-api': NewAPIProviderLogo,
'aws-bedrock': AwsProviderLogo, 'aws-bedrock': AwsProviderLogo,
poe: PoeProviderLogo poe: 'svg' // use svg icon component
} as const } as const
export function getProviderLogo(providerId: string) { 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. * 使 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)) (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)
}

View 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']

View File

@ -20,7 +20,6 @@ interface CodeStyleContextType {
activeShikiTheme: string activeShikiTheme: string
isShikiThemeDark: boolean isShikiThemeDark: boolean
activeCmTheme: any activeCmTheme: any
languageMap: Record<string, string>
} }
const defaultCodeStyleContext: CodeStyleContextType = { const defaultCodeStyleContext: CodeStyleContextType = {
@ -33,8 +32,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
themeNames: ['auto'], themeNames: ['auto'],
activeShikiTheme: 'auto', activeShikiTheme: 'auto',
isShikiThemeDark: false, isShikiThemeDark: false,
activeCmTheme: null, activeCmTheme: null
languageMap: {}
} }
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext) const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
@ -93,8 +91,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
return cmThemes[themeName as keyof typeof cmThemes] || themeName return cmThemes[themeName as keyof typeof cmThemes] || themeName
}, [theme, codeEditor, themeNames]) }, [theme, codeEditor, themeNames])
// 一些语言的别名 // 自定义 shiki 语言别名
const languageMap = useMemo(() => { const languageAliases = useMemo(() => {
return { return {
bash: 'shell', bash: 'shell',
'objective-c++': 'objective-cpp', 'objective-c++': 'objective-cpp',
@ -114,10 +112,10 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
// 流式代码高亮,返回已高亮的 token lines // 流式代码高亮,返回已高亮的 token lines
const highlightCodeChunk = useCallback( const highlightCodeChunk = useCallback(
async (trunk: string, language: string, callerId: string) => { 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) 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( const highlightStreamingCode = useCallback(
async (fullContent: string, language: string, callerId: string) => { 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) return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId)
}, },
[activeShikiTheme, languageMap] [activeShikiTheme, languageAliases]
) )
// 获取 Shiki pre 标签属性 // 获取 Shiki pre 标签属性
const getShikiPreProperties = useCallback( const getShikiPreProperties = useCallback(
async (language: string) => { 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) return shikiStreamService.getShikiPreProperties(normalizedLang, activeShikiTheme)
}, },
[activeShikiTheme, languageMap] [activeShikiTheme, languageAliases]
) )
const highlightCode = useCallback( const highlightCode = useCallback(
@ -176,8 +174,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
themeNames, themeNames,
activeShikiTheme, activeShikiTheme,
isShikiThemeDark, isShikiThemeDark,
activeCmTheme, activeCmTheme
languageMap
}), }),
[ [
highlightCodeChunk, highlightCodeChunk,
@ -189,8 +186,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
themeNames, themeNames,
activeShikiTheme, activeShikiTheme,
isShikiThemeDark, isShikiThemeDark,
activeCmTheme, activeCmTheme
languageMap
] ]
) )

View File

@ -66,7 +66,7 @@ db.version(6).stores({
// --- NEW VERSION 7 --- // --- NEW VERSION 7 ---
db.version(7) db.version(7)
.stores({ .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', files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id', // Correct index for topics topics: '&id', // Correct index for topics
settings: '&id, value', settings: '&id, value',
@ -79,7 +79,7 @@ db.version(7)
db.version(8) db.version(8)
.stores({ .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', files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id', // Correct index for topics topics: '&id', // Correct index for topics
settings: '&id, value', settings: '&id, value',
@ -91,7 +91,7 @@ db.version(8)
.upgrade((tx) => upgradeToV8(tx)) .upgrade((tx) => upgradeToV8(tx))
db.version(9).stores({ 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', files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id', // Correct index for topics topics: '&id', // Correct index for topics
settings: '&id, value', settings: '&id, value',

View File

@ -3,7 +3,8 @@ import {
getThinkModelType, getThinkModelType,
isSupportedReasoningEffortModel, isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel, isSupportedThinkingTokenModel,
MODEL_SUPPORTED_OPTIONS MODEL_SUPPORTED_OPTIONS,
MODEL_SUPPORTED_REASONING_EFFORT
} from '@renderer/config/models' } from '@renderer/config/models'
import { db } from '@renderer/databases' import { db } from '@renderer/databases'
import { getDefaultTopic } from '@renderer/services/AssistantService' import { getDefaultTopic } from '@renderer/services/AssistantService'
@ -24,9 +25,9 @@ import {
updateTopics updateTopics
} from '@renderer/store/assistants' } from '@renderer/store/assistants'
import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm' 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 { uuid } from '@renderer/utils'
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { TopicManager } from './useTopic' import { TopicManager } from './useTopic'
@ -84,6 +85,12 @@ export function useAssistant(id: string) {
const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model]) const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model])
const settingsRef = useRef(assistant?.settings)
useEffect(() => {
settingsRef.current = assistant.settings
}, [assistant?.settings])
const updateAssistantSettings = useCallback( const updateAssistantSettings = useCallback(
(settings: Partial<AssistantSettings>) => { (settings: Partial<AssistantSettings>) => {
assistant?.id && dispatch(_updateAssistantSettings({ assistantId: assistant.id, settings })) assistant?.id && dispatch(_updateAssistantSettings({ assistantId: assistant.id, settings }))
@ -93,28 +100,46 @@ export function useAssistant(id: string) {
// 当model变化时同步reasoning effort为模型支持的合法值 // 当model变化时同步reasoning effort为模型支持的合法值
useEffect(() => { useEffect(() => {
if (assistant?.settings) { const settings = settingsRef.current
if (settings) {
const currentReasoningEffort = settings.reasoning_effort
if (isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) { if (isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) {
const currentReasoningEffort = assistant?.settings?.reasoning_effort const modelType = getThinkModelType(model)
const supportedOptions = MODEL_SUPPORTED_OPTIONS[getThinkModelType(model)] const supportedOptions = MODEL_SUPPORTED_OPTIONS[modelType]
if (currentReasoningEffort && !supportedOptions.includes(currentReasoningEffort)) { if (supportedOptions.every((option) => option !== currentReasoningEffort)) {
// 选项不支持时,回退到第一个支持的值 const cache = settings.reasoning_effort_cache
// 注意这里假设可用的options不会为空 let fallbackOption: ThinkingOption
const fallbackOption = supportedOptions[0]
// 选项不支持时,首先尝试恢复到上次使用的值
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({ updateAssistantSettings({
reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption, reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption,
qwenThinkMode: fallbackOption === 'off' reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption,
qwenThinkMode: fallbackOption === 'off' ? undefined : true
}) })
} else {
// 对于支持的选项, 不再更新 cache.
} }
} else { } else {
// 切换到非思考模型时保留cache
updateAssistantSettings({ updateAssistantSettings({
reasoning_effort: undefined, reasoning_effort: undefined,
reasoning_effort_cache: currentReasoningEffort,
qwenThinkMode: undefined qwenThinkMode: undefined
}) })
} }
} }
}, [assistant?.settings, model, updateAssistantSettings]) }, [model, updateAssistantSettings])
return { return {
assistant: assistantWithModel, assistant: assistantWithModel,

View File

@ -11,6 +11,7 @@ import {
setSelectedModel setSelectedModel
} from '@renderer/store/codeTools' } from '@renderer/store/codeTools'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { codeTools } from '@shared/config/constant'
import { useCallback } from 'react' import { useCallback } from 'react'
export const useCodeTools = () => { export const useCodeTools = () => {
@ -20,7 +21,7 @@ export const useCodeTools = () => {
// 设置选择的 CLI 工具 // 设置选择的 CLI 工具
const setCliTool = useCallback( const setCliTool = useCallback(
(tool: string) => { (tool: codeTools) => {
dispatch(setSelectedCliTool(tool)) dispatch(setSelectedCliTool(tool))
}, },
[dispatch] [dispatch]

View 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 }
}

View 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
}
}

View File

@ -26,13 +26,22 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
const [originalValue, setOriginalValue] = useState('') const [originalValue, setOriginalValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const editTimerRef = useRef<NodeJS.Timeout>(undefined)
useEffect(() => {
return () => {
clearTimeout(editTimerRef.current)
}
}, [])
const startEdit = useCallback( const startEdit = useCallback(
(initialValue: string) => { (initialValue: string) => {
setIsEditing(true) setIsEditing(true)
setEditValue(initialValue) setEditValue(initialValue)
setOriginalValue(initialValue) setOriginalValue(initialValue)
setTimeout(() => { clearTimeout(editTimerRef.current)
editTimerRef.current = setTimeout(() => {
inputRef.current?.focus() inputRef.current?.focus()
if (autoSelectOnStart) { if (autoSelectOnStart) {
inputRef.current?.select() inputRef.current?.select()

View File

@ -20,7 +20,7 @@ import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@r
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { useAgents } from './useAgents' import { useAgents } from './useAgents'
@ -29,6 +29,7 @@ import { useAssistants } from './useAssistant'
export const useKnowledge = (baseId: string) => { export const useKnowledge = (baseId: string) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId)) const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId))
const checkTimerRef = useRef<NodeJS.Timeout>(undefined)
// 重命名知识库 // 重命名知识库
const renameKnowledgeBase = (name: string) => { const renameKnowledgeBase = (name: string) => {
@ -40,34 +41,46 @@ export const useKnowledge = (baseId: string) => {
dispatch(updateBase(base)) dispatch(updateBase(base))
} }
useEffect(() => {
return () => {
clearTimeout(checkTimerRef.current)
}
}, [])
// 检查知识库
const checkAllBases = () => {
clearTimeout(checkTimerRef.current)
checkTimerRef.current = setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 批量添加文件 // 批量添加文件
const addFiles = (files: FileMetadata[]) => { const addFiles = (files: FileMetadata[]) => {
dispatch(addFilesThunk(baseId, files)) dispatch(addFilesThunk(baseId, files))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) checkAllBases()
} }
// 添加笔记 // 添加笔记
const addNote = async (content: string) => { const addNote = async (content: string) => {
await dispatch(addNoteThunk(baseId, content)) await dispatch(addNoteThunk(baseId, content))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) checkAllBases()
} }
// 添加URL // 添加URL
const addUrl = (url: string) => { const addUrl = (url: string) => {
dispatch(addItemThunk(baseId, 'url', url)) dispatch(addItemThunk(baseId, 'url', url))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) checkAllBases()
} }
// 添加 Sitemap // 添加 Sitemap
const addSitemap = (url: string) => { const addSitemap = (url: string) => {
dispatch(addItemThunk(baseId, 'sitemap', url)) dispatch(addItemThunk(baseId, 'sitemap', url))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) checkAllBases()
} }
// Add directory support // Add directory support
const addDirectory = (path: string) => { const addDirectory = (path: string) => {
dispatch(addItemThunk(baseId, 'directory', path)) dispatch(addItemThunk(baseId, 'directory', path))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) checkAllBases()
} }
// 更新笔记内容 // 更新笔记内容
const updateNoteContent = async (noteId: string, content: string) => { const updateNoteContent = async (noteId: string, content: string) => {
@ -133,7 +146,7 @@ export const useKnowledge = (baseId: string) => {
uniqueId: undefined, uniqueId: undefined,
updated_at: Date.now() 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}`) throw new Error(`Failed to migrate files ${files}: ${error}`)
} }
setTimeout(() => KnowledgeQueue.checkAllBases(), 0) checkAllBases()
} }
const fileItems = base?.items.filter((item) => item.type === 'file') || [] const fileItems = base?.items.filter((item) => item.type === 'file') || []

View 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