diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index 170c4ca90..82058ec2a 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -1,5 +1,8 @@
name: Pull Request CI
+permissions:
+ contents: read
+
on:
workflow_dispatch:
pull_request:
@@ -42,8 +45,14 @@ jobs:
- name: Install Dependencies
run: yarn install
- - name: Build Check
- run: yarn build:check
-
- name: Lint Check
run: yarn test:lint
+
+ - name: Type Check
+ run: yarn typecheck
+
+ - name: i18n Check
+ run: yarn check:i18n
+
+ - name: Test
+ run: yarn test
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 1519379f6..25f24dac6 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/antd-npm-5.26.7-029c5c381a.patch b/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch
similarity index 100%
rename from .yarn/patches/antd-npm-5.26.7-029c5c381a.patch
rename to .yarn/patches/antd-npm-5.27.0-aa91c36546.patch
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 000000000..0cb156ee9
--- /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/electron-builder.yml b/electron-builder.yml
index 68b60cb5f..9fb0244bf 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -116,9 +116,11 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
- 支持 GPT-5 模型
- 新增代码工具,支持快速启动 Qwen Code, Gemini Cli, Claude Code
- 翻译页面改版,支持更多设置
- 支持保存整个话题到知识库
- 坚果云备份支持设置最大备份数量
+ 输入框快捷菜单增加清除按钮
+ 侧边栏增加代码工具入口,代码工具增加环境变量设置
+ 小程序增加多语言显示
+ 优化 MCP 服务器列表
+ 新增 Web 搜索图标
+ 优化 SVG 预览,优化 HTML 内容样式
+ 修复知识库文档预处理失败问题
稳定性改进和错误修复
diff --git a/package.json b/package.json
index 08ac0f539..0f851ed2e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
- "version": "1.5.6",
+ "version": "1.5.7-rc.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -78,7 +78,9 @@
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
- "selection-hook": "^1.0.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": {
@@ -107,7 +109,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",
@@ -150,7 +155,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",
@@ -158,10 +163,10 @@
"@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",
- "@vitejs/plugin-react-swc": "^3.10.2",
+ "@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",
"@vitest/ui": "^3.2.4",
@@ -170,7 +175,7 @@
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.24",
- "antd": "patch:antd@npm%3A5.26.7#~/.yarn/patches/antd-npm-5.26.7-029c5c381a.patch",
+ "antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"axios": "^1.7.3",
@@ -186,7 +191,7 @@
"diff": "^7.0.0",
"docx": "^9.0.2",
"dotenv-cli": "^7.4.2",
- "electron": "37.2.3",
+ "electron": "37.3.1",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-store": "^8.2.0",
@@ -210,6 +215,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",
@@ -231,9 +237,9 @@
"prettier": "^3.5.3",
"prettier-plugin-sort-json": "^4.1.1",
"proxy-agent": "^6.5.0",
- "rc-virtual-list": "^3.18.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
@@ -249,7 +255,9 @@
"reflect-metadata": "0.2.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0",
+ "rehype-parse": "^9.0.1",
"rehype-raw": "^7.0.0",
+ "rehype-stringify": "^10.0.1",
"remark-cjk-friendly": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-github-blockquote-alert": "^2.0.0",
@@ -279,21 +287,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/aiCore/src/core/runtime/pluginEngine.ts b/packages/aiCore/src/core/runtime/pluginEngine.ts
index 7e43c9c01..17e0c1de7 100644
--- a/packages/aiCore/src/core/runtime/pluginEngine.ts
+++ b/packages/aiCore/src/core/runtime/pluginEngine.ts
@@ -3,7 +3,7 @@ import { ImageModelV2 } from '@ai-sdk/provider'
import { LanguageModel } from 'ai'
import { type AiPlugin, createContext, PluginManager } from '../plugins'
-import { type ProviderId, type ProviderSettingsMap } from '../providers/types'
+import { type ProviderId } from '../providers/types'
/**
* 插件增强的 AI 客户端
@@ -228,15 +228,4 @@ export class PluginEngine {
throw error
}
}
- // === 静态工厂方法 ===
-
- /**
- * 创建 OpenAI Compatible 客户端
- */
- static createOpenAICompatible(
- config: ProviderSettingsMap['openai-compatible'],
- plugins: AiPlugin[] = []
- ): PluginEngine<'openai-compatible'> {
- return new PluginEngine('openai-compatible', plugins)
- }
}
diff --git a/packages/aiCore/src/utils/model.ts b/packages/aiCore/src/utils/model.ts
index 85800eea1..4a37fe123 100644
--- a/packages/aiCore/src/utils/model.ts
+++ b/packages/aiCore/src/utils/model.ts
@@ -1,4 +1,4 @@
-import { type ProviderId } from '../types'
+import { type ProviderId } from '../core/providers/types'
export function isOpenAIChatCompletionOnlyModel(modelId: string): boolean {
if (!modelId) {
diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts
index bae5c54a7..f2b856ef1 100644
--- a/packages/shared/IpcChannel.ts
+++ b/packages/shared/IpcChannel.ts
@@ -35,6 +35,7 @@ export enum IpcChannel {
App_InstallBunBinary = 'app:install-bun-binary',
App_LogToMain = 'app:log-to-main',
App_SaveData = 'app:save-data',
+ App_SetFullScreen = 'app:set-full-screen',
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
@@ -155,7 +156,9 @@ export enum IpcChannel {
File_Base64File = 'file:base64File',
File_GetPdfInfo = 'file:getPdfInfo',
Fs_Read = 'fs:read',
+ Fs_ReadText = 'fs:readText',
File_OpenWithRelativePath = 'file:openWithRelativePath',
+ File_IsTextFile = 'file:isTextFile',
// file service
FileService_Upload = 'file-service:upload',
@@ -279,5 +282,8 @@ export enum IpcChannel {
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
// CodeTools
- CodeTools_Run = 'code-tools:run'
+ CodeTools_Run = 'code-tools:run',
+
+ // OCR
+ OCR_ocr = 'ocr:ocr'
}
diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts
index 17304f357..82b78459a 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 c676823b8..5a6f667d1 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 98d384c7c..20ccf06d7 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -30,6 +30,7 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic
import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
+import { ocrService } from './services/ocr/OcrService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -71,7 +72,7 @@ const dxtService = new DxtService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
- const notificationService = new NotificationService(mainWindow)
+ const notificationService = new NotificationService()
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
@@ -191,6 +192,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
}
+ ipcMain.handle(IpcChannel.App_SetFullScreen, (_, value: boolean): void => {
+ mainWindow.setFullScreen(value)
+ })
+
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
@@ -440,6 +445,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
+ ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@@ -464,6 +470,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService))
+ ipcMain.handle(IpcChannel.Fs_ReadText, FileService.readTextFileWithAutoEncoding.bind(FileService))
// export
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
@@ -704,4 +711,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// CodeTools
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
+
+ // OCR
+ ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters) => ocrService.ocr(...args))
}
diff --git a/src/main/knowledge/preprocess/BasePreprocessProvider.ts b/src/main/knowledge/preprocess/BasePreprocessProvider.ts
index 7981e6f13..daf990149 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 834ff2f27..6708e8f93 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 fe1269cec..46d3bb87d 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 3ed00dae1..6cc8a41b0 100644
--- a/src/main/services/CodeToolsService.ts
+++ b/src/main/services/CodeToolsService.ts
@@ -3,9 +3,11 @@ import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
+import { isWin } from '@main/constant'
import { removeEnvProxy } from '@main/utils'
import { isUserInChina } from '@main/utils/ipService'
import { getBinaryName } from '@main/utils/process'
+import { codeTools } from '@shared/config/constant'
import { spawn } from 'child_process'
import { promisify } from 'util'
@@ -40,23 +42,33 @@ class CodeToolsService {
}
public async getPackageName(cliTool: string) {
- if (cliTool === 'claude-code') {
- return '@anthropic-ai/claude-code'
+ switch (cliTool) {
+ case codeTools.claudeCode:
+ return '@anthropic-ai/claude-code'
+ case codeTools.geminiCli:
+ return '@google/gemini-cli'
+ case codeTools.openaiCodex:
+ return '@openai/codex'
+ case codeTools.qwenCode:
+ return '@qwen-code/qwen-code'
+ default:
+ throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
- if (cliTool === 'gemini-cli') {
- return '@google/gemini-cli'
- }
- return '@qwen-code/qwen-code'
}
public async getCliExecutableName(cliTool: string) {
- if (cliTool === 'claude-code') {
- return 'claude'
+ switch (cliTool) {
+ case codeTools.claudeCode:
+ return 'claude'
+ case codeTools.geminiCli:
+ return 'gemini'
+ case codeTools.openaiCodex:
+ return 'codex'
+ case codeTools.qwenCode:
+ return 'qwen'
+ default:
+ throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
- if (cliTool === 'gemini-cli') {
- return 'gemini'
- }
- return 'qwen'
}
private async isPackageInstalled(cliTool: string): Promise {
@@ -114,9 +126,21 @@ class CodeToolsService {
} else {
logger.info(`Fetching latest version for ${packageName} from npm`)
try {
- const bunPath = await this.getBunPath()
- const { stdout } = await execAsync(`"${bunPath}" info ${packageName} version`, { timeout: 15000 })
- latestVersion = stdout.trim().replace(/["']/g, '')
+ // Get registry URL
+ const registryUrl = await this.getNpmRegistryUrl()
+
+ // Fetch package info directly from npm registry API
+ const packageUrl = `${registryUrl}/${packageName}/latest`
+ const response = await fetch(packageUrl, {
+ signal: AbortSignal.timeout(15000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch package info: ${response.statusText}`)
+ }
+
+ const packageInfo = await response.json()
+ latestVersion = packageInfo.version
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
@@ -283,12 +307,11 @@ class CodeToolsService {
}
// Build command to execute
- let baseCommand: string
+ let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) {
// If already installed, run executable directly (with optional update message)
- baseCommand = `"${executablePath}"`
if (updateMessage) {
baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}`
}
@@ -300,8 +323,8 @@ class CodeToolsService {
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
- const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
- baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && "${executablePath}"`
+ const installCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
+ baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
}
switch (platform) {
@@ -314,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 f5c773a7c..3efaafa73 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 f5df9ed3f..39a16713d 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 47e897e15..2cd0d5aeb 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 50b5e9b15..538a0ede7 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)
@@ -630,6 +629,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 5ba0d82ce..2ceb12ee4 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 620a6a5fe..860324bc6 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 95e9d8b1b..8a4e42099 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 12f786d79..97216e6a6 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 185901322..66b4b8d95 100644
--- a/src/main/services/WindowService.ts
+++ b/src/main/services/WindowService.ts
@@ -223,26 +223,26 @@ export class WindowService {
})
// 添加Escape键退出全屏的支持
- mainWindow.webContents.on('before-input-event', (event, input) => {
- // 当按下Escape键且窗口处于全屏状态时退出全屏
- if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
- if (mainWindow.isFullScreen()) {
- // 获取 shortcuts 配置
- const shortcuts = configManager.getShortcuts()
- const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
- if (exitFullscreenShortcut == undefined) {
- mainWindow.setFullScreen(false)
- return
- }
- if (exitFullscreenShortcut?.enabled) {
- event.preventDefault()
- mainWindow.setFullScreen(false)
- return
- }
- }
- }
- return
- })
+ // mainWindow.webContents.on('before-input-event', (event, input) => {
+ // // 当按下Escape键且窗口处于全屏状态时退出全屏
+ // if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
+ // if (mainWindow.isFullScreen()) {
+ // // 获取 shortcuts 配置
+ // const shortcuts = configManager.getShortcuts()
+ // const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
+ // if (exitFullscreenShortcut == undefined) {
+ // mainWindow.setFullScreen(false)
+ // return
+ // }
+ // if (exitFullscreenShortcut?.enabled) {
+ // event.preventDefault()
+ // mainWindow.setFullScreen(false)
+ // return
+ // }
+ // }
+ // }
+ // return
+ // })
}
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
@@ -555,9 +555,9 @@ export class WindowService {
// [Windows] hacky fix
// the window is minimized only when in Windows platform
- // because it's a workround for Windows, see `hideMiniWindow()`
+ // because it's a workaround for Windows, see `hideMiniWindow()`
if (this.miniWindow?.isMinimized()) {
- // don't let the window being seen before we finish adusting the position across screens
+ // don't let the window being seen before we finish adjusting the position across screens
this.miniWindow?.setOpacity(0)
// DO NOT use `restore()` here, Electron has the bug with screens of different scale factor
// We have to use `show()` here, then set the position and bounds
diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts
new file mode 100644
index 000000000..6ac8c311e
--- /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 000000000..d2ba6d2ed
--- /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 dc6af193f..150a28eac 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 000000000..ca63e82f0
--- /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 9b4657647..49c40d116 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -17,9 +17,12 @@ import {
MemoryConfig,
MemoryListOptions,
MemorySearchOptions,
+ OcrProvider,
+ OcrResult,
Provider,
S3Config,
Shortcut,
+ SupportedOcrFile,
ThemeMode,
WebDavConfig
} from '@types'
@@ -76,6 +79,7 @@ const api = {
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
logToMain: (source: LogSourceWithContext, level: LogLevel, message: string, data: any[]) =>
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
+ setFullScreen: (value: boolean): Promise => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
mac: {
isProcessTrusted: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
@@ -132,14 +136,15 @@ const api = {
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
},
file: {
- select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
+ select: (options?: OpenDialogOptions): Promise =>
+ 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 文件名
@@ -169,10 +174,12 @@ const api = {
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
- openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
+ openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
+ isTextFile: (filePath: string): Promise => 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)
@@ -295,7 +302,8 @@ const api = {
return ipcRenderer.invoke(IpcChannel.Mcp_UploadDxt, buffer, file.name)
},
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
- getServerVersion: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
+ getServerVersion: (server: MCPServer): Promise =>
+ ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
},
python: {
execute: (script: string, context?: Record, timeout?: number) =>
@@ -403,6 +411,10 @@ const api = {
env: Record,
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)
}
}
diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx
index 627fb3754..36d045aae 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/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
index 54242d12e..825960850 100644
--- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
+++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
@@ -5,7 +5,7 @@
import { TextStreamPart, ToolSet } from '@cherrystudio/ai-core'
import { loggerService } from '@logger'
-import { BaseTool, WebSearchResults, WebSearchSource } from '@renderer/types'
+import { MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -30,7 +30,7 @@ export class AiSdkToChunkAdapter {
toolCallHandler: ToolCallChunkHandler
constructor(
private onChunk: (chunk: Chunk) => void,
- private mcpTools: BaseTool[] = []
+ mcpTools: MCPTool[] = []
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
}
diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts
index 746768592..78a0a1533 100644
--- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts
+++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts
@@ -32,7 +32,7 @@ export class ToolCallChunkHandler {
>()
constructor(
private onChunk: (chunk: Chunk) => void,
- private mcpTools: BaseTool[]
+ private mcpTools: MCPTool[]
) {}
// /**
diff --git a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts
index 958fd7668..42c511858 100644
--- a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts
+++ b/src/renderer/src/aiCore/legacy/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/legacy/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts
index 5539d9c94..153b53a36 100644
--- a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts
+++ b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts
@@ -51,6 +51,7 @@ import {
GeminiSdkRawOutput,
GeminiSdkToolCall
} from '@renderer/types/sdk'
+import { isToolUseModeFunction } from '@renderer/utils/assistant'
import {
geminiFunctionCallToMcpTool,
isSupportedToolUse,
@@ -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/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts
index faba24c1a..feebf487e 100644
--- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts
+++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts
@@ -5,10 +5,14 @@ import {
GEMINI_FLASH_MODEL_REGEX,
getOpenAIWebSearchParams,
getThinkModelType,
+ isClaudeReasoningModel,
+ isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
+ isGeminiReasoningModel,
isGPT5SeriesModel,
isGrokReasoningModel,
isNotSupportSystemMessageModel,
+ isOpenAIOpenWeightModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
isQwenMTModel,
@@ -24,7 +28,8 @@ import {
isSupportedThinkingTokenQwenModel,
isSupportedThinkingTokenZhipuModel,
isVisionModel,
- MODEL_SUPPORTED_REASONING_EFFORT
+ MODEL_SUPPORTED_REASONING_EFFORT,
+ ZHIPU_RESULT_TOKENS
} from '@renderer/config/models'
import {
isSupportArrayContentProvider,
@@ -40,12 +45,14 @@ import {
Assistant,
EFFORT_RATIO,
FileTypes,
+ isSystemProvider,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
Model,
OpenAIServiceTier,
Provider,
+ SystemProviderIds,
ToolCallResponse,
TranslateAssistant,
WebSearchSource
@@ -108,7 +115,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
*/
// Method for reasoning effort, moved from OpenAIProvider
override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams {
- if (this.provider.id === 'groq') {
+ if (this.provider.id === SystemProviderIds.groq) {
return {}
}
@@ -117,22 +124,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
const reasoningEffort = assistant?.settings?.reasoning_effort
- // Doubao 思考模式支持
- if (isSupportedThinkingTokenDoubaoModel(model)) {
- // reasoningEffort 为空,默认开启 enabled
- if (!reasoningEffort) {
- return { thinking: { type: 'disabled' } }
- }
- if (reasoningEffort === 'high') {
- return { thinking: { type: 'enabled' } }
- }
- if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
- return { thinking: { type: 'auto' } }
- }
- // 其他情况不带 thinking 字段
- return {}
- }
-
if (isSupportedThinkingTokenZhipuModel(model)) {
if (!reasoningEffort) {
return { thinking: { type: 'disabled' } }
@@ -141,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 {}
@@ -153,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 {
@@ -192,8 +195,48 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min!
)
+ // DeepSeek hybrid inference models, v3.1 and maybe more in the future
+ // 不同的 provider 有不同的思考控制方式,在这里统一解决
+ if (isDeepSeekHybridInferenceModel(model)) {
+ if (isSystemProvider(this.provider)) {
+ switch (this.provider.id) {
+ case SystemProviderIds.dashscope:
+ return {
+ enable_thinking: true,
+ incremental_output: true
+ }
+ case SystemProviderIds.silicon:
+ return {
+ enable_thinking: true
+ }
+ case SystemProviderIds.doubao:
+ return {
+ thinking: {
+ type: 'enabled' // auto is invalid
+ }
+ }
+ case SystemProviderIds.openrouter:
+ return {
+ reasoning: {
+ enabled: true
+ }
+ }
+ case 'nvidia':
+ return {
+ chat_template_kwargs: {
+ thinking: true
+ }
+ }
+ default:
+ logger.warn(
+ `Skipping thinking options for provider ${this.provider.name} as DeepSeek v3.1 thinking control method is unknown`
+ )
+ }
+ }
+ }
+
// OpenRouter models
- if (model.provider === 'openrouter') {
+ if (model.provider === SystemProviderIds.openrouter) {
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -203,6 +246,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
}
+ // Doubao 思考模式支持
+ if (isSupportedThinkingTokenDoubaoModel(model)) {
+ if (reasoningEffort === 'high') {
+ return { thinking: { type: 'enabled' } }
+ }
+ if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
+ return { thinking: { type: 'auto' } }
+ }
+ // 其他情况不带 thinking 字段
+ return {}
+ }
+
// Qwen models
if (isQwenReasoningModel(model)) {
const thinkConfig = {
@@ -210,7 +265,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true,
thinking_budget: budgetTokens
}
- if (this.provider.id === 'dashscope') {
+ if (this.provider.id === SystemProviderIds.dashscope) {
return {
...thinkConfig,
incremental_output: true
@@ -527,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')) {
@@ -556,24 +611,46 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
userMessages.push(await this.convertMessageToSdkParam(message, model))
}
}
+ if (userMessages.length === 0) {
+ logger.warn('No user message. Some providers may not support.')
+ }
+
+ // poe 需要通过用户消息传递 reasoningEffort
+ const reasoningEffort = this.getReasoningEffort(assistant, model)
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
- if (
- lastUserMsg &&
- isSupportedThinkingTokenQwenModel(model) &&
- !isSupportEnableThinkingProvider(this.provider)
- ) {
- const postsuffix = '/no_think'
- const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
- const currentContent = lastUserMsg.content
+ if (lastUserMsg) {
+ if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) {
+ const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
+ const currentContent = lastUserMsg.content
- lastUserMsg.content = processPostsuffixQwen3Model(currentContent, postsuffix, qwenThinkModeEnabled) as any
+ lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled)
+ }
+ if (this.provider.id === SystemProviderIds.poe) {
+ // 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
+ if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) {
+ lastUserMsg.content += ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
+ } else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
+ lastUserMsg.content += ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
+ } else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
+ lastUserMsg.content += ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
+ }
+ }
}
// 4. 最终请求消息
let reqMessages: OpenAISdkMessageParam[]
- if (!systemMessage.content || isNotSupportSystemMessageModel(model)) {
+ if (!systemMessage.content) {
reqMessages = [...userMessages]
+ } else if (isNotSupportSystemMessageModel(model)) {
+ // transform into user message
+ const firstUserMsg = userMessages.shift()
+ if (firstUserMsg) {
+ firstUserMsg.content = `System Instruction: \n${systemMessage.content}\n\nUser Message(s):\n${firstUserMsg.content}`
+ reqMessages = [firstUserMsg, ...userMessages]
+ } else {
+ reqMessages = []
+ }
} else {
reqMessages = [systemMessage, ...userMessages].filter(Boolean) as OpenAISdkMessageParam[]
}
@@ -585,8 +662,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// Note: Some providers like Mistral don't support stream_options
const shouldIncludeStreamOptions = streamOutput && isSupportStreamOptionsProvider(this.provider)
- const reasoningEffort = this.getReasoningEffort(assistant, model)
-
// minimal cannot be used with web_search tool
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort === 'minimal' && enableWebSearch) {
reasoningEffort.reasoning_effort = 'low'
@@ -806,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) {
@@ -884,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/legacy/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts
index 2c3a67690..2d5bc6010 100644
--- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts
+++ b/src/renderer/src/aiCore/legacy/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/legacy/index.ts b/src/renderer/src/aiCore/legacy/index.ts
index a4ae0e455..7b5f75f9c 100644
--- a/src/renderer/src/aiCore/legacy/index.ts
+++ b/src/renderer/src/aiCore/legacy/index.ts
@@ -1,13 +1,13 @@
import { loggerService } from '@logger'
import { ApiClientFactory } from '@renderer/aiCore/legacy/clients/ApiClientFactory'
import { BaseApiClient } from '@renderer/aiCore/legacy/clients/BaseApiClient'
-import { isDedicatedImageGenerationModel } from '@renderer/config/models'
+import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@renderer/config/models'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { withSpanResult } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
-import { isPromptToolUse } from '@renderer/utils/mcp-tools'
+import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { AihubmixAPIClient } from './clients/AihubmixAPIClient'
import { VertexAPIClient } from './clients/gemini/VertexAPIClient'
@@ -20,7 +20,6 @@ import { MIDDLEWARE_NAME as FinalChunkConsumerMiddlewareName } from './middlewar
import { applyCompletionsMiddlewares } from './middleware/composer'
import { MIDDLEWARE_NAME as McpToolChunkMiddlewareName } from './middleware/core/McpToolChunkMiddleware'
import { MIDDLEWARE_NAME as RawStreamListenerMiddlewareName } from './middleware/core/RawStreamListenerMiddleware'
-import { MIDDLEWARE_NAME as ThinkChunkMiddlewareName } from './middleware/core/ThinkChunkMiddleware'
import { MIDDLEWARE_NAME as WebSearchMiddlewareName } from './middleware/core/WebSearchMiddleware'
import { MIDDLEWARE_NAME as ImageGenerationMiddlewareName } from './middleware/feat/ImageGenerationMiddleware'
import { MIDDLEWARE_NAME as ThinkingTagExtractionMiddlewareName } from './middleware/feat/ThinkingTagExtractionMiddleware'
@@ -92,7 +91,9 @@ export default class AiProvider {
}
const isAnthropicOrOpenAIResponseCompatible =
- clientTypes.includes('AnthropicAPIClient') || clientTypes.includes('OpenAIResponseAPIClient')
+ clientTypes.includes('AnthropicAPIClient') ||
+ clientTypes.includes('OpenAIResponseAPIClient') ||
+ clientTypes.includes('AnthropicVertexAPIClient')
if (!isAnthropicOrOpenAIResponseCompatible) {
logger.silly('RawStreamListenerMiddleware is removed')
builder.remove(RawStreamListenerMiddlewareName)
@@ -107,11 +108,11 @@ export default class AiProvider {
builder.remove(McpToolChunkMiddlewareName)
logger.silly('McpToolChunkMiddleware is removed')
}
- if (!isPromptToolUse(params.assistant)) {
+ if (isSupportedToolUse(params.assistant) && isFunctionCallingModel(model)) {
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)
}
@@ -120,13 +121,14 @@ export default class AiProvider {
logger.silly('ErrorHandlerMiddleware is removed')
builder.remove(FinalChunkConsumerMiddlewareName)
logger.silly('FinalChunkConsumerMiddleware is removed')
- builder.insertBefore(ThinkChunkMiddlewareName, MiddlewareRegistry[ThinkingTagExtractionMiddlewareName])
- logger.silly('ThinkingTagExtractionMiddleware is inserted')
}
}
const middlewares = builder.build()
- logger.silly('middlewares', middlewares)
+ logger.silly(
+ 'middlewares',
+ middlewares.map((m) => m.name)
+ )
// 3. Create the wrapped SDK method with middlewares
const wrappedCompletionMethod = applyCompletionsMiddlewares(client, client.createCompletions, middlewares)
diff --git a/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts
index c1d3102ed..a733e45d7 100644
--- a/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts
+++ b/src/renderer/src/aiCore/legacy/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/legacy/middleware/core/RawStreamListenerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts
index e97a2ce96..0d59ad9de 100644
--- a/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts
+++ b/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts
@@ -1,5 +1,4 @@
import { AnthropicAPIClient } from '@renderer/aiCore/legacy/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/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts
index 5ab19a617..447b9d2f2 100644
--- a/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts
+++ b/src/renderer/src/aiCore/legacy/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/legacy/middleware/schemas.ts b/src/renderer/src/aiCore/legacy/middleware/schemas.ts
index d429add46..ce89934f0 100644
--- a/src/renderer/src/aiCore/legacy/middleware/schemas.ts
+++ b/src/renderer/src/aiCore/legacy/middleware/schemas.ts
@@ -22,8 +22,10 @@ export interface CompletionsParams {
* 'search': 搜索摘要
* 'generate': 生成
* 'check': API检查
+ * 'test': 测试调用
+ * 'translate-lang-detect': 翻译语言检测
*/
- callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check' | 'test'
+ callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check' | 'test' | 'translate-lang-detect'
// 基础对话数据
messages: Message[] | string // 联合类型方便判断是否为空
@@ -57,6 +59,9 @@ export interface CompletionsParams {
contextCount?: number
topicId?: string // 主题ID,用于关联上下文
+ // abort 控制
+ abortKey?: string
+
_internal?: ProcessingState
}
diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts
index 3cc76eebd..eba71d986 100644
--- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts
+++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts
@@ -3,7 +3,7 @@ import {
LanguageModelV2Middleware,
simulateStreamingMiddleware
} from '@cherrystudio/ai-core'
-import type { BaseTool, Model, Provider } from '@renderer/types'
+import type { MCPTool, Model, Provider } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
/**
@@ -23,7 +23,7 @@ export interface AiSdkMiddlewareConfig {
isImageGenerationEndpoint: boolean
enableWebSearch: boolean
enableGenerateImage: boolean
- mcpTools?: BaseTool[]
+ mcpTools?: MCPTool[]
}
/**
@@ -172,7 +172,7 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
/**
* 添加模型特定的中间件
*/
-function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
+function addModelSpecificMiddlewares(_: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
if (!config.model) return
// 可以根据模型ID或特性添加特定中间件
diff --git a/src/renderer/src/aiCore/transformParameters.ts b/src/renderer/src/aiCore/transformParameters.ts
index 105f3d034..6e5f7b0d6 100644
--- a/src/renderer/src/aiCore/transformParameters.ts
+++ b/src/renderer/src/aiCore/transformParameters.ts
@@ -3,16 +3,7 @@
* 统一管理从各个 apiClient 提取的参数处理和转换功能
*/
-import {
- AssistantModelMessage,
- FilePart,
- ImagePart,
- ModelMessage,
- stepCountIs,
- type StreamTextParams,
- TextPart,
- UserModelMessage
-} from '@cherrystudio/ai-core'
+import { stepCountIs, type StreamTextParams } from '@cherrystudio/ai-core'
import { loggerService } from '@logger'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import {
@@ -39,6 +30,7 @@ import {
getMainTextContent
} from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
+import type { AssistantModelMessage, FilePart, ImagePart, ModelMessage, TextPart, UserModelMessage } from 'ai'
import { getAiSdkProviderId } from './provider/factory'
// import { webSearchTool } from './tools/WebSearchTool'
@@ -329,14 +321,14 @@ export async function buildStreamTextParams(
const model = assistant.model || getDefaultModel()
- const { maxTokens, reasoning_effort } = getAssistantSettings(assistant)
+ const { maxTokens } = getAssistantSettings(assistant)
// 这三个变量透传出来,交给下面动态启用插件/中间件
// 也可以在外部构建好再传入buildStreamTextParams
// FIXME: qwen3即使关闭思考仍然会导致enableReasoning的结果为true
const enableReasoning =
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
- reasoning_effort !== undefined) ||
+ assistant.settings?.reasoning_effort !== undefined) ||
(isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model)))
const enableWebSearch =
diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts
index 4ae56d722..01f7adbf5 100644
--- a/src/renderer/src/aiCore/utils/mcp.ts
+++ b/src/renderer/src/aiCore/utils/mcp.ts
@@ -4,14 +4,15 @@ import { MCPTool, MCPToolResponse } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { callMCPTool, getMcpServerByTool, isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { requestToolConfirmation } from '@renderer/utils/userConfirmation'
-import { jsonSchema, type Tool, tool } from 'ai'
+import { type Tool, type ToolSet } from 'ai'
+import { jsonSchema, tool } from 'ai'
import { JSONSchema7 } from 'json-schema'
const logger = loggerService.withContext('MCP-utils')
// Setup tools configuration based on provided parameters
export function setupToolsConfig(mcpTools?: MCPTool[]): Record | undefined {
- let tools: Record = {}
+ let tools: ToolSet = {}
if (!mcpTools?.length) {
return undefined
@@ -25,8 +26,8 @@ export function setupToolsConfig(mcpTools?: MCPTool[]): Record | u
/**
* 将 MCPTool 转换为 AI SDK 工具格式
*/
-export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): Record {
- const tools: Record = {}
+export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): ToolSet {
+ const tools: ToolSet = {}
for (const mcpTool of mcpTools) {
tools[mcpTool.name] = tool({
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 000000000..d60b9b687
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 1083effc3..000000000
--- a/src/renderer/src/assets/images/providers/poe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ 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 bbc1c569f..9b8b428c5 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 7a2a2ce27..ad6d1e0ea 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;
@@ -184,3 +207,28 @@
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
}
}
+
+.ant-splitter-bar {
+ .ant-splitter-bar-dragger {
+ &::before {
+ background-color: var(--color-border) !important;
+ transition:
+ background-color 0.15s ease,
+ width 0.15s ease;
+ }
+ &:hover {
+ &::before {
+ width: 4px !important;
+ background-color: var(--color-primary) !important;
+ transition-delay: 0.15s;
+ }
+ }
+ }
+
+ .ant-splitter-bar-dragger-active {
+ &::before {
+ width: 4px !important;
+ background-color: var(--color-primary) !important;
+ }
+ }
+}
diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss
index 6bd29fc8a..caf41aca5 100644
--- a/src/renderer/src/assets/styles/markdown.scss
+++ b/src/renderer/src/assets/styles/markdown.scss
@@ -103,6 +103,10 @@
}
span {
+ white-space: pre-wrap;
+ }
+
+ .katex span {
white-space: pre;
}
@@ -260,10 +264,6 @@
text-decoration: underline;
}
}
-
- > *:last-child {
- margin-bottom: 0 !important;
- }
}
.footnotes {
diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss
index 23f0edfb3..cf3c672c4 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 67b583e9e..acb4a9c4f 100644
--- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
+++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
@@ -1,11 +1,11 @@
-import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
+import { CodeOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { extractTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
-import { Code, Download, Globe, Sparkles } from 'lucide-react'
-import { FC, useMemo, useState } from 'react'
+import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react'
+import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClipLoader } from 'react-spinners'
import styled, { keyframes } from 'styled-components'
@@ -14,92 +14,10 @@ import HtmlArtifactsPopup from './HtmlArtifactsPopup'
const logger = loggerService.withContext('HtmlArtifactsCard')
-const HTML_VOID_ELEMENTS = new Set([
- 'area',
- 'base',
- 'br',
- 'col',
- 'embed',
- 'hr',
- 'img',
- 'input',
- 'link',
- 'meta',
- 'param',
- 'source',
- 'track',
- 'wbr'
-])
-
-const HTML_COMPLETION_PATTERNS = [
- /<\/html\s*>/i,
- //i,
- /<\/div\s*>/i,
- /<\/script\s*>/i,
- /<\/style\s*>/i
-]
-
interface Props {
html: string
-}
-
-function hasUnmatchedTags(html: string): boolean {
- const stack: string[] = []
- const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
- let match
-
- while ((match = tagRegex.exec(html)) !== null) {
- const [fullTag, tagName] = match
- const isClosing = fullTag.startsWith('')
- const isSelfClosing = fullTag.endsWith('/>') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
-
- if (isSelfClosing) continue
-
- if (isClosing) {
- if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
- return true
- }
- } else {
- stack.push(tagName.toLowerCase())
- }
- }
-
- return stack.length > 0
-}
-
-function checkIsStreaming(html: string): boolean {
- if (!html?.trim()) return false
-
- const trimmed = html.trim()
-
- // 快速检查:如果有明显的完成标志,直接返回false
- for (const pattern of HTML_COMPLETION_PATTERNS) {
- if (pattern.test(trimmed)) {
- // 特殊情况:同时有DOCTYPE和