diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 2c15302bee..82058ec2a9 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -45,8 +45,14 @@ jobs: - name: Install Dependencies run: yarn install - - name: Build Check - run: yarn build:check - - name: Lint Check run: yarn test:lint + + - name: Type Check + run: yarn typecheck + + - name: i18n Check + run: yarn check:i18n + + - name: Test + run: yarn test diff --git a/.vscode/launch.json b/.vscode/launch.json index 1519379f6e..25f24dac61 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,39 +1,40 @@ { - "version": "0.2.0", + "compounds": [ + { + "configurations": ["Debug Main Process", "Debug Renderer Process"], + "name": "Debug All", + "presentation": { + "order": 1 + } + } + ], "configurations": [ { - "name": "Debug Main Process", - "type": "node", - "request": "launch", "cwd": "${workspaceRoot}", - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", - "windows": { - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" - }, - "runtimeArgs": ["--inspect", "--sourcemap"], "env": { "REMOTE_DEBUGGING_PORT": "9222" + }, + "envFile": "${workspaceFolder}/.env", + "name": "Debug Main Process", + "request": "launch", + "runtimeArgs": ["--inspect", "--sourcemap"], + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", + "type": "node", + "windows": { + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" } }, { "name": "Debug Renderer Process", "port": 9222, - "request": "attach", - "type": "chrome", - "webRoot": "${workspaceFolder}/src/renderer", - "timeout": 3000000, "presentation": { "hidden": true - } + }, + "request": "attach", + "timeout": 3000000, + "type": "chrome", + "webRoot": "${workspaceFolder}/src/renderer" } ], - "compounds": [ - { - "name": "Debug All", - "configurations": ["Debug Main Process", "Debug Renderer Process"], - "presentation": { - "order": 1 - } - } - ] + "version": "0.2.0" } diff --git a/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch b/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch new file mode 100644 index 0000000000..0cb156ee99 --- /dev/null +++ b/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch @@ -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"; ++ ++/// ++ + declare namespace Tesseract { +- function createScheduler(): Scheduler +- function createWorker(langs?: string | string[] | Lang[], oem?: OEM, options?: Partial, config?: string | Partial): Promise +- function setLogging(logging: boolean): void +- function recognize(image: ImageLike, langs?: string, options?: Partial): Promise +- function detect(image: ImageLike, options?: Partial): any ++ function createScheduler(): Scheduler; ++ function createWorker( ++ langs?: LanguageCode | LanguageCode[] | Lang[], ++ oem?: OEM, ++ options?: Partial, ++ config?: string | Partial ++ ): Promise; ++ function setLogging(logging: boolean): void; ++ function recognize( ++ image: ImageLike, ++ langs?: LanguageCode, ++ options?: Partial ++ ): Promise; ++ function detect(image: ImageLike, options?: Partial): 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): Promise +- addJob(action: 'detect', ...args: Parameters): Promise +- terminate(): Promise +- getQueueLen(): number +- getNumWorkers(): number ++ addWorker(worker: Worker): string; ++ addJob( ++ action: "recognize", ++ ...args: Parameters ++ ): Promise; ++ addJob( ++ action: "detect", ++ ...args: Parameters ++ ): Promise; ++ terminate(): Promise; ++ getQueueLen(): number; ++ getNumWorkers(): number; + } + + interface Worker { +- load(jobId?: string): Promise +- writeText(path: string, text: string, jobId?: string): Promise +- readText(path: string, jobId?: string): Promise +- removeText(path: string, jobId?: string): Promise +- FS(method: string, args: any[], jobId?: string): Promise +- reinitialize(langs?: string | Lang[], oem?: OEM, config?: string | Partial, jobId?: string): Promise +- setParameters(params: Partial, jobId?: string): Promise +- getImage(type: imageType): string +- recognize(image: ImageLike, options?: Partial, output?: Partial, jobId?: string): Promise +- detect(image: ImageLike, jobId?: string): Promise +- terminate(jobId?: string): Promise ++ load(jobId?: string): Promise; ++ writeText( ++ path: string, ++ text: string, ++ jobId?: string ++ ): Promise; ++ readText(path: string, jobId?: string): Promise; ++ removeText(path: string, jobId?: string): Promise; ++ FS(method: string, args: any[], jobId?: string): Promise; ++ reinitialize( ++ langs?: string | Lang[], ++ oem?: OEM, ++ config?: string | Partial, ++ jobId?: string ++ ): Promise; ++ setParameters( ++ params: Partial, ++ jobId?: string ++ ): Promise; ++ getImage(type: imageType): string; ++ recognize( ++ image: ImageLike, ++ options?: Partial, ++ output?: Partial, ++ jobId?: string ++ ): Promise; ++ detect(image: ImageLike, jobId?: string): Promise; ++ terminate(jobId?: string): Promise; + } + + 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 { diff --git a/package.json b/package.json index 46566cc422..37565b76f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.5.7-rc.1", + "version": "1.5.7-rc.2", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -79,7 +79,9 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", - "selection-hook": "^1.0.9", + "selection-hook": "^1.0.11", + "sharp": "^0.34.3", + "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "turndown": "7.2.0" }, "devDependencies": { @@ -104,7 +106,10 @@ "@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31", - "@codemirror/view": "^6.0.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", @@ -146,7 +151,7 @@ "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", - "@types/node": "^18.19.9", + "@types/node": "^22.17.1", "@types/pako": "^1.0.2", "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", @@ -154,9 +159,9 @@ "@types/react-transition-group": "^4.4.12", "@types/tinycolor2": "^1", "@types/word-extractor": "^1", - "@uiw/codemirror-extensions-langs": "^4.23.14", - "@uiw/codemirror-themes-all": "^4.23.14", - "@uiw/react-codemirror": "^4.23.14", + "@uiw/codemirror-extensions-langs": "^4.25.1", + "@uiw/codemirror-themes-all": "^4.25.1", + "@uiw/react-codemirror": "^4.25.1", "@vitejs/plugin-react-swc": "^3.9.0", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3.2.4", @@ -183,7 +188,7 @@ "dotenv-cli": "^7.4.2", "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.2", - "electron": "37.2.3", + "electron": "37.3.1", "electron-builder": "26.0.15", "electron-devtools-installer": "^3.2.0", "electron-store": "^8.2.0", @@ -207,6 +212,7 @@ "husky": "^9.1.7", "i18next": "^23.11.5", "iconv-lite": "^0.6.3", + "isbinaryfile": "5.0.4", "jaison": "^2.0.2", "jest-styled-components": "^7.2.0", "linguist-languages": "^8.0.0", @@ -230,6 +236,7 @@ "proxy-agent": "^6.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-error-boundary": "^6.0.0", "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.2", "react-infinite-scroll-component": "^6.1.0", @@ -277,21 +284,25 @@ "zod": "^3.25.74" }, "resolutions": { - "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", + "@codemirror/language": "6.11.3", + "@codemirror/lint": "6.8.5", + "@codemirror/view": "6.38.1", + "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch", "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", "@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", - "libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch", - "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", "app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch", "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch", - "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch", - "node-abi": "4.12.0", - "undici": "6.21.2", - "vite": "npm:rolldown-vite@latest", "atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch", "file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch", + "libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch", + "node-abi": "4.12.0", "openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", - "openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch" + "openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", + "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", + "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", + "undici": "6.21.2", + "vite": "npm:rolldown-vite@latest", + "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 78044e3a3e..992a372384 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -156,7 +156,9 @@ export enum IpcChannel { File_Base64File = 'file:base64File', File_GetPdfInfo = 'file:getPdfInfo', Fs_Read = 'fs:read', + Fs_ReadText = 'fs:readText', File_OpenWithRelativePath = 'file:openWithRelativePath', + File_IsTextFile = 'file:isTextFile', // file service FileService_Upload = 'file-service:upload', @@ -306,5 +308,8 @@ export enum IpcChannel { TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage', // CodeTools - CodeTools_Run = 'code-tools:run' + CodeTools_Run = 'code-tools:run', + + // OCR + OCR_ocr = 'ocr:ocr' } diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 17304f357f..82b78459a5 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -211,3 +211,10 @@ export const MIN_WINDOW_WIDTH = 1080 export const SECOND_MIN_WINDOW_WIDTH = 520 export const MIN_WINDOW_HEIGHT = 600 export const defaultByPassRules = 'localhost,127.0.0.1,::1' + +export enum codeTools { + qwenCode = 'qwen-code', + claudeCode = 'claude-code', + geminiCli = 'gemini-cli', + openaiCodex = 'openai-codex' +} diff --git a/src/main/config.ts b/src/main/config.ts index c676823b89..5a6f667d18 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1,7 +1,7 @@ +import { isDev, isWin } from '@main/constant' import { app } from 'electron' import { getDataPath } from './utils' -const isDev = process.env.NODE_ENV === 'development' if (isDev) { app.setPath('userData', app.getPath('userData') + 'Dev') @@ -11,7 +11,7 @@ export const DATA_PATH = getDataPath() export const titleBarOverlayDark = { height: 42, - color: 'rgba(255,255,255,0)', + color: isWin ? 'rgba(0,0,0,0.02)' : 'rgba(255,255,255,0)', symbolColor: '#fff' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7c307741b7..f83b99d947 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -31,6 +31,7 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic import NotificationService from './services/NotificationService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' +import { ocrService } from './services/ocr/OcrService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' import { FileServiceManager } from './services/remotefile/FileServiceManager' @@ -72,7 +73,7 @@ const dxtService = new DxtService() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater() - const notificationService = new NotificationService(mainWindow) + const notificationService = new NotificationService() // Initialize Python service with main window pythonService.setMainWindow(mainWindow) @@ -445,6 +446,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) + ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager)) // file service ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => { @@ -469,6 +471,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // fs ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService)) + ipcMain.handle(IpcChannel.Fs_ReadText, FileService.readTextFileWithAutoEncoding.bind(FileService)) // export ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService)) @@ -710,6 +713,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // CodeTools ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) + // OCR + ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters) => ocrService.ocr(...args)) + // Preference handlers PreferenceService.registerIpcHandler() } diff --git a/src/main/knowledge/preprocess/BasePreprocessProvider.ts b/src/main/knowledge/preprocess/BasePreprocessProvider.ts index 7981e6f139..daf9901498 100644 --- a/src/main/knowledge/preprocess/BasePreprocessProvider.ts +++ b/src/main/knowledge/preprocess/BasePreprocessProvider.ts @@ -91,7 +91,7 @@ export default abstract class BasePreprocessProvider { } public async readPdf(buffer: Buffer) { - const pdfDoc = await PDFDocument.load(buffer) + const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true }) return { numPages: pdfDoc.getPageCount() } diff --git a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts index 834ff2f27e..6708e8f938 100644 --- a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts @@ -201,20 +201,14 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { */ private async putFile(filePath: string, url: string): Promise { try { - // 获取文件大小用于设置 Content-Length - const stats = await fs.promises.stat(filePath) - const fileSize = stats.size - // 创建可读流 const fileStream = fs.createReadStream(filePath) const response = await net.fetch(url, { method: 'PUT', body: fileStream as any, // TypeScript 类型转换,net.fetch 支持 ReadableStream - headers: { - 'Content-Length': fileSize.toString() - } - }) + duplex: 'half' + } as any) // TypeScript 类型转换,net.fetch 需要 duplex 选项 if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index fe1269cec2..46d3bb87d2 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -1,5 +1,6 @@ import { loggerService } from '@logger' import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types' import BraveSearchServer from './brave-search' import DifyKnowledgeServer from './dify-knowledge' @@ -11,30 +12,34 @@ import ThinkingServer from './sequentialthinking' const logger = loggerService.withContext('MCPFactory') -export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record = {}): Server { +export function createInMemoryMCPServer( + name: BuiltinMCPServerName, + args: string[] = [], + envs: Record = {} +): Server { logger.debug(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`) switch (name) { - case '@cherry/memory': { + case BuiltinMCPServerNames.memory: { const envPath = envs.MEMORY_FILE_PATH return new MemoryServer(envPath).server } - case '@cherry/sequentialthinking': { + case BuiltinMCPServerNames.sequentialThinking: { return new ThinkingServer().server } - case '@cherry/brave-search': { + case BuiltinMCPServerNames.braveSearch: { return new BraveSearchServer(envs.BRAVE_API_KEY).server } - case '@cherry/fetch': { + case BuiltinMCPServerNames.fetch: { return new FetchServer().server } - case '@cherry/filesystem': { + case BuiltinMCPServerNames.filesystem: { return new FileSystemServer(args).server } - case '@cherry/dify-knowledge': { + case BuiltinMCPServerNames.difyKnowledge: { const difyKey = envs.DIFY_KEY return new DifyKnowledgeServer(difyKey, args).server } - case '@cherry/python': { + case BuiltinMCPServerNames.python: { return new PythonServer().server } default: diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 5fa2ce87c2..6cc8a41b05 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -7,6 +7,7 @@ import { isWin } from '@main/constant' import { removeEnvProxy } from '@main/utils' import { isUserInChina } from '@main/utils/ipService' import { getBinaryName } from '@main/utils/process' +import { codeTools } from '@shared/config/constant' import { spawn } from 'child_process' import { promisify } from 'util' @@ -41,23 +42,33 @@ class CodeToolsService { } public async getPackageName(cliTool: string) { - if (cliTool === 'claude-code') { - return '@anthropic-ai/claude-code' + switch (cliTool) { + case codeTools.claudeCode: + return '@anthropic-ai/claude-code' + case codeTools.geminiCli: + return '@google/gemini-cli' + case codeTools.openaiCodex: + return '@openai/codex' + case codeTools.qwenCode: + return '@qwen-code/qwen-code' + default: + throw new Error(`Unsupported CLI tool: ${cliTool}`) } - if (cliTool === 'gemini-cli') { - return '@google/gemini-cli' - } - return '@qwen-code/qwen-code' } public async getCliExecutableName(cliTool: string) { - if (cliTool === 'claude-code') { - return 'claude' + switch (cliTool) { + case codeTools.claudeCode: + return 'claude' + case codeTools.geminiCli: + return 'gemini' + case codeTools.openaiCodex: + return 'codex' + case codeTools.qwenCode: + return 'qwen' + default: + throw new Error(`Unsupported CLI tool: ${cliTool}`) } - if (cliTool === 'gemini-cli') { - return 'gemini' - } - return 'qwen' } private async isPackageInstalled(cliTool: string): Promise { @@ -192,7 +203,7 @@ class CodeToolsService { ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` - const updateCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}` + const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}` logger.info(`Executing update command: ${updateCommand}`) await execAsync(updateCommand, { timeout: 60000 }) @@ -296,7 +307,7 @@ class CodeToolsService { } // Build command to execute - let baseCommand = isWin ? `${executablePath}` : `${bunPath} ${executablePath}` + let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"` const bunInstallPath = path.join(os.homedir(), '.cherrystudio') if (isInstalled) { @@ -326,8 +337,9 @@ class CodeToolsService { terminalArgs = [ '-e', `tell application "Terminal" + set newTab to do script "cd '${directory.replace(/'/g, "\\'")}' && clear" activate - do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}" + do script "${command.replace(/"/g, '\\"')}" in newTab end tell` ] break diff --git a/src/main/services/CopilotService.ts b/src/main/services/CopilotService.ts index f5c773a7cc..3efaafa737 100644 --- a/src/main/services/CopilotService.ts +++ b/src/main/services/CopilotService.ts @@ -1,9 +1,10 @@ import { loggerService } from '@logger' -import { net } from 'electron' -import { app, safeStorage } from 'electron' -import fs from 'fs/promises' +import { app, net, safeStorage } from 'electron' +import fs from 'fs' import path from 'path' +import { getConfigDir } from '../utils/file' + const logger = loggerService.withContext('CopilotService') // 配置常量,集中管理 @@ -28,7 +29,8 @@ const CONFIG = { GITHUB_DEVICE_CODE: 'https://github.com/login/device/code', GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token', COPILOT_TOKEN: 'https://api.github.com/copilot_internal/v2/token' - } + }, + TOKEN_FILE_NAME: '.copilot_token' } // 接口定义移到顶部,便于查阅 @@ -67,8 +69,20 @@ class CopilotService { private headers: Record constructor() { - this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token') - this.headers = { ...CONFIG.DEFAULT_HEADERS } + this.tokenFilePath = this.getTokenFilePath() + this.headers = { + ...CONFIG.DEFAULT_HEADERS, + accept: 'application/json', + 'user-agent': 'Visual Studio Code (desktop)' + } + } + + private getTokenFilePath = (): string => { + const oldTokenFilePath = path.join(app.getPath('userData'), CONFIG.TOKEN_FILE_NAME) + if (fs.existsSync(oldTokenFilePath)) { + return oldTokenFilePath + } + return path.join(getConfigDir(), CONFIG.TOKEN_FILE_NAME) } /** @@ -93,6 +107,7 @@ class CopilotService { 'Sec-Fetch-Site': 'none', 'Sec-Fetch-Mode': 'no-cors', 'Sec-Fetch-Dest': 'empty', + accept: 'application/json', authorization: `token ${token}` } }) @@ -204,7 +219,13 @@ class CopilotService { public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise => { try { const encryptedToken = safeStorage.encryptString(token) - await fs.writeFile(this.tokenFilePath, encryptedToken) + // 确保目录存在 + const dir = path.dirname(this.tokenFilePath) + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }) + } + + await fs.promises.writeFile(this.tokenFilePath, encryptedToken) } catch (error) { logger.error('Failed to save token:', error as Error) throw new CopilotServiceError('无法保存访问令牌', error) @@ -221,7 +242,7 @@ class CopilotService { try { this.updateHeaders(headers) - const encryptedToken = await fs.readFile(this.tokenFilePath) + const encryptedToken = await fs.promises.readFile(this.tokenFilePath) const access_token = safeStorage.decryptString(Buffer.from(encryptedToken)) const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, { @@ -249,8 +270,8 @@ class CopilotService { public logout = async (): Promise => { try { try { - await fs.access(this.tokenFilePath) - await fs.unlink(this.tokenFilePath) + await fs.promises.access(this.tokenFilePath) + await fs.promises.unlink(this.tokenFilePath) logger.debug('Successfully logged out from Copilot') } catch (error) { // 文件不存在不是错误,只是记录一下 diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index f5df9ed3f7..39a16713d7 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file' -import { documentExts, imageExts, MB } from '@shared/config/constant' +import { documentExts, imageExts, KB, MB } from '@shared/config/constant' import { FileMetadata } from '@types' +import chardet from 'chardet' import * as crypto from 'crypto' import { dialog, @@ -15,6 +16,7 @@ import { import * as fs from 'fs' import { writeFileSync } from 'fs' import { readFile } from 'fs/promises' +import { isBinaryFile } from 'isbinaryfile' import officeParser from 'officeparser' import * as path from 'path' import { PDFDocument } from 'pdf-lib' @@ -630,6 +632,34 @@ class FileStorage { public getFilePathById(file: FileMetadata): string { return path.join(this.storageDir, file.id + file.ext) } + + public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise => { + 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() diff --git a/src/main/services/FileSystemService.ts b/src/main/services/FileSystemService.ts index 47e897e15b..2cd0d5aeb6 100644 --- a/src/main/services/FileSystemService.ts +++ b/src/main/services/FileSystemService.ts @@ -1,3 +1,4 @@ +import { readTextFileWithAutoEncoding } from '@main/utils/file' import { TraceMethod } from '@mcp-trace/trace-core' import fs from 'fs/promises' @@ -8,4 +9,15 @@ export default class FileService { if (encoding) return fs.readFile(path, { encoding }) return fs.readFile(path) } + + /** + * 自动识别编码,读取文本文件 + * @param _ event + * @param pathOrUrl + * @throws 路径不存在时抛出错误 + */ + @TraceMethod({ spanName: 'readTextFileWithAutoEncoding', tag: 'FileService' }) + public static async readTextFileWithAutoEncoding(_: Electron.IpcMainInvokeEvent, path: string): Promise { + return readTextFileWithAutoEncoding(path) + } } diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index a7f907f65f..9e2c3f88d7 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -21,14 +21,22 @@ import { CancelledNotificationSchema, type GetPromptResult, LoggingMessageNotificationSchema, - ProgressNotificationSchema, PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ResourceUpdatedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' import { nanoid } from '@reduxjs/toolkit' -import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types' +import { + BuiltinMCPServerNames, + type GetResourceResponse, + isBuiltinMCPServer, + type MCPCallToolResponse, + type MCPPrompt, + type MCPResource, + type MCPServer, + type MCPTool +} from '@types' import { app, net } from 'electron' import { EventEmitter } from 'events' import { memoize } from 'lodash' @@ -163,7 +171,7 @@ class McpService { StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport > => { // Create appropriate transport based on configuration - if (server.type === 'inMemory') { + if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) { logger.debug(`Using in-memory transport for server: ${server.name}`) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() // start the in-memory server with the given name and environment variables @@ -432,15 +440,6 @@ class McpService { this.clearResourceCaches(serverKey) }) - // Set up progress notification handler - client.setNotificationHandler(ProgressNotificationSchema, async (notification) => { - logger.debug(`Progress notification received for server: ${server.name}`, notification.params) - const mainWindow = windowService.getMainWindow() - if (mainWindow) { - mainWindow.webContents.send('mcp-progress', notification.params.progress / (notification.params.total || 1)) - } - }) - // Set up cancelled notification handler client.setNotificationHandler(CancelledNotificationSchema, async (notification) => { logger.debug(`Operation cancelled for server: ${server.name}`, notification.params) @@ -629,6 +628,11 @@ class McpService { const result = await client.callTool({ name, arguments: args }, undefined, { onprogress: (process) => { logger.debug(`Progress: ${process.progress / (process.total || 1)}`) + logger.debug(`Progress notification received for server: ${server.name}`, process) + const mainWindow = windowService.getMainWindow() + if (mainWindow) { + mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1)) + } }, timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute, // 需要服务端支持: https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#timeouts diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts index 5ba0d82ce4..2ceb12ee40 100644 --- a/src/main/services/NotificationService.ts +++ b/src/main/services/NotificationService.ts @@ -1,14 +1,9 @@ -import { BrowserWindow, Notification as ElectronNotification } from 'electron' +import { Notification as ElectronNotification } from 'electron' import { Notification } from 'src/renderer/src/types/notification' +import { windowService } from './WindowService' + class NotificationService { - private window: BrowserWindow - - constructor(window: BrowserWindow) { - // Initialize the service - this.window = window - } - public async sendNotification(notification: Notification) { // 使用 Electron Notification API const electronNotification = new ElectronNotification({ @@ -17,8 +12,8 @@ class NotificationService { }) electronNotification.on('click', () => { - this.window.show() - this.window.webContents.send('notification-click', notification) + windowService.getMainWindow()?.show() + windowService.getMainWindow()?.webContents.send('notification-click', notification) }) electronNotification.show() diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index 620a6a5fef..860324bc62 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -11,14 +11,42 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher const logger = loggerService.withContext('ProxyManager') let byPassRules: string[] = [] -const isByPass = (hostname: string) => { +const isByPass = (url: string) => { if (byPassRules.length === 0) { return false } - return byPassRules.includes(hostname) -} + try { + const subjectUrlTokens = new URL(url) + for (const rule of byPassRules) { + const ruleMatch = rule.replace(/^(?\.)/, '*').match(/^(?.+?)(?::(?\d+))?$/) + if (!ruleMatch || !ruleMatch.groups) { + logger.warn('Failed to parse bypass rule:', { rule }) + continue + } + + if (!ruleMatch.groups.hostname) { + continue + } + + const hostnameIsMatch = subjectUrlTokens.hostname === ruleMatch.groups.hostname + + if ( + hostnameIsMatch && + (!ruleMatch.groups || + !ruleMatch.groups.port || + (subjectUrlTokens.port && subjectUrlTokens.port === ruleMatch.groups.port)) + ) { + return true + } + } + return false + } catch (error) { + logger.error('Failed to check bypass:', error as Error) + return false + } +} class SelectiveDispatcher extends Dispatcher { private proxyDispatcher: Dispatcher private directDispatcher: Dispatcher @@ -31,9 +59,7 @@ class SelectiveDispatcher extends Dispatcher { dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) { if (opts.origin) { - const url = new URL(opts.origin) - // 检查是否为 localhost 或本地地址 - if (isByPass(url.hostname)) { + if (isByPass(opts.origin.toString())) { return this.directDispatcher.dispatch(opts, handler) } } @@ -93,15 +119,20 @@ export class ProxyManager { // Set new interval this.systemProxyInterval = setInterval(async () => { const currentProxy = await getSystemProxy() - if (currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules) { + if ( + currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules && + currentProxy?.noProxy.join(',').toLowerCase() === this.config?.proxyBypassRules?.toLowerCase() + ) { return } - logger.info(`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}`) + logger.info( + `system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}, this.config.proxyBypassRules: ${this.config.proxyBypassRules}` + ) await this.configureProxy({ mode: 'system', proxyRules: currentProxy?.proxyUrl.toLowerCase(), - proxyBypassRules: undefined + proxyBypassRules: currentProxy?.noProxy.join(',') }) }, 1000 * 60) } @@ -151,6 +182,7 @@ export class ProxyManager { delete process.env.grpc_proxy delete process.env.http_proxy delete process.env.https_proxy + delete process.env.no_proxy delete process.env.SOCKS_PROXY delete process.env.ALL_PROXY @@ -162,6 +194,7 @@ export class ProxyManager { process.env.HTTPS_PROXY = url process.env.http_proxy = url process.env.https_proxy = url + process.env.no_proxy = byPassRules.join(',') if (url.startsWith('socks')) { process.env.SOCKS_PROXY = url @@ -229,8 +262,7 @@ export class ProxyManager { // filter localhost if (url) { - const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname - if (isByPass(hostname)) { + if (isByPass(url.toString())) { return originalMethod(url, options, callback) } } diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts index 95e9d8b1be..8a4e42099a 100644 --- a/src/main/services/SearchService.ts +++ b/src/main/services/SearchService.ts @@ -1,6 +1,9 @@ import { is } from '@electron-toolkit/utils' +import { loggerService } from '@logger' import { BrowserWindow } from 'electron' +const logger = loggerService.withContext('SearchService') + export class SearchService { private static instance: SearchService | null = null private searchWindows: Record = {} @@ -55,6 +58,7 @@ export class SearchService { public async openUrlInSearchWindow(uid: string, url: string): Promise { let window = this.searchWindows[uid] + logger.debug(`Searching with URL: ${url}`) if (window) { await window.loadURL(url) } else { diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 12f786d797..97216e6a65 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -204,7 +204,7 @@ export function registerShortcuts(window: BrowserWindow) { selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut) break - //the following ZOOMs will register shortcuts seperately, so will return + //the following ZOOMs will register shortcuts separately, so will return case 'zoom_in': globalShortcut.register('CommandOrControl+=', () => handler(window)) globalShortcut.register('CommandOrControl+numadd', () => handler(window)) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index e2c2dc4866..66b4b8d955 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -555,9 +555,9 @@ export class WindowService { // [Windows] hacky fix // the window is minimized only when in Windows platform - // because it's a workround for Windows, see `hideMiniWindow()` + // because it's a workaround for Windows, see `hideMiniWindow()` if (this.miniWindow?.isMinimized()) { - // don't let the window being seen before we finish adusting the position across screens + // don't let the window being seen before we finish adjusting the position across screens this.miniWindow?.setOpacity(0) // DO NOT use `restore()` here, Electron has the bug with screens of different scale factor // We have to use `show()` here, then set the position and bounds diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts new file mode 100644 index 0000000000..6ac8c311e3 --- /dev/null +++ b/src/main/services/ocr/OcrService.ts @@ -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 = 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 { + 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)) diff --git a/src/main/services/ocr/tesseract/TesseractService.ts b/src/main/services/ocr/tesseract/TesseractService.ts new file mode 100644 index 0000000000..d2ba6d2ed8 --- /dev/null +++ b/src/main/services/ocr/tesseract/TesseractService.ts @@ -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 { + 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 { + 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 { + if (!isImageFile(file)) { + throw new Error('Only image files are supported currently') + } + return this.imageOcr(file) + } + + private async _getLangPath(): Promise { + const country = await getIpCountry() + return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL + } + + private async _getCacheDir(): Promise { + 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 { + if (this.worker) { + await this.worker.terminate() + this.worker = null + } + } +} + +export const tesseractService = new TesseractService() diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index dc6af193f8..150a28eaca 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -168,6 +168,7 @@ export function getMcpDir() { * 读取文件内容并自动检测编码格式进行解码 * @param filePath - 文件路径 * @returns 解码后的文件内容 + * @throws 如果路径不存在抛出错误 */ export async function readTextFileWithAutoEncoding(filePath: string): Promise { const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8' diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts new file mode 100644 index 0000000000..ca63e82f07 --- /dev/null +++ b/src/main/utils/ocr.ts @@ -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 => { + const buffer = await readFile(file.path) + return await preprocessImage(buffer) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 7f21040302..8d45e88095 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,9 +18,12 @@ import { MemoryConfig, MemoryListOptions, MemorySearchOptions, + OcrProvider, + OcrResult, Provider, S3Config, Shortcut, + SupportedOcrFile, ThemeMode, WebDavConfig } from '@types' @@ -134,14 +137,15 @@ const api = { checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) }, file: { - select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), + select: (options?: OpenDialogOptions): Promise => + ipcRenderer.invoke(IpcChannel.File_Select, options), upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), read: (fileId: string, detectEncoding?: boolean) => ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding), clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext), - get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath), + get: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_Get, filePath), /** * 创建一个空的临时文件 * @param fileName 文件名 @@ -171,10 +175,12 @@ const api = { base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId), getPathForFile: (file: File) => webUtils.getPathForFile(file), - openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file) + openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file), + isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath) }, fs: { - read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding) + read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding), + readText: (pathOrUrl: string): Promise => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl) }, export: { toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName) @@ -407,6 +413,10 @@ const api = { options?: { autoUpdateToLatest?: boolean } ) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options) }, + ocr: { + ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise => + ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider) + }, preference: { get: (key: K): Promise => ipcRenderer.invoke(IpcChannel.Preference_Get, key), diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index 627fb37546..36d045aae5 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -4,6 +4,7 @@ import { FC, useMemo } from 'react' import { HashRouter, Route, Routes } from 'react-router-dom' import Sidebar from './components/app/Sidebar' +import { ErrorBoundary } from './components/ErrorBoundary' import TabsContainer from './components/Tab/TabContainer' import NavigationHandler from './handler/NavigationHandler' import { useNavbarPosition } from './hooks/useSettings' @@ -23,18 +24,20 @@ const Router: FC = () => { const routes = useMemo(() => { return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) }, []) diff --git a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts index d9bd9af9c8..1d990dfbda 100644 --- a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts @@ -19,6 +19,7 @@ import { estimateTextTokens } from '@renderer/services/TokenService' import { Assistant, EFFORT_RATIO, + FileTypes, GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -53,7 +54,7 @@ import { mcpToolCallResponseToAwsBedrockMessage, mcpToolsToAwsBedrockTools } from '@renderer/utils/mcp-tools' -import { findImageBlocks } from '@renderer/utils/messageUtils/find' +import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find' import { t } from 'i18next' import { BaseApiClient } from '../BaseApiClient' @@ -683,6 +684,30 @@ export class AwsBedrockAPIClient extends BaseApiClient< } } + // 处理文件内容 + const fileBlocks = findFileBlocks(message) + for (const fileBlock of fileBlocks) { + const file = fileBlock.file + if (!file) { + logger.warn(`No file in the file block. Passed.`, { fileBlock }) + continue + } + + if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { + try { + const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim() + if (fileContent) { + parts.push({ + text: `${file.origin_name}\n${fileContent}` + }) + } + } catch (error) { + logger.error('Error reading file content:', error as Error) + parts.push({ text: `[File: ${file.origin_name} - Failed to read content]` }) + } + } + } + // 如果没有任何内容,添加默认文本而不是空文本 if (parts.length === 0) { parts.push({ text: 'No content provided' }) diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index bdd7689d6f..70b2997fd2 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -52,6 +52,7 @@ import { GeminiSdkRawOutput, GeminiSdkToolCall } from '@renderer/types/sdk' +import { isToolUseModeFunction } from '@renderer/utils/assistant' import { geminiFunctionCallToMcpTool, isEnabledToolUse, @@ -428,8 +429,7 @@ export class GeminiAPIClient extends BaseApiClient< private getGenerateImageParameter(): Partial { return { systemInstruction: undefined, - responseModalities: [Modality.TEXT, Modality.IMAGE], - responseMimeType: 'text/plain' + responseModalities: [Modality.TEXT, Modality.IMAGE] } } @@ -476,16 +476,20 @@ export class GeminiAPIClient extends BaseApiClient< } } - if (enableWebSearch) { - tools.push({ - googleSearch: {} - }) - } + if (tools.length === 0 || !isToolUseModeFunction(assistant)) { + if (enableWebSearch) { + tools.push({ + googleSearch: {} + }) + } - if (enableUrlContext) { - tools.push({ - urlContext: {} - }) + if (enableUrlContext) { + tools.push({ + urlContext: {} + }) + } + } else if (enableWebSearch || enableUrlContext) { + logger.warn('Native tools cannot be used with function calling for now.') } if (isGemmaModel(model) && assistant.prompt) { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 7fcae3823c..228398fea7 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -6,11 +6,13 @@ import { getOpenAIWebSearchParams, getThinkModelType, isClaudeReasoningModel, + isDeepSeekHybridInferenceModel, isDoubaoThinkingAutoModel, isGeminiReasoningModel, isGPT5SeriesModel, isGrokReasoningModel, isNotSupportSystemMessageModel, + isOpenAIOpenWeightModel, isOpenAIReasoningModel, isQwenAlwaysThinkModel, isQwenMTModel, @@ -26,7 +28,8 @@ import { isSupportedThinkingTokenQwenModel, isSupportedThinkingTokenZhipuModel, isVisionModel, - MODEL_SUPPORTED_REASONING_EFFORT + MODEL_SUPPORTED_REASONING_EFFORT, + ZHIPU_RESULT_TOKENS } from '@renderer/config/models' import { isSupportArrayContentProvider, @@ -42,6 +45,7 @@ import { Assistant, EFFORT_RATIO, FileTypes, + isSystemProvider, MCPCallToolResponse, MCPTool, MCPToolResponse, @@ -111,7 +115,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< */ // Method for reasoning effort, moved from OpenAIProvider override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams { - if (this.provider.id === 'groq') { + if (this.provider.id === SystemProviderIds.groq) { return {} } @@ -120,22 +124,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } const reasoningEffort = assistant?.settings?.reasoning_effort - // Doubao 思考模式支持 - if (isSupportedThinkingTokenDoubaoModel(model)) { - // reasoningEffort 为空,默认开启 enabled - if (!reasoningEffort) { - return { thinking: { type: 'disabled' } } - } - if (reasoningEffort === 'high') { - return { thinking: { type: 'enabled' } } - } - if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) { - return { thinking: { type: 'auto' } } - } - // 其他情况不带 thinking 字段 - return {} - } - if (isSupportedThinkingTokenZhipuModel(model)) { if (!reasoningEffort) { return { thinking: { type: 'disabled' } } @@ -144,7 +132,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (!reasoningEffort) { - if (model.provider === 'openrouter') { + // DeepSeek hybrid inference models, v3.1 and maybe more in the future + // 不同的 provider 有不同的思考控制方式,在这里统一解决 + // if (isDeepSeekHybridInferenceModel(model)) { + // // do nothing for now. default to non-think. + // } + + // openrouter: use reasoning + if (model.provider === SystemProviderIds.openrouter) { // Don't disable reasoning for Gemini models that support thinking tokens if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) { return {} @@ -156,17 +151,22 @@ export class OpenAIAPIClient extends OpenAIBaseClient< return { reasoning: { enabled: false, exclude: true } } } + // providers that use enable_thinking if ( isSupportEnableThinkingProvider(this.provider) && - (isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model)) + (isSupportedThinkingTokenQwenModel(model) || + isSupportedThinkingTokenHunyuanModel(model) || + (this.provider.id === SystemProviderIds.dashscope && isDeepSeekHybridInferenceModel(model))) ) { return { enable_thinking: false } } + // claude if (isSupportedThinkingTokenClaudeModel(model)) { return {} } + // gemini if (isSupportedThinkingTokenGeminiModel(model)) { if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { return { @@ -195,8 +195,48 @@ export class OpenAIAPIClient extends OpenAIBaseClient< (findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min! ) + // DeepSeek hybrid inference models, v3.1 and maybe more in the future + // 不同的 provider 有不同的思考控制方式,在这里统一解决 + if (isDeepSeekHybridInferenceModel(model)) { + if (isSystemProvider(this.provider)) { + switch (this.provider.id) { + case SystemProviderIds.dashscope: + return { + enable_thinking: true, + incremental_output: true + } + case SystemProviderIds.silicon: + return { + enable_thinking: true + } + case SystemProviderIds.doubao: + return { + thinking: { + type: 'enabled' // auto is invalid + } + } + case SystemProviderIds.openrouter: + return { + reasoning: { + enabled: true + } + } + case 'nvidia': + return { + chat_template_kwargs: { + thinking: true + } + } + default: + logger.warn( + `Skipping thinking options for provider ${this.provider.name} as DeepSeek v3.1 thinking control method is unknown` + ) + } + } + } + // OpenRouter models - if (model.provider === 'openrouter') { + if (model.provider === SystemProviderIds.openrouter) { if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) { return { reasoning: { @@ -206,6 +246,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } } + // Doubao 思考模式支持 + if (isSupportedThinkingTokenDoubaoModel(model)) { + if (reasoningEffort === 'high') { + return { thinking: { type: 'enabled' } } + } + if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) { + return { thinking: { type: 'auto' } } + } + // 其他情况不带 thinking 字段 + return {} + } + // Qwen models if (isQwenReasoningModel(model)) { const thinkConfig = { @@ -213,7 +265,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true, thinking_budget: budgetTokens } - if (this.provider.id === 'dashscope') { + if (this.provider.id === SystemProviderIds.dashscope) { return { ...thinkConfig, incremental_output: true @@ -530,12 +582,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // 1. 处理系统消息 const systemMessage = { role: 'system', content: assistant.prompt || '' } - if (isSupportedReasoningEffortOpenAIModel(model)) { - if (isSupportDeveloperRoleProvider(this.provider)) { - systemMessage.role = 'developer' - } else { - systemMessage.role = 'system' - } + if ( + isSupportedReasoningEffortOpenAIModel(model) && + isSupportDeveloperRoleProvider(this.provider) && + !isOpenAIOpenWeightModel(model) + ) { + systemMessage.role = 'developer' } if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) { @@ -559,6 +611,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< userMessages.push(await this.convertMessageToSdkParam(message, model)) } } + if (userMessages.length === 0) { + logger.warn('No user message. Some providers may not support.') + } // poe 需要通过用户消息传递 reasoningEffort const reasoningEffort = this.getReasoningEffort(assistant, model) @@ -566,11 +621,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient< const lastUserMsg = userMessages.findLast((m) => m.role === 'user') if (lastUserMsg) { if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) { - const postsuffix = '/no_think' const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true const currentContent = lastUserMsg.content - lastUserMsg.content = processPostsuffixQwen3Model(currentContent, postsuffix, qwenThinkModeEnabled) as any + lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled) } if (this.provider.id === SystemProviderIds.poe) { // 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分 @@ -586,8 +640,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // 4. 最终请求消息 let reqMessages: OpenAISdkMessageParam[] - if (!systemMessage.content || isNotSupportSystemMessageModel(model)) { + if (!systemMessage.content) { reqMessages = [...userMessages] + } else if (isNotSupportSystemMessageModel(model)) { + // transform into user message + const firstUserMsg = userMessages.shift() + if (firstUserMsg) { + firstUserMsg.content = `System Instruction: \n${systemMessage.content}\n\nUser Message(s):\n${firstUserMsg.content}` + reqMessages = [firstUserMsg, ...userMessages] + } else { + reqMessages = [] + } } else { reqMessages = [systemMessage, ...userMessages].filter(Boolean) as OpenAISdkMessageParam[] } @@ -818,7 +881,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< (typeof choice.delta.content === 'string' && choice.delta.content !== '') || (typeof (choice.delta as any).reasoning_content === 'string' && (choice.delta as any).reasoning_content !== '') || - (typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '')) + (typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '') || + ((choice.delta as OpenAISdkRawContentSource).images && + Array.isArray((choice.delta as OpenAISdkRawContentSource).images))) ) { contentSource = choice.delta } else if ('message' in choice) { @@ -896,27 +961,59 @@ export class OpenAIAPIClient extends OpenAIBaseClient< accumulatingText = true } // logger.silly('enqueue TEXT_DELTA') - controller.enqueue({ - type: ChunkType.TEXT_DELTA, - text: contentSource.content - }) + // 处理特殊token + // 智谱api的一个chunk中只会输出一个token,因而使用 ===,避免正常内容被误判 + if ( + context.provider.id === SystemProviderIds.zhipu && + ZHIPU_RESULT_TOKENS.some((pattern) => contentSource.content === pattern) + ) { + controller.enqueue({ + type: ChunkType.TEXT_DELTA, + text: '**' // strong + }) + } else { + controller.enqueue({ + type: ChunkType.TEXT_DELTA, + text: contentSource.content + }) + } } else { accumulatingText = false } + // 处理图片内容 (e.g. from OpenRouter Gemini image generation models) + if (contentSource.images && Array.isArray(contentSource.images)) { + controller.enqueue({ + type: ChunkType.IMAGE_CREATED + }) + controller.enqueue({ + type: ChunkType.IMAGE_COMPLETE, + image: { + type: 'base64', + images: contentSource.images.map((image) => image.image_url?.url || '') + } + }) + } + // 处理工具调用 if (contentSource.tool_calls) { for (const toolCall of contentSource.tool_calls) { if ('index' in toolCall) { const { id, index, function: fun } = toolCall if (fun?.name) { - toolCalls[index] = { + const toolCallObject = { id: id || '', function: { name: fun.name, arguments: fun.arguments || '' }, - type: 'function' + type: 'function' as const + } + + if (index === -1) { + toolCalls.push(toolCallObject) + } else { + toolCalls[index] = toolCallObject } } else if (fun?.arguments) { if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 10a2ee7bbe..36666fcaf2 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -5,6 +5,7 @@ import { isGPT5SeriesModel, isOpenAIChatCompletionOnlyModel, isOpenAILLMModel, + isOpenAIOpenWeightModel, isSupportedReasoningEffortOpenAIModel, isSupportVerbosityModel, isVisionModel @@ -374,12 +375,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< text: assistant.prompt || '', type: 'input_text' } - if (isSupportedReasoningEffortOpenAIModel(model)) { - if (isSupportDeveloperRoleProvider(this.provider)) { - systemMessage.role = 'developer' - } else { - systemMessage.role = 'system' - } + if ( + isSupportedReasoningEffortOpenAIModel(model) && + isSupportDeveloperRoleProvider(this.provider) && + isOpenAIOpenWeightModel(model) + ) { + systemMessage.role = 'developer' } // 2. 设置工具 diff --git a/src/renderer/src/aiCore/index.ts b/src/renderer/src/aiCore/index.ts index cea27d2568..99eb6c940b 100644 --- a/src/renderer/src/aiCore/index.ts +++ b/src/renderer/src/aiCore/index.ts @@ -112,7 +112,7 @@ export default class AiProvider { builder.remove(ToolUseExtractionMiddlewareName) logger.silly('ToolUseExtractionMiddleware is removed') } - if (params.callType !== 'chat') { + if (params.callType !== 'chat' && params.callType !== 'check' && params.callType !== 'translate') { logger.silly('AbortHandlerMiddleware is removed') builder.remove(AbortHandlerMiddlewareName) } diff --git a/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts b/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts index c1d3102ed9..a733e45d70 100644 --- a/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts @@ -21,32 +21,38 @@ export const AbortHandlerMiddleware: CompletionsMiddleware = return result } - // 获取当前消息的ID用于abort管理 - // 优先使用处理过的消息,如果没有则使用原始消息 - let messageId: string | undefined - - if (typeof params.messages === 'string') { - messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` - } else { - const processedMessages = params.messages - const lastUserMessage = processedMessages.findLast((m) => m.role === 'user') - messageId = lastUserMessage?.id - } - - if (!messageId) { - logger.warn(`No messageId found, abort functionality will not be available.`) - return next(ctx, params) - } - const abortController = new AbortController() const abortFn = (): void => abortController.abort() - - addAbortController(messageId, abortFn) - let abortSignal: AbortSignal | null = abortController.signal + let abortKey: string + // 如果参数中传入了abortKey则优先使用 + if (params.abortKey) { + abortKey = params.abortKey + } else { + // 获取当前消息的ID用于abort管理 + // 优先使用处理过的消息,如果没有则使用原始消息 + let messageId: string | undefined + + if (typeof params.messages === 'string') { + messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` + } else { + const processedMessages = params.messages + const lastUserMessage = processedMessages.findLast((m) => m.role === 'user') + messageId = lastUserMessage?.id + } + + if (!messageId) { + logger.warn(`No messageId found, abort functionality will not be available.`) + return next(ctx, params) + } + + abortKey = messageId + } + + addAbortController(abortKey, abortFn) const cleanup = (): void => { - removeAbortController(messageId as string, abortFn) + removeAbortController(abortKey, abortFn) if (ctx._internal?.flowControl) { ctx._internal.flowControl.abortController = undefined ctx._internal.flowControl.abortSignal = undefined diff --git a/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts b/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts index 25d0e358c6..fa936af479 100644 --- a/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/RawStreamListenerMiddleware.ts @@ -1,5 +1,4 @@ import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient' -import { isAnthropicModel } from '@renderer/config/models' import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk' import { AnthropicStreamListener } from '../../clients/types' @@ -16,9 +15,8 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware = // 在这里可以监听到从SDK返回的最原始流 if (result.rawOutput) { - const model = params.assistant.model // TODO: 后面下放到AnthropicAPIClient - if (isAnthropicModel(model)) { + if (ctx.apiClientInstance instanceof AnthropicAPIClient) { const anthropicListener: AnthropicStreamListener = { onMessage: (message) => { if (ctx._internal?.toolProcessingState) { diff --git a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts index 5ab19a6175..447b9d2f23 100644 --- a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts @@ -7,6 +7,7 @@ import { ThinkingDeltaChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { getLowerBaseModelName } from '@renderer/utils' import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' @@ -22,13 +23,16 @@ const reasoningTags: TagConfig[] = [ { openingTag: '', closingTag: '', separator: '\n' }, { openingTag: '###Thinking', closingTag: '###Response', separator: '\n' }, { openingTag: '◁think▷', closingTag: '◁/think▷', separator: '\n' }, - { openingTag: '', closingTag: '', separator: '\n' } + { openingTag: '', closingTag: '', separator: '\n' }, + { openingTag: '', closingTag: '', separator: '\n' } ] const getAppropriateTag = (model?: Model): TagConfig => { - if (model?.id?.includes('qwen3')) return reasoningTags[0] - if (model?.id?.includes('gemini-2.5')) return reasoningTags[1] - if (model?.id?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3] + const modelId = model?.id ? getLowerBaseModelName(model.id) : undefined + if (modelId?.includes('qwen3')) return reasoningTags[0] + if (modelId?.includes('gemini-2.5')) return reasoningTags[1] + if (modelId?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3] + if (modelId?.includes('seed-oss-36b')) return reasoningTags[5] // 可以在这里添加更多模型特定的标签配置 return reasoningTags[0] // 默认使用 标签 } diff --git a/src/renderer/src/aiCore/middleware/schemas.ts b/src/renderer/src/aiCore/middleware/schemas.ts index fcb59d4aff..ce89934f02 100644 --- a/src/renderer/src/aiCore/middleware/schemas.ts +++ b/src/renderer/src/aiCore/middleware/schemas.ts @@ -59,6 +59,9 @@ export interface CompletionsParams { contextCount?: number topicId?: string // 主题ID,用于关联上下文 + // abort 控制 + abortKey?: string + _internal?: ProcessingState } diff --git a/src/renderer/src/assets/images/providers/Tesseract.js.png b/src/renderer/src/assets/images/providers/Tesseract.js.png new file mode 100644 index 0000000000..d60b9b6878 Binary files /dev/null and b/src/renderer/src/assets/images/providers/Tesseract.js.png differ diff --git a/src/renderer/src/assets/images/providers/poe.svg b/src/renderer/src/assets/images/providers/poe.svg deleted file mode 100644 index 1083effc31..0000000000 --- a/src/renderer/src/assets/images/providers/poe.svg +++ /dev/null @@ -1 +0,0 @@ -Poe \ No newline at end of file diff --git a/src/renderer/src/assets/styles/animation.scss b/src/renderer/src/assets/styles/animation.scss index bbc1c569f3..9b8b428c5c 100644 --- a/src/renderer/src/assets/styles/animation.scss +++ b/src/renderer/src/assets/styles/animation.scss @@ -68,3 +68,23 @@ transform-origin: center; animation: animation-rotate 0.75s linear infinite; } + +// 定位高亮动画 +@keyframes animation-locate-highlight { + 0% { + background-color: transparent; + } + 10% { + background-color: var(--color-primary-mute); + } + 70% { + background-color: var(--color-primary-mute); + } + 100% { + background-color: transparent; + } +} + +.animation-locate-highlight { + animation: animation-locate-highlight 2.5s ease-in-out; +} diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 9ebc658010..ad6d1e0eaf 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -1,5 +1,26 @@ @use './container.scss'; +/* Modal 关闭按钮不应该可拖拽,以确保点击正常 */ +.ant-modal-close { + -webkit-app-region: no-drag; +} + +/* 普通 Drawer 内容不应该可拖拽 */ +.ant-drawer-content { + -webkit-app-region: no-drag; +} + +/* minapp-drawer 有自己的拖拽规则 */ + +/* 下拉菜单和弹出框内容不应该可拖拽 */ +.ant-dropdown, +.ant-dropdown-menu, +.ant-popover-content, +.ant-tooltip-content, +.ant-popconfirm { + -webkit-app-region: no-drag; +} + #inputbar { resize: none; } @@ -66,6 +87,7 @@ } .ant-drawer-header { + /* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */ -webkit-app-region: no-drag; } @@ -76,7 +98,7 @@ } .ant-dropdown-menu .ant-dropdown-menu-sub { - max-height: 50vh; + max-height: 80vh; width: max-content; overflow-y: auto; overflow-x: hidden; @@ -88,7 +110,7 @@ border-radius: var(--ant-border-radius-lg); user-select: none; .ant-dropdown-menu { - max-height: 50vh; + max-height: 80vh; overflow-y: auto; border: 0.5px solid var(--color-border); @@ -148,6 +170,7 @@ border-radius: 10px; } .ant-modal-body { + /* 保持 body 在视口内,使用标准的最大高度 */ max-height: 80vh; overflow-y: auto; padding: 0 16px 0 16px; diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 2760ecb6e5..caf41aca5e 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -106,6 +106,10 @@ white-space: pre-wrap; } + .katex span { + white-space: pre; + } + p code, li code { background: var(--color-background-mute); diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss index 23f0edfb34..cf3c672c45 100644 --- a/src/renderer/src/assets/styles/selection-toolbar.scss +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -6,10 +6,8 @@ html { :root { // Basic Colors - --color-primary: #00b96b; --color-error: #f44336; - --selection-toolbar-color-primary: var(--color-primary); --selection-toolbar-color-error: var(--color-error); // Toolbar @@ -54,8 +52,6 @@ html { --selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9); --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); - --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); - --selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary); --selection-toolbar-button-bgcolor: transparent; // default: transparent --selection-toolbar-button-bgcolor-hover: #333333; } @@ -72,7 +68,5 @@ html { --selection-toolbar-button-text-color: rgba(0, 0, 0, 1); --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); - --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); - --selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary); --selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04); } diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index 3a82db90fa..acb4a9c4f1 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -157,6 +157,7 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>` display: flex; align-items: center; justify-content: center; + flex-shrink: 0; width: 44px; height: 44px; background: ${(props) => @@ -177,13 +178,16 @@ const TitleSection = styled.div` gap: 6px; ` -const Title = styled.h3` - margin: 0 !important; - font-size: 14px !important; - font-weight: 600; - color: var(--color-text); +const Title = styled.span` + font-size: 14px; + font-weight: bold; + color: var(--color-text-1); line-height: 1.4; font-family: 'Ubuntu'; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; ` const TypeBadge = styled.div` diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index a548d5e163..216e247701 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,7 +1,7 @@ import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' import { isLinux, isMac, isWin } from '@renderer/config/constant' import { classNames } from '@renderer/utils' -import { Button, Modal, Splitter, Tooltip } from 'antd' +import { Button, Modal, Splitter, Tooltip, Typography } from 'antd' import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -43,7 +43,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht const renderHeader = () => ( setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}> - {title} + {title} @@ -266,13 +266,13 @@ const HeaderRight = styled.div<{ $isFullscreen?: boolean }>` padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')}; ` -const TitleText = styled.span` +const TitleText = styled(Typography.Text)` font-size: 16px; - font-weight: 600; + font-weight: bold; color: var(--color-text); white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; + width: 50%; ` const ViewControls = styled.div` diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 086218c023..21c6da743f 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -18,8 +18,8 @@ import { BasicPreviewHandles } from '@renderer/components/Preview' import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { useSettings } from '@renderer/hooks/useSettings' import { pyodideService } from '@renderer/services/PyodideService' +import { getExtensionByLanguage } from '@renderer/utils/code-language' import { extractTitle } from '@renderer/utils/formats' -import { getExtensionByLanguage } from '@renderer/utils/markdown' import dayjs from 'dayjs' import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index d49a703297..7917cebd80 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -1,10 +1,11 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup import { EditorView } from '@codemirror/view' import { loggerService } from '@logger' -import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { Extension, keymap } from '@uiw/react-codemirror' import { useEffect, useMemo, useState } from 'react' +import { getNormalizedExtension } from './utils' + const logger = loggerService.withContext('CodeEditorHooks') // 语言对应的 linter 加载器 @@ -17,32 +18,33 @@ const linterLoaders: Record Promise> = { /** * 特殊语言加载器 + * key: 语言文件扩展名(不包含 `.`) */ const specialLanguageLoaders: Record Promise> = { dot: async () => { const mod = await import('@viz-js/lang-dot') return mod.dot() + }, + // @uiw/codemirror-extensions-langs 4.25.1 移除了 mermaid 支持,这里加回来 + mmd: async () => { + const mod = await import('codemirror-lang-mermaid') + return mod.mermaid() } } /** * 加载语言扩展 */ -async function loadLanguageExtension(language: string, languageMap: Record): Promise { - let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() - - // 如果语言名包含 `-`,转换为驼峰命名法 - if (normalizedLang.includes('-')) { - normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) - } +async function loadLanguageExtension(language: string): Promise { + const fileExt = await getNormalizedExtension(language) // 尝试加载特殊语言 - const specialLoader = specialLanguageLoaders[normalizedLang] + const specialLoader = specialLanguageLoaders[fileExt] if (specialLoader) { try { return await specialLoader() } catch (error) { - logger.debug(`Failed to load language ${normalizedLang}`, error as Error) + logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error) return null } } @@ -50,10 +52,10 @@ async function loadLanguageExtension(language: string, languageMap: Record * 加载语言相关扩展 */ export const useLanguageExtensions = (language: string, lint?: boolean) => { - const { languageMap } = useCodeStyle() const [extensions, setExtensions] = useState([]) useEffect(() => { @@ -87,7 +88,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => { try { // 加载所有扩展 const [languageResult, linterResult] = await Promise.allSettled([ - loadLanguageExtension(language, languageMap), + loadLanguageExtension(language), lint ? loadLinterExtension(language) : Promise.resolve(null) ]) @@ -119,7 +120,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => { return () => { cancelled = true } - }, [language, lint, languageMap]) + }, [language, lint]) return extensions } diff --git a/src/renderer/src/components/CodeEditor/utils.ts b/src/renderer/src/components/CodeEditor/utils.ts new file mode 100644 index 0000000000..251778b9d1 --- /dev/null +++ b/src/renderer/src/components/CodeEditor/utils.ts @@ -0,0 +1,34 @@ +import { getExtensionByLanguage } from '@renderer/utils/code-language' + +// 自定义语言文件扩展名映射 +// key: 语言名小写 +// value: 扩展名 +const _customLanguageExtensions: Record = { + 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 +} diff --git a/src/renderer/src/components/CollapsibleSearchBar.tsx b/src/renderer/src/components/CollapsibleSearchBar.tsx index 3ce3b436be..04b838e37a 100644 --- a/src/renderer/src/components/CollapsibleSearchBar.tsx +++ b/src/renderer/src/components/CollapsibleSearchBar.tsx @@ -1,21 +1,30 @@ +import i18n from '@renderer/i18n' import { Input, InputRef, Tooltip } from 'antd' import { Search } from 'lucide-react' import { motion } from 'motion/react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' interface CollapsibleSearchBarProps { onSearch: (text: string) => void + placeholder?: string + tooltip?: string icon?: React.ReactNode maxWidth?: string | number + style?: React.CSSProperties } /** * A collapsible search bar for list headers * Renders as an icon initially, expands to full search input when clicked */ -const CollapsibleSearchBar: React.FC = ({ onSearch, icon, maxWidth }) => { - const { t } = useTranslation() +const CollapsibleSearchBar = ({ + onSearch, + placeholder = i18n.t('common.search'), + tooltip = i18n.t('common.search'), + icon = , + maxWidth = '100%', + style +}: CollapsibleSearchBarProps) => { const [searchVisible, setSearchVisible] = useState(false) const [searchText, setSearchText] = useState('') const inputRef = useRef(null) @@ -46,16 +55,16 @@ const CollapsibleSearchBar: React.FC = ({ onSearch, i initial="collapsed" animate={searchVisible ? 'expanded' : 'collapsed'} variants={{ - expanded: { maxWidth: maxWidth || '100%', opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } }, + expanded: { maxWidth: maxWidth, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } }, collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } } }} style={{ overflow: 'hidden', flex: 1 }}> } + suffix={icon} value={searchText} autoFocus allowClear @@ -71,7 +80,7 @@ const CollapsibleSearchBar: React.FC = ({ onSearch, i if (!searchText) setSearchVisible(false) }} onClear={handleClear} - style={{ width: '100%' }} + style={{ width: '100%', ...style }} /> = ({ onSearch, i }} style={{ cursor: 'pointer', display: 'flex' }} onClick={() => setSearchVisible(true)}> - - {icon || } + + {icon} diff --git a/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx index d6c1aa7b21..2d61583787 100644 --- a/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx +++ b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx @@ -29,20 +29,6 @@ vi.mock('@hello-pangea/dnd', () => { } }) -// mock antd list 只做简单渲染 -vi.mock('antd', () => ({ - __esModule: true, - List: ({ dataSource, renderItem }: any) => ( -
- {dataSource.map((item: any, idx: number) => ( -
- {renderItem(item, idx)} -
- ))} -
- ) -})) - declare global { interface Window { triggerOnDragEnd: (result?: any, provided?: any) => void @@ -73,14 +59,15 @@ describe('DraggableList', () => { const list = [{ id: 'a', name: 'A' }] const style = { background: 'red' } const listStyle = { color: 'blue' } - render( + const { container } = render( {}}> {(item) =>
{item.name}
}
) // 检查 style 是否传递到外层容器 - const virtualList = screen.getByTestId('virtual-list') - expect(virtualList.parentElement).toHaveStyle({ background: 'red' }) + const listContainer = container.querySelector('.draggable-list-container') + expect(listContainer).not.toBeNull() + expect(listContainer?.parentElement).toHaveStyle({ background: 'red' }) }) it('should render nothing when list is empty', () => { diff --git a/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx b/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx index 523ef89d26..d931d961b8 100644 --- a/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx +++ b/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx @@ -32,7 +32,7 @@ vi.mock('@hello-pangea/dnd', () => ({ })) vi.mock('@tanstack/react-virtual', () => ({ - useVirtualizer: ({ count }) => ({ + useVirtualizer: ({ count, getScrollElement }) => ({ getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, @@ -41,7 +41,13 @@ vi.mock('@tanstack/react-virtual', () => ({ size: 50 })), getTotalSize: () => count * 50, - measureElement: vi.fn() + measureElement: vi.fn(), + scrollToIndex: vi.fn(), + scrollToOffset: vi.fn(), + scrollElement: getScrollElement(), + measure: vi.fn(), + resizeItem: vi.fn(), + getVirtualIndexes: () => Array.from({ length: count }, (_, i) => i) }) })) diff --git a/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableList.test.tsx.snap b/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableList.test.tsx.snap index f85a3e07bd..75f9e74875 100644 --- a/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableList.test.tsx.snap +++ b/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableList.test.tsx.snap @@ -10,56 +10,44 @@ exports[`DraggableList > snapshot > should match snapshot 1`] = ` >
-
- A -
+ A
-
- B -
+ B
-
- C -
+ C
diff --git a/src/renderer/src/components/DraggableList/index.tsx b/src/renderer/src/components/DraggableList/index.tsx index 642b12bfd7..3a953115fa 100644 --- a/src/renderer/src/components/DraggableList/index.tsx +++ b/src/renderer/src/components/DraggableList/index.tsx @@ -1,3 +1,7 @@ export { default as DraggableList } from './list' export { useDraggableReorder } from './useDraggableReorder' -export { default as DraggableVirtualList } from './virtual-list' +export { + default as DraggableVirtualList, + type DraggableVirtualListProps, + type DraggableVirtualListRef +} from './virtual-list' diff --git a/src/renderer/src/components/DraggableList/list.tsx b/src/renderer/src/components/DraggableList/list.tsx index 3a2cc5b108..b0e87bd2d2 100644 --- a/src/renderer/src/components/DraggableList/list.tsx +++ b/src/renderer/src/components/DraggableList/list.tsx @@ -9,13 +9,13 @@ import { ResponderProvided } from '@hello-pangea/dnd' import { droppableReorder } from '@renderer/utils' -import { List } from 'antd' -import { FC } from 'react' +import { FC, HTMLAttributes } from 'react' interface Props { list: T[] style?: React.CSSProperties listStyle?: React.CSSProperties + listProps?: HTMLAttributes children: (item: T, index: number) => React.ReactNode onUpdate: (list: T[]) => void onDragStart?: OnDragStartResponder @@ -28,6 +28,7 @@ const DraggableList: FC> = ({ list, style, listStyle, + listProps, droppableProps, onDragStart, onUpdate, @@ -50,9 +51,8 @@ const DraggableList: FC> = ({ {(provided) => (
- { +
+ {list.map((item, index) => { const id = item.id || item return ( @@ -71,8 +71,8 @@ const DraggableList: FC> = ({ )} ) - }} - /> + })} +
{provided.placeholder}
)} diff --git a/src/renderer/src/components/DraggableList/virtual-list.tsx b/src/renderer/src/components/DraggableList/virtual-list.tsx index 69b0ced667..e915eec1fe 100644 --- a/src/renderer/src/components/DraggableList/virtual-list.tsx +++ b/src/renderer/src/components/DraggableList/virtual-list.tsx @@ -10,8 +10,19 @@ import { } from '@hello-pangea/dnd' import Scrollbar from '@renderer/components/Scrollbar' import { droppableReorder } from '@renderer/utils' -import { useVirtualizer } from '@tanstack/react-virtual' -import { type Key, memo, useCallback, useRef } from 'react' +import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' +import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react' + +export interface DraggableVirtualListRef { + measure: () => void + scrollElement: () => HTMLDivElement | null + scrollToOffset: (offset: number, options?: ScrollToOptions) => void + scrollToIndex: (index: number, options?: ScrollToOptions) => void + resizeItem: (index: number, size: number) => void + getTotalSize: () => number + getVirtualItems: () => VirtualItem[] + getVirtualIndexes: () => number[] +} /** * 泛型 Props,用于配置 DraggableVirtualList。 @@ -31,8 +42,8 @@ import { type Key, memo, useCallback, useRef } from 'react' * @property {React.ReactNode} [header] 列表头部内容 * @property {(item: T, index: number) => React.ReactNode} children 列表项渲染函数 */ -interface DraggableVirtualListProps { - ref?: React.Ref +export interface DraggableVirtualListProps { + ref?: React.Ref className?: string style?: React.CSSProperties scrollerStyle?: React.CSSProperties @@ -100,9 +111,23 @@ function DraggableVirtualList({ 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 (
diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..5bfeaeb620 --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -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 = (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 ( + + + + + + } + /> + + ) +} + +const ErrorBoundaryCustomized = ({ + children, + fallbackComponent +}: { + children: ReactNode + fallbackComponent?: ComponentType +}) => { + return {children} +} + +const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + padding: 8px; +` + +export { ErrorBoundaryCustomized as ErrorBoundary } diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index 88598bb02e..8cae9c94de 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps) { return ( ) { return ( ) { return ( ) } + +export function PoeLogo(props: SVGProps) { + return ( + + Poe + + + + + + + + + + + + + + + + ) +} diff --git a/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx b/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx index 9972d52139..3eb6ac40ea 100644 --- a/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx +++ b/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx @@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC = ({ children }) => { } return ( -
+
{createPortal( diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 95f0f1b5d0..226598dc57 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMinappsOpenLinkExternal } from '@renderer/store/settings' import { MinAppType } from '@renderer/types' @@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => { const isInDevelopment = process.env.NODE_ENV === 'development' + const { setTimeoutTimer } = useTimer() + useBridge() /** set the popup display status */ @@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => { window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) } if (appid == currentMinappId) { - setTimeout(() => setIsReady(true), 200) + setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200) } } diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index 3da6ccfc8d..263292dcad 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -8,20 +8,19 @@ import { } from '@renderer/config/models' import i18n from '@renderer/i18n' import { Model } from '@renderer/types' -import { isFreeModel } from '@renderer/utils' +import { isFreeModel } from '@renderer/utils/model' import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import CustomTag from './Tags/CustomTag' import { EmbeddingTag, + FreeTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag -} from './Tags/ModelCapabilities' +} from './Tags/Model' interface ModelTagsProps { model: Model @@ -44,7 +43,6 @@ const ModelTagsWithLabel: FC = ({ showTooltip = true, style }) => { - const { t } = useTranslation() const [shouldShowLabel, setShouldShowLabel] = useState(false) const containerRef = useRef(null) const resizeObserver = useRef(null) @@ -86,7 +84,7 @@ const ModelTagsWithLabel: FC = ({ )} {isEmbeddingModel(model) && } - {showFree && isFreeModel(model) && } + {showFree && isFreeModel(model) && } {isRerankModel(model) && } ) diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index b3ea93662a..eecad5ec9c 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -1,6 +1,7 @@ import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import { useSystemAgents } from '@renderer/pages/agents' import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -33,6 +34,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const loadingRef = useRef(false) const [selectedIndex, setSelectedIndex] = useState(0) const containerRef = useRef(null) + const { setTimeoutTimer } = useTimer() const agents = useMemo(() => { const allAgents = [...userAgents, ...systemAgents] as Agent[] @@ -80,11 +82,11 @@ const PopupContainer: React.FC = ({ resolve }) => { assistant = await createAssistantFromAgent(agent) } - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) + setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) resolve(assistant) setOpen(false) }, - [resolve, addAssistant, setOpen] + [setTimeoutTimer, resolve, addAssistant] ) // 添加函数内使用的依赖项 // 键盘导航处理 useEffect(() => { diff --git a/src/renderer/src/components/Popups/PromptPopup.tsx b/src/renderer/src/components/Popups/PromptPopup.tsx index 9d8b9ae752..0d254d3fb9 100644 --- a/src/renderer/src/components/Popups/PromptPopup.tsx +++ b/src/renderer/src/components/Popups/PromptPopup.tsx @@ -1,6 +1,6 @@ import { Input, Modal } from 'antd' import { TextAreaProps } from 'antd/es/input' -import { useRef, useState } from 'react' +import { ReactNode, useRef, useState } from 'react' import { Box } from '../Layout' import { TopView } from '../TopView' @@ -11,6 +11,7 @@ interface PromptPopupShowParams { defaultValue?: string inputPlaceholder?: string inputProps?: TextAreaProps + extraNode?: ReactNode } interface Props extends PromptPopupShowParams { @@ -23,6 +24,7 @@ const PromptPopupContainer: React.FC = ({ defaultValue = '', inputPlaceholder = '', inputProps = {}, + extraNode = null, resolve }) => { const [value, setValue] = useState(defaultValue) @@ -88,6 +90,7 @@ const PromptPopupContainer: React.FC = ({ rows={1} {...inputProps} /> + {extraNode} ) } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index 3ea309f6a0..ef616e71ac 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,16 +1,36 @@ import { PushpinOutlined } from '@ant-design/icons' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' +import { + EmbeddingTag, + FreeTag, + ReasoningTag, + RerankerTag, + ToolsCallingTag, + VisionTag, + WebSearchTag +} from '@renderer/components/Tags/Model' import { TopView } from '@renderer/components/TopView' import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' -import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { + getModelLogo, + isEmbeddingModel, + isFunctionCallingModel, + isReasoningModel, + isRerankModel, + isVisionModel, + isWebSearchModel +} from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, Provider } from '@renderer/types' +import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' -import { Avatar, Divider, Empty, Modal } from 'antd' +import { getModelTags, isFreeModel } from '@renderer/utils/model' +import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd' import { first, sortBy } from 'lodash' +import { SettingsIcon } from 'lucide-react' import React, { + ReactNode, startTransition, useCallback, useDeferredValue, @@ -24,22 +44,28 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import SelectModelSearchBar from './searchbar' -import { FlatListItem } from './types' +import { FlatListItem, FlatListModel } from './types' -const PAGE_SIZE = 11 +const PAGE_SIZE = 12 const ITEM_HEIGHT = 36 +type ModelPredict = (m: Model) => boolean + interface PopupParams { model?: Model modelFilter?: (model: Model) => boolean + userFilterDisabled?: boolean } interface Props extends PopupParams { resolve: (value: Model | undefined) => void - modelFilter?: (model: Model) => boolean } -const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { +export type FilterType = Exclude | 'free' + +// const logger = loggerService.withContext('SelectModelPopup') + +const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilterDisabled }) => { const { t } = useTranslation() const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() @@ -48,6 +74,11 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) + const allModels: Model[] = useMemo( + () => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)), + [modelFilter, providers] + ) + // 当前选中的模型ID const currentModelId = model ? getModelUniqId(model) : '' @@ -62,10 +93,99 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { }) }, []) + // 管理用户筛选状态 + /** 从模型列表获取的需要显示的标签 */ + const availableTags = useMemo( + () => + objectEntries(getModelTags(allModels)) + .filter(([, state]) => state) + .map(([tag]) => tag), + [allModels] + ) + + const filterConfig: Record = useMemo( + () => ({ + vision: isVisionModel, + embedding: isEmbeddingModel, + reasoning: isReasoningModel, + function_calling: isFunctionCallingModel, + web_search: isWebSearchModel, + rerank: isRerankModel, + free: isFreeModel + }), + [] + ) + + /** 当前选择的标签,表示是否启用特定tag的筛选 */ + const [filterTags, setFilterTags] = useState>({ + 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 = useMemo( + () => ({ + vision: onClickTag('vision')} />, + embedding: onClickTag('embedding')} />, + reasoning: onClickTag('reasoning')} />, + function_calling: ( + onClickTag('function_calling')} + /> + ), + web_search: onClickTag('web_search')} />, + rerank: onClickTag('rerank')} />, + free: onClickTag('free')} /> + }), + [ + filterTags.embedding, + filterTags.free, + filterTags.function_calling, + filterTags.reasoning, + filterTags.rerank, + filterTags.vision, + filterTags.web_search, + onClickTag + ] + ) + + // 要显示的筛选项 + const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems]) // 根据输入的文本筛选模型 - const getFilteredModels = useCallback( + const searchFilter = useCallback( (provider: Provider) => { - let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) + let models = provider.models if (searchText.trim()) { models = filterModelsByKeywords(searchText, models, provider) @@ -78,7 +198,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 创建模型列表项 const createModelItem = useCallback( - (model: Model, provider: Provider, isPinned: boolean): FlatListItem => { + (model: Model, provider: Provider, isPinned: boolean): FlatListModel => { const modelId = getModelUniqId(model) const groupName = getFancyProviderName(provider) @@ -113,7 +233,11 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const { listItems, modelItems } = useMemo(() => { const items: FlatListItem[] = [] const pinnedModelIds = new Set(pinnedModels) - const finalModelFilter = modelFilter || (() => true) + const finalModelFilter = (model: Model) => { + const _userFilter = userFilterDisabled || userFilter(model) + const _modelFilter = modelFilter === undefined || modelFilter(model) + return _userFilter && _modelFilter + } // 添加置顶模型分组(仅在无搜索文本时) if (searchText.length === 0 && pinnedModelIds.size > 0) { @@ -139,7 +263,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 添加常规模型分组 providers.forEach((p) => { - const filteredModels = getFilteredModels(p) + const filteredModels = searchFilter(p) .filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m))) .filter(finalModelFilter) @@ -150,6 +274,22 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { key: `provider-${p.id}`, type: 'group', name: getFancyProviderName(p), + actions: ( + + @@ -368,7 +306,7 @@ const CodeToolsPage: FC = () => { setEnvVars(e.target.value)} rows={2} style={{ fontFamily: 'monospace' }} /> @@ -408,11 +346,14 @@ const Container = styled.div` const ContentContainer = styled.div` display: flex; flex: 1; + overflow-y: auto; + padding: 20px 0; ` const MainContent = styled.div` width: 600px; margin: auto; + min-height: fit-content; ` const Title = styled.h1` diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 434a3f0a45..c94e8bc867 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -1 +1,135 @@ +import { EndpointType, Model, Provider } from '@renderer/types' +import { codeTools } from '@shared/config/constant' + +export interface LaunchValidationResult { + isValid: boolean + message?: string +} + +export interface ToolEnvironmentConfig { + tool: codeTools + model: Model + modelProvider: Provider + apiKey: string + baseUrl: string +} + +// CLI 工具选项 +export const CLI_TOOLS = [ + { value: codeTools.qwenCode, label: 'Qwen Code' }, + { value: codeTools.claudeCode, label: 'Claude Code' }, + { value: codeTools.geminiCli, label: 'Gemini CLI' }, + { value: codeTools.openaiCodex, label: 'OpenAI Codex' } +] + +export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api'] +export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu'] +export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS] + +// Provider 过滤映射 +export const CLI_TOOL_PROVIDER_MAP: Record Provider[]> = { + [codeTools.claudeCode]: (providers) => + providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id)), + [codeTools.geminiCli]: (providers) => + providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)), + [codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')), + [codeTools.openaiCodex]: (providers) => providers.filter((p) => p.id === 'openai') +} + +export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { + const CODE_TOOLS_API_ENDPOINTS = { + aihubmix: { + gemini: { + api_base_url: 'https://api.aihubmix.com/gemini' + } + }, + deepseek: { + anthropic: { + api_base_url: 'https://api.deepseek.com/anthropic' + } + }, + moonshot: { + anthropic: { + api_base_url: 'https://api.moonshot.cn/anthropic' + } + }, + zhipu: { + anthropic: { + api_base_url: 'https://open.bigmodel.cn/api/anthropic' + } + } + } + + const provider = model.provider + + return CODE_TOOLS_API_ENDPOINTS[provider]?.[type]?.api_base_url +} + +// 解析环境变量字符串为对象 +export const parseEnvironmentVariables = (envVars: string): Record => { + const env: Record = {} + if (!envVars) return env + + const lines = envVars.split('\n') + for (const line of lines) { + const trimmedLine = line.trim() + if (trimmedLine && trimmedLine.includes('=')) { + const [key, ...valueParts] = trimmedLine.split('=') + const trimmedKey = key.trim() + const value = valueParts.join('=').trim() + if (trimmedKey) { + env[trimmedKey] = value + } + } + } + return env +} + +// 为不同 CLI 工具生成环境变量配置 +export const generateToolEnvironment = ({ + tool, + model, + modelProvider, + apiKey, + baseUrl +}: { + tool: codeTools + model: Model + modelProvider: Provider + apiKey: string + baseUrl: string +}): Record => { + const env: Record = {} + + switch (tool) { + case codeTools.claudeCode: + env.ANTHROPIC_BASE_URL = getCodeToolsApiBaseUrl(model, 'anthropic') || modelProvider.apiHost + env.ANTHROPIC_MODEL = model.id + if (modelProvider.type === 'anthropic') { + env.ANTHROPIC_API_KEY = apiKey + } else { + env.ANTHROPIC_AUTH_TOKEN = apiKey + } + break + + case codeTools.geminiCli: { + const apiBaseUrl = getCodeToolsApiBaseUrl(model, 'gemini') || modelProvider.apiHost + env.GEMINI_API_KEY = apiKey + env.GEMINI_BASE_URL = apiBaseUrl + env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl + env.GEMINI_MODEL = model.id + break + } + + case codeTools.qwenCode: + case codeTools.openaiCodex: + env.OPENAI_API_KEY = apiKey + env.OPENAI_BASE_URL = baseUrl + env.OPENAI_MODEL = model.id + break + } + + return env +} + export { default } from './CodeToolsPage' diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index d20accfd87..7d0857f2e9 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -22,7 +22,7 @@ let _stack: Route[] = ['topics'] let _topic: Topic | undefined let _message: Message | undefined -const TopicsPage: FC = () => { +const HistoryPage: FC = () => { const { t } = useTranslation() const [search, setSearch] = useState(_search) const [searchKeywords, setSearchKeywords] = useState(_search) @@ -52,7 +52,12 @@ const TopicsPage: FC = () => { setTopic(undefined) } - const onTopicClick = (topic: Topic) => { + // topic 不包含 messages,用到的时候才会获取 + const onTopicClick = (topic: Topic | null | undefined) => { + if (!topic) { + window.message.error(t('history.error.topic_not_found')) + return + } setStack((prev) => [...prev, 'topic']) setTopic(topic) } @@ -86,7 +91,7 @@ const TopicsPage: FC = () => { ) } - suffix={search.length >= 2 ? : null} + suffix={search.length ? : null} ref={inputRef} placeholder={t('history.search.placeholder')} value={search} @@ -146,4 +151,4 @@ const SearchIcon = styled.div` } ` -export default TopicsPage +export default HistoryPage diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 2fd299a388..a88e97dd0e 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -1,15 +1,23 @@ +import { LoadingIcon } from '@renderer/components/Icons' import db from '@renderer/databases' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { getTopicById } from '@renderer/hooks/useTopic' +import { selectTopicsMap } from '@renderer/store/assistants' import { Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' -import { List, Typography } from 'antd' +import { List, Spin, Typography } from 'antd' import { useLiveQuery } from 'dexie-react-hooks' -import { FC, memo, useCallback, useEffect, useState } from 'react' +import { FC, memo, useCallback, useEffect, useRef, useState } from 'react' +import { useSelector } from 'react-redux' import styled from 'styled-components' const { Text, Title } = Typography +type SearchResult = { + message: Message + topic: Topic + content: string +} + interface Props extends React.HTMLAttributes { keywords: string onMessageClick: (message: Message) => void @@ -18,6 +26,7 @@ interface Props extends React.HTMLAttributes { const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('SearchResults') + const observerRef = useRef(null) const [searchTerms, setSearchTerms] = useState( keywords @@ -27,9 +36,12 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p ) const topics = useLiveQuery(() => db.topics.toArray(), []) + // FIXME: db 中没有 topic.name 等信息,只能从 store 获取 + const storeTopicsMap = useSelector(selectTopicsMap) - const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([]) + const [searchResults, setSearchResults] = useState([]) const [searchStats, setSearchStats] = useState({ count: 0, time: 0 }) + const [isLoading, setIsLoading] = useState(false) const removeMarkdown = (text: string) => { return text @@ -44,33 +56,40 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p const onSearch = useCallback(async () => { setSearchResults([]) + setIsLoading(true) if (keywords.length === 0) { setSearchStats({ count: 0, time: 0 }) setSearchTerms([]) + setIsLoading(false) return } const startTime = performance.now() - const results: { message: Message; topic: Topic; content: string }[] = [] const newSearchTerms = keywords .toLowerCase() .split(' ') .filter((term) => term.length > 0) + const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i')) - const blocksArray = await db.message_blocks.toArray() - const blocks = blocksArray + const blocks = (await db.message_blocks.toArray()) .filter((block) => block.type === MessageBlockType.MAIN_TEXT) - .filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term))) + .filter((block) => searchRegexes.some((regex) => regex.test(block.content))) - const messages = topics?.map((topic) => topic.messages).flat() + const messages = topics?.flatMap((topic) => topic.messages) - for (const block of blocks) { - const message = messages?.find((message) => message.id === block.messageId) - if (message) { - results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content }) - } - } + const results = await Promise.all( + blocks.map(async (block) => { + const message = messages?.find((message) => message.id === block.messageId) + if (message) { + const topic = storeTopicsMap.get(message.topicId) + if (topic) { + return { message, topic, content: block.content } + } + } + return null + }) + ).then((results) => results.filter(Boolean) as SearchResult[]) const endTime = performance.now() setSearchResults(results) @@ -79,7 +98,8 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p time: (endTime - startTime) / 1000 }) setSearchTerms(newSearchTerms) - }, [keywords, topics]) + setIsLoading(false) + }, [keywords, storeTopicsMap, topics]) const highlightText = (text: string) => { let highlightedText = removeMarkdown(text) @@ -98,9 +118,24 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p onSearch() }, [onSearch]) + useEffect(() => { + if (!containerRef.current) return + + observerRef.current = new MutationObserver(() => { + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) + }) + + observerRef.current.observe(containerRef.current, { + childList: true, + subtree: true + }) + + return () => observerRef.current?.disconnect() + }, [containerRef]) + return ( - + }> {searchResults.length > 0 && ( Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds @@ -111,19 +146,15 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p dataSource={searchResults} pagination={{ pageSize: 10, - onChange: () => { - setTimeout(() => containerRef.current?.scrollTo({ top: 0 }), 0) - } + hideOnSinglePage: true }} + style={{ opacity: isLoading ? 0 : 1 }} renderItem={({ message, topic, content }) => ( { - const _topic = await getTopicById(topic.id) - onTopicClick(_topic) - }}> + onClick={() => onTopicClick(topic)}> {topic.name}
onMessageClick(message)}> @@ -136,24 +167,17 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p )} />
- + ) } const Container = styled.div` width: 100%; - padding: 20px; + height: 100%; + padding: 20px 36px; overflow-y: auto; display: flex; - flex-direction: row; - justify-content: center; -` - -const ContainerWrapper = styled.div` - width: 100%; - padding: 0 16px; - display: flex; flex-direction: column; ` @@ -164,6 +188,7 @@ const SearchStats = styled.div` const SearchResultTime = styled.div` margin-top: 10px; + text-align: right; ` export default memo(SearchResults) diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 8c98c458df..9f5111a254 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -4,18 +4,18 @@ import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' +import { getTopicById } from '@renderer/hooks/useTopic' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' import NavigationService from '@renderer/services/NavigationService' -import { useAppDispatch } from '@renderer/store' -import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' -import { classNames } from '@renderer/utils' +import { classNames, runAsyncFunction } from '@renderer/utils' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' import { Forward } from 'lucide-react' -import { FC, useEffect } from 'react' +import { FC, useEffect, useState } from 'react' import styled from 'styled-components' import { default as MessageItem } from '../../home/Messages/Message' @@ -24,15 +24,22 @@ interface Props extends React.HTMLAttributes { topic?: Topic } -const TopicMessages: FC = ({ topic, ...props }) => { +const TopicMessages: FC = ({ topic: _topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') - const dispatch = useAppDispatch() const { messageStyle } = useSettings() + const { setTimeoutTimer } = useTimer() + + const [topic, setTopic] = useState(_topic) useEffect(() => { - topic && dispatch(loadTopicMessagesThunk(topic.id)) - }, [dispatch, topic]) + if (!_topic) return + + runAsyncFunction(async () => { + const topic = await getTopicById(_topic.id) + setTopic(topic) + }) + }, [_topic, topic]) const isEmpty = (topic?.messages || []).length === 0 @@ -45,7 +52,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { SearchPopup.hide() const assistant = getAssistantById(topic.assistantId) navigate('/', { state: { assistant, topic } }) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) + setTimeoutTimer('onContinueChat', () => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) } return ( diff --git a/src/renderer/src/pages/history/components/TopicsHistory.tsx b/src/renderer/src/pages/history/components/TopicsHistory.tsx index 2051d536bf..37113891f2 100644 --- a/src/renderer/src/pages/history/components/TopicsHistory.tsx +++ b/src/renderer/src/pages/history/components/TopicsHistory.tsx @@ -1,14 +1,14 @@ import { SearchOutlined } from '@ant-design/icons' import { VStack } from '@renderer/components/Layout' -import { useAssistants } from '@renderer/hooks/useAssistant' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { getTopicById } from '@renderer/hooks/useTopic' +import { selectAllTopics } from '@renderer/store/assistants' import { Topic } from '@renderer/types' import { Button, Divider, Empty, Segmented } from 'antd' import dayjs from 'dayjs' import { groupBy, isEmpty, orderBy } from 'lodash' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import styled from 'styled-components' type SortType = 'createdAt' | 'updatedAt' @@ -20,18 +20,18 @@ type Props = { } & React.HTMLAttributes const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props }) => { - const { assistants } = useAssistants() const { t } = useTranslation() const { handleScroll, containerRef } = useScrollPosition('TopicsHistory') const [sortType, setSortType] = useState('createdAt') - const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc') + // FIXME: db 中没有 topic.name 等信息,只能从 store 获取 + const topics = useSelector(selectAllTopics) const filteredTopics = topics.filter((topic) => { return topic.name.toLowerCase().includes(keywords.toLowerCase()) }) - const groupedTopics = groupBy(filteredTopics, (topic) => { + const groupedTopics = groupBy(orderBy(filteredTopics, sortType, 'desc'), (topic) => { return dayjs(topic[sortType]).format('MM/DD') }) @@ -66,19 +66,14 @@ const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props {date} {items.map((topic) => ( - { - const _topic = await getTopicById(topic.id) - onClick(_topic) - }}> + onClick(topic)}> {topic.name.substring(0, 50)} {dayjs(topic[sortType]).format('HH:mm')} ))} ))} - {keywords.length >= 2 && ( + {keywords && (
- - - {(server: MCPServer) => ( -
navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}> - handleToggleActive(server, active)} - onDelete={() => onDeleteMcpServer(server)} - onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)} - onOpenUrl={(url) => window.open(url, '_blank')} - /> -
+ ( + handleToggleActive(server, active)} + onDelete={() => onDeleteMcpServer(server)} + onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)} + onOpenUrl={(url) => window.open(url, '_blank')} + /> )} -
- {mcpServers.length === 0 && ( + /> + {(mcpServers.length === 0 || filteredMcpServers.length === 0) && ( )} diff --git a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx index 0316975f46..cea47ae0e8 100644 --- a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { getAI302Token, saveAI302Token, syncAi302Servers } from './providers/302ai' +import { getBailianToken, saveBailianToken, syncBailianServers } from './providers/bailian' import { getTokenLanYunToken, LANYUN_KEY_HOST, saveTokenLanYunToken, syncTokenLanYunServers } from './providers/lanyun' import { getModelScopeToken, MODELSCOPE_HOST, saveModelScopeToken, syncModelScopeServers } from './providers/modelscope' import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_HOST } from './providers/tokenflux' @@ -69,6 +70,17 @@ const providers: ProviderConfig[] = [ getToken: getAI302Token, saveToken: saveAI302Token, syncServers: syncAi302Servers + }, + { + key: 'bailian', + name: '阿里云百炼', + description: '百炼平台服务', + discoverUrl: `https://bailian.console.aliyun.com/?tab=mcp#/mcp-market`, + apiKeyUrl: `https://bailian.console.aliyun.com/?tab=app#/api-key`, + tokenFieldName: 'bailianToken', + getToken: getBailianToken, + saveToken: saveBailianToken, + syncServers: syncBailianServers } ] diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/bailian.ts b/src/renderer/src/pages/settings/MCPSettings/providers/bailian.ts new file mode 100644 index 0000000000..8eecb2bac2 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/providers/bailian.ts @@ -0,0 +1,208 @@ +import { loggerService } from '@logger' +import { nanoid } from '@reduxjs/toolkit' +import type { MCPServer } from '@renderer/types' +import i18next from 'i18next' + +const logger = loggerService.withContext('BailianSyncUtils') + +// 常量定义 +export const BAILIAN_HOST = 'https://dashscope.aliyuncs.com' +const TOKEN_STORAGE_KEY = 'bailian_token' + +// Token 工具函数 +export const saveBailianToken = (token: string): void => { + localStorage.setItem(TOKEN_STORAGE_KEY, token) +} + +export const getBailianToken = (): string | null => { + const token = localStorage.getItem(TOKEN_STORAGE_KEY) + return token +} + +export const clearBailianToken = (): void => { + localStorage.removeItem(TOKEN_STORAGE_KEY) +} + +export const hasBailianToken = (): boolean => { + const hasToken = !!getBailianToken() + return hasToken +} + +// ========== 类型定义 ========== +export interface BailianServer { + id: string + name: string + description?: string + operationalUrl?: string + tags?: string[] + logoUrl?: string + providerUrl?: string + provider?: string + type?: 'streamableHttp' | 'sse' + active: boolean +} + +interface McpServerCherryDetailResponse { + success: boolean + message: string + requestId: string + total: number + data: BailianServer[] +} + +export interface BailianSyncResult { + success: boolean + message: string + addedServers: MCPServer[] + updatedServers: MCPServer[] + errorDetails?: string +} + +// ========== 拉取所有 MCP 服务 ========== +const PAGE_SIZE = 20 + +/** + * 拉取全部 MCP 服务器列表,分页封装 + * 抛出明确错误字符串,供 syncBailianServers 捕捉 + */ +async function fetchAllMcpServers(token: string): Promise { + const allServers: BailianServer[] = [] + let pageNum = 1 + let total = 0 + let length = 0 + + do { + const url = `${BAILIAN_HOST}/api/v1/mcps/user/list?pageNo=${pageNum}&pageSize=${PAGE_SIZE}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + + // ----- 错误处理(不再封装 Result,直接 throw,外层处理) ----- + if (response.status === 401 || response.status === 403) { + throw new Error('unauthorized') + } + if (response.status === 500) { + throw new Error('server_error') + } + if (!response.ok) { + throw new Error(`Status: ${response.status}`) + } + + const result: McpServerCherryDetailResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Fetch failed') + } + + allServers.push(...(result.data || [])) + length = result.data.length + total = result.total || 0 + pageNum++ + } while ((pageNum - 1) * PAGE_SIZE < total && length > 0) + + return allServers +} + +// ========== 主同步函数 ========== +export const syncBailianServers = async (token: string, existingServers: MCPServer[]): Promise => { + const t = i18next.t + + try { + const servers = await fetchAllMcpServers(token) + + const addedServers: MCPServer[] = [] + const updatedServers: MCPServer[] = [] + + for (const server of servers) { + try { + if (!server.operationalUrl) { + continue + } + + const id = `@bailian/${server.id}` + const existingServer = existingServers.find((s) => s.id === id) + + const mcpServer: MCPServer = { + id, + name: server.name || `Bailian Server ${nanoid()}`, + description: server.description || '', + type: server.type, + baseUrl: server.operationalUrl, + command: '', + args: [], + env: {}, + isActive: server.active, + provider: server.provider, + providerUrl: server.providerUrl, + logoUrl: server.logoUrl || '', + tags: server.tags || [], + headers: { + Authorization: `Bearer ${token}` + } + } + + if (existingServer) { + updatedServers.push(mcpServer) + } else { + addedServers.push(mcpServer) + } + } catch (err) { + logger.error(`Error processing Bailian server ${server.id}:`, err as Error) + } + } + + const totalServers = addedServers.length + updatedServers.length + + return { + success: true, + message: t('settings.mcp.sync.success', { count: totalServers }), + addedServers, + updatedServers + } + } catch (error) { + let message = '' + let errorDetails: string | undefined = undefined + + if (error instanceof Error && error.message === 'unauthorized') { + clearBailianToken() + message = t('settings.mcp.sync.unauthorized', 'Sync Unauthorized') + logger.error('Unauthorized access during sync') + return { + success: false, + message, + addedServers: [], + updatedServers: [] + } + } + + if (error instanceof Error && error.message === 'server_error') { + message = t('settings.mcp.sync.error') + errorDetails = 'Status: 500' + logger.error('Server error during sync') + return { + success: false, + message, + addedServers: [], + updatedServers: [], + errorDetails + } + } + + // 其他情况 + logger.error('Bailian sync error:', error as Error) + message = t('settings.mcp.sync.error') + errorDetails = String(error) + return { + success: false, + message, + addedServers: [], + updatedServers: [], + errorDetails + } + } +} diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index c8575d67ff..57620bd379 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -1,10 +1,12 @@ import { loggerService } from '@logger' import { Center, VStack } from '@renderer/components/Layout' +import ProviderLogoPicker from '@renderer/components/ProviderLogoPicker' import { TopView } from '@renderer/components/TopView' +import { PROVIDER_LOGO_MAP } from '@renderer/config/providers' import ImageStorage from '@renderer/services/ImageStorage' import { Provider, ProviderType } from '@renderer/types' -import { compressImage } from '@renderer/utils' -import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd' +import { compressImage, generateColorFromChar, getForegroundColor } from '@renderer/utils' +import { Divider, Dropdown, Form, Input, Modal, Popover, Select, Upload } from 'antd' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -21,6 +23,7 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { const [name, setName] = useState(provider?.name || '') const [type, setType] = useState(provider?.type || 'openai') const [logo, setLogo] = useState(null) + const [logoPickerOpen, setLogoPickerOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() @@ -63,6 +66,25 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { const buttonDisabled = name.length === 0 + // 处理内置头像的点击事件 + const handleProviderLogoClick = async (providerId: string) => { + try { + const logoUrl = PROVIDER_LOGO_MAP[providerId] + + if (provider?.id) { + await ImageStorage.set(`provider-${provider.id}`, logoUrl) + const savedLogo = await ImageStorage.get(`provider-${provider.id}`) + setLogo(savedLogo) + } else { + setLogo(logoUrl) + } + + setLogoPickerOpen(false) + } catch (error: any) { + window.message.error(error.message) + } + } + const handleReset = async () => { try { setLogo(null) @@ -78,7 +100,7 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { } const getInitials = () => { - return name.charAt(0).toUpperCase() || 'P' + return name.charAt(0) || 'P' } const items = [ @@ -131,6 +153,20 @@ const PopupContainer: React.FC = ({ provider, resolve }) => {
) }, + { + key: 'builtin', + label: ( +
{ + e.stopPropagation() + setDropdownOpen(false) + setLogoPickerOpen(true) + }}> + {t('settings.general.avatar.builtin')} +
+ ) + }, { key: 'reset', label: ( @@ -146,6 +182,10 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { } ] + // for logo + const backgroundColor = generateColorFromChar(name) + const color = name ? getForegroundColor(backgroundColor) : 'white' + return ( = ({ provider, resolve }) => { placement="bottom" onOpenChange={(visible) => { setDropdownOpen(visible) + if (visible) { + setLogoPickerOpen(false) + } }}> - {logo ? : {getInitials()}} + } + trigger="click" + open={logoPickerOpen} + onOpenChange={(visible) => { + setLogoPickerOpen(visible) + if (visible) { + setDropdownOpen(false) + } + }} + placement="bottom"> + {logo ? ( + + ) : ( + + {getInitials()} + + )} + diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx index 7dfa6070e1..fce4ad0637 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx @@ -6,7 +6,7 @@ import { ToolsCallingTag, VisionTag, WebSearchTag -} from '@renderer/components/Tags/ModelCapabilities' +} from '@renderer/components/Tags/Model' import WarnTooltip from '@renderer/components/WarnTooltip' import { endpointTypeOptions } from '@renderer/config/endpointTypes' import { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx index 6f32841434..036894dbee 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx @@ -18,7 +18,8 @@ import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/Model import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup' import { fetchModels } from '@renderer/services/ApiService' import { Model, Provider } from '@renderer/types' -import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName, isFreeModel } from '@renderer/utils' +import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName } from '@renderer/utils' +import { isFreeModel } from '@renderer/utils/model' import { Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { groupBy, isEmpty, uniqBy } from 'lodash' diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx index 14832e5aee..58468f09bb 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx @@ -106,7 +106,11 @@ const ModelList: React.FC = ({ providerId }) => { {modelCount} )} - + diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 8c496e64a1..fbb2806f01 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -6,6 +6,7 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { PROVIDER_URLS } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList' import { checkApi } from '@renderer/services/ApiService' @@ -53,6 +54,7 @@ const ProviderSetting: FC = ({ providerId }) => { const [apiVersion, setApiVersion] = useState(provider.apiVersion) const { t } = useTranslation() const { theme } = useTheme() + const { setTimeoutTimer } = useTimer() const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -170,9 +172,13 @@ const ProviderSetting: FC = ({ providerId }) => { }) setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.SUCCESS })) - setTimeout(() => { - setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED })) - }, 3000) + setTimeoutTimer( + 'onCheckApi', + () => { + setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED })) + }, + 3000 + ) } catch (error: any) { window.message.error({ key: 'api-check', diff --git a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx index b419cb5e5d..cf65c42448 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx @@ -1,6 +1,7 @@ import ModelSelector from '@renderer/components/ModelSelector' import { TopView } from '@renderer/components/TopView' import { isRerankModel } from '@renderer/config/models' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { getModelUniqId } from '@renderer/services/ModelService' import { Model, Provider } from '@renderer/types' @@ -19,6 +20,7 @@ interface Props extends ShowParams { const PopupContainer: React.FC = ({ provider, resolve, reject }) => { const [open, setOpen] = useState(true) + const { setTimeoutTimer } = useTimer() // Keep the natural order of models const models = useMemo(() => provider.models.filter((m) => !isRerankModel(m)), [provider]) @@ -42,7 +44,7 @@ const PopupContainer: React.FC = ({ provider, resolve, reject }) => { const onCancel = () => { setOpen(false) - setTimeout(reject, 300) + setTimeoutTimer('onCancel', reject, 300) } const onClose = () => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 5fdfaecd64..9982d35c2e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -1,9 +1,14 @@ import { DropResult } from '@hello-pangea/dnd' import { loggerService } from '@logger' -import { DraggableVirtualList, useDraggableReorder } from '@renderer/components/DraggableList' -import { DeleteIcon, EditIcon } from '@renderer/components/Icons' +import { + DraggableVirtualList, + type DraggableVirtualListRef, + useDraggableReorder +} from '@renderer/components/DraggableList' +import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons' import { getProviderLogo } from '@renderer/config/providers' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' +import { useTimer } from '@renderer/hooks/useTimer' import { getProviderLabel } from '@renderer/i18n/label' import ImageStorage from '@renderer/services/ImageStorage' import { isSystemProvider, Provider, ProviderType } from '@renderer/types' @@ -11,13 +16,14 @@ import { generateColorFromChar, getFancyProviderName, getFirstCharacter, + getForegroundColor, matchKeywordsInModel, matchKeywordsInProvider, uuid } from '@renderer/utils' import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd' import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react' -import { FC, startTransition, useCallback, useEffect, useState } from 'react' +import { FC, startTransition, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router-dom' import styled from 'styled-components' @@ -34,11 +40,13 @@ const ProvidersList: FC = () => { const [searchParams] = useSearchParams() const providers = useAllProviders() const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders() + const { setTimeoutTimer } = useTimer() const [selectedProvider, _setSelectedProvider] = useState(providers[0]) const { t } = useTranslation() const [searchText, setSearchText] = useState('') const [dragging, setDragging] = useState(false) const [providerLogos, setProviderLogos] = useState>({}) + const listRef = useRef(null) const setSelectedProvider = useCallback( (provider: Provider) => { @@ -74,11 +82,20 @@ const ProvidersList: FC = () => { const provider = providers.find((p) => p.id === providerId) if (provider) { setSelectedProvider(provider) + // 滚动到选中的 provider + const index = providers.findIndex((p) => p.id === providerId) + if (index >= 0) { + setTimeoutTimer( + 'scroll-to-selected-provider', + () => listRef.current?.scrollToIndex(index, { align: 'center' }), + 100 + ) + } } else { setSelectedProvider(providers[0]) } } - }, [providers, searchParams, setSelectedProvider]) + }, [providers, searchParams, setSelectedProvider, setTimeoutTimer]) // Handle provider add key from URL schema useEffect(() => { @@ -327,7 +344,7 @@ const ProvidersList: FC = () => { if (name) { updateProvider({ ...provider, name, type }) if (provider.id) { - if (logoFile && logo) { + if (logo) { try { await ImageStorage.set(`provider-${provider.id}`, logo) setProviderLogos((prev) => ({ @@ -406,22 +423,31 @@ const ProvidersList: FC = () => { } } - const getProviderAvatar = (provider: Provider) => { + const getProviderAvatar = (provider: Provider, size: number = 25) => { + // 特殊处理一下svg格式 + if (isSystemProvider(provider)) { + switch (provider.id) { + case 'poe': + return + } + } + const logoSrc = getProviderLogo(provider.id) if (logoSrc) { - return + return } const customLogo = providerLogos[provider.id] if (customLogo) { - return + return } + // generate color for custom provider + const backgroundColor = generateColorFromChar(provider.name) + const color = provider.name ? getForegroundColor(backgroundColor) : 'white' + return ( - + {getFirstCharacter(provider.name)} ) @@ -475,6 +501,7 @@ const ProvidersList: FC = () => { /> { {t('memory.title')} - - + + {t('settings.tool.preprocess.title')} @@ -144,7 +144,7 @@ const SettingsPage: FC = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 4eb91e9c05..834f5e283a 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout' import { isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useShortcuts } from '@renderer/hooks/useShortcuts' +import { useTimer } from '@renderer/hooks/useTimer' import { getShortcutLabel } from '@renderer/i18n/label' import { useAppDispatch } from '@renderer/store' import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts' @@ -22,6 +23,7 @@ const ShortcutSettings: FC = () => { const { shortcuts: originalShortcuts } = useShortcuts() const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) + const { setTimeoutTimer } = useTimer() //if shortcut is not available on all the platforms, block the shortcut here let shortcuts = originalShortcuts @@ -42,9 +44,13 @@ const ShortcutSettings: FC = () => { const handleAddShortcut = (record: Shortcut) => { setEditingKey(record.key) - setTimeout(() => { - inputRefs.current[record.key]?.focus() - }, 0) + setTimeoutTimer( + 'handleAddShortcut', + () => { + inputRefs.current[record.key]?.focus() + }, + 0 + ) } const isShortcutModified = (record: Shortcut) => { diff --git a/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx b/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx index 6908d89ab3..7c6ff0f78e 100644 --- a/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx +++ b/src/renderer/src/pages/settings/TranslateSettingsPopup/TranslateSettingsPopup.tsx @@ -40,8 +40,9 @@ const PopupContainer: React.FC = ({ resolve }) => { afterClose={onClose} transitionName="animation-move-down" width="80vw" + footer={null} centered> - + diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx index 8aa7783a44..de94587e2f 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' +import { useTimer } from '@renderer/hooks/useTimer' import { useBlacklist } from '@renderer/hooks/useWebSearchProviders' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setExcludeDomains } from '@renderer/store/websearch' @@ -47,6 +48,7 @@ const BlacklistSettings: FC = () => { name: source.name })) || [] ) + const { setTimeoutTimer } = useTimer() const dispatch = useAppDispatch() @@ -148,7 +150,7 @@ const BlacklistSettings: FC = () => { content: t('settings.tool.websearch.subscribe_update_success'), duration: 2 }) - setTimeout(() => setSubscribeValid(false), 3000) + setTimeoutTimer('updateSubscribe', () => setSubscribeValid(false), 3000) } else { setSubscribeValid(false) throw new Error('No valid sources updated') @@ -190,7 +192,7 @@ const BlacklistSettings: FC = () => { content: t('settings.tool.websearch.subscribe_add_success'), duration: 2 }) - setTimeout(() => setSubscribeValid(false), 3000) + setTimeoutTimer('handleAddSubscribe', () => setSubscribeValid(false), 3000) } catch (error) { setSubscribeValid(false) window.message.error({ diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 5c6faa7b78..d719b38a3e 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -6,6 +6,7 @@ import SearxngLogo from '@renderer/assets/images/search/searxng.svg' import TavilyLogo from '@renderer/assets/images/search/tavily.png' import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup' import { WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' +import { useTimer } from '@renderer/hooks/useTimer' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProviderId } from '@renderer/types' @@ -33,6 +34,7 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '') const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '') const [apiValid, setApiValid] = useState(false) + const { setTimeoutTimer } = useTimer() const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey @@ -125,7 +127,7 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { }) } finally { setApiChecking(false) - setTimeout(() => setApiValid(false), 2500) + setTimeoutTimer('checkSearch', () => setApiValid(false), 2500) } } diff --git a/src/renderer/src/pages/translate/TranslateHistory.tsx b/src/renderer/src/pages/translate/TranslateHistory.tsx index 1b65285b72..5930fa9e64 100644 --- a/src/renderer/src/pages/translate/TranslateHistory.tsx +++ b/src/renderer/src/pages/translate/TranslateHistory.tsx @@ -1,49 +1,121 @@ -import { DeleteOutlined } from '@ant-design/icons' +import { DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' import { DynamicVirtualList } from '@renderer/components/VirtualList' import db from '@renderer/databases' import useTranslate from '@renderer/hooks/useTranslate' -import { clearHistory, deleteHistory } from '@renderer/services/TranslateService' +import { clearHistory, deleteHistory, updateTranslateHistory } from '@renderer/services/TranslateService' import { TranslateHistory, TranslateLanguage } from '@renderer/types' -import { Button, Drawer, Dropdown, Empty, Flex, Popconfirm } from 'antd' +import { Button, Drawer, Empty, Flex, Input, Popconfirm } from 'antd' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' import { isEmpty } from 'lodash' -import { FC, useMemo } from 'react' +import { SearchIcon } from 'lucide-react' +import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -type DisplayedTranslateHistory = TranslateHistory & { +type DisplayedTranslateHistoryItem = TranslateHistory & { _sourceLanguage: TranslateLanguage _targetLanguage: TranslateLanguage } type TranslateHistoryProps = { isOpen: boolean - onHistoryItemClick: (history: DisplayedTranslateHistory) => void + onHistoryItemClick: (history: DisplayedTranslateHistoryItem) => void onClose: () => void } +// const logger = loggerService.withContext('TranslateHistory') + // px -const ITEM_HEIGHT = 140 +const ITEM_HEIGHT = 160 const TranslateHistoryList: FC = ({ isOpen, onHistoryItemClick, onClose }) => { const { t } = useTranslation() const { getLanguageByLangcode } = useTranslate() const _translateHistory = useLiveQuery(() => db.translate_history.orderBy('createdAt').reverse().toArray(), []) + const [search, setSearch] = useState('') + const [displayedHistory, setDisplayedHistory] = useState([]) + const [showStared, setShowStared] = useState(false) - const translateHistory: DisplayedTranslateHistory[] = useMemo(() => { + const translateHistory: DisplayedTranslateHistoryItem[] = useMemo(() => { if (!_translateHistory) return [] return _translateHistory.map((item) => ({ ...item, _sourceLanguage: getLanguageByLangcode(item.sourceLanguage), - _targetLanguage: getLanguageByLangcode(item.targetLanguage) + _targetLanguage: getLanguageByLangcode(item.targetLanguage), + createdAt: dayjs(item.createdAt).format('MM/DD HH:mm') })) }, [_translateHistory, getLanguageByLangcode]) + const searchFilter = useCallback( + (item: DisplayedTranslateHistoryItem) => { + if (isEmpty(search)) return true + const content = `${item._sourceLanguage.label()} ${item._targetLanguage.label()} ${item.sourceText} ${item.targetText} ${item.createdAt}` + return content.includes(search) + }, + [search] + ) + + const starFilter = useMemo( + () => (showStared ? (item: DisplayedTranslateHistoryItem) => !!item.star : () => true), + [showStared] + ) + + const finalFilter = useCallback( + (item: DisplayedTranslateHistoryItem) => searchFilter(item) && starFilter(item), + [searchFilter, starFilter] + ) + + const handleStar = useCallback( + (id: string) => { + const origin = translateHistory.find((item) => item.id === id) + if (!origin) { + return + } + updateTranslateHistory(id, { star: !origin.star }) + }, + [translateHistory] + ) + + const handleDelete = useCallback( + (id: string) => { + try { + deleteHistory(id) + } catch (e) { + window.message.error(t('translate.history.error.delete')) + } + }, + [t] + ) + + useEffect(() => { + setDisplayedHistory(translateHistory.filter(finalFilter)) + }, [finalFilter, translateHistory]) + + const Title = () => { + return ( + + {t('translate.history.title')} +