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 @@ -Poe \ No newline at end of file diff --git a/src/renderer/src/assets/styles/animation.scss b/src/renderer/src/assets/styles/animation.scss index 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('') || 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和 - if (trimmed.includes('/i.test(trimmed)) { - return false - } - // 如果只是以结尾,也认为是完成的 - if (/<\/html\s*>$/i.test(trimmed)) { - return false - } - } - } - - // 检查未完成的标志 - const hasIncompleteTag = /<[^>]*$/.test(trimmed) - const hasUnmatched = hasUnmatchedTags(trimmed) - - if (hasIncompleteTag || hasUnmatched) return true - - // 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成 - const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed) - if (!hasStructureTags && trimmed.length < 500) { - return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed)) - } - - return false + onSave?: (html: string) => void + isStreaming?: boolean } const getTerminalStyles = (theme: ThemeMode) => ({ @@ -108,7 +26,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({ promptColor: theme === 'dark' ? '#00ff00' : '#007700' }) -const HtmlArtifactsCard: FC = ({ html }) => { +const HtmlArtifactsCard: FC = ({ html, onSave, isStreaming = false }) => { const { t } = useTranslation() const title = extractTitle(html) || 'HTML Artifacts' const [isPopupOpen, setIsPopupOpen] = useState(false) @@ -116,7 +34,6 @@ const HtmlArtifactsCard: FC = ({ html }) => { const htmlContent = html || '' const hasContent = htmlContent.trim().length > 0 - const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent]) const handleOpenExternal = async () => { const path = await window.api.file.createTempFile('artifacts-preview.html') @@ -181,10 +98,10 @@ const HtmlArtifactsCard: FC = ({ html }) => { - - @@ -192,7 +109,13 @@ const HtmlArtifactsCard: FC = ({ html }) => { - setIsPopupOpen(false)} /> + setIsPopupOpen(false)} + /> ) } @@ -234,6 +157,7 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>` display: flex; align-items: center; justify-content: center; + flex-shrink: 0; width: 44px; height: 44px; background: ${(props) => @@ -254,13 +178,16 @@ const TitleSection = styled.div` gap: 6px; ` -const Title = styled.h3` - margin: 0 !important; - font-size: 14px !important; - font-weight: 600; - color: var(--color-text); +const Title = styled.span` + font-size: 14px; + font-weight: bold; + color: var(--color-text-1); line-height: 1.4; font-family: 'Ubuntu'; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; ` const TypeBadge = styled.div` @@ -286,7 +213,6 @@ const ButtonContainer = styled.div` margin: 10px 16px !important; display: flex; flex-direction: row; - gap: 8px; ` const TerminalPreview = styled.div<{ $theme: ThemeMode }>` @@ -294,7 +220,7 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>` background: ${(props) => getTerminalStyles(props.$theme).background}; border-radius: 8px; overflow: hidden; - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-family: var(--code-font-family); ` const TerminalContent = styled.div<{ $theme: ThemeMode }>` diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index dba34f29a..216e24770 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,8 +1,8 @@ -import CodeEditor from '@renderer/components/CodeEditor' +import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' import { isLinux, isMac, isWin } from '@renderer/config/constant' import { classNames } from '@renderer/utils' -import { Button, Modal } from 'antd' -import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' +import { Button, Modal, Splitter, Tooltip, Typography } from 'antd' +import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -11,60 +11,17 @@ interface HtmlArtifactsPopupProps { open: boolean title: string html: string + onSave?: (html: string) => void onClose: () => void } type ViewMode = 'split' | 'code' | 'preview' -const HtmlArtifactsPopup: React.FC = ({ open, title, html, onClose }) => { +const HtmlArtifactsPopup: React.FC = ({ open, title, html, onSave, onClose }) => { const { t } = useTranslation() const [viewMode, setViewMode] = useState('split') - const [currentHtml, setCurrentHtml] = useState(html) const [isFullscreen, setIsFullscreen] = useState(false) - - // Preview refresh related state - const [previewHtml, setPreviewHtml] = useState(html) - const intervalRef = useRef(null) - const latestHtmlRef = useRef(html) - const currentPreviewHtmlRef = useRef(html) - - // Sync internal state when external html updates - useEffect(() => { - setCurrentHtml(html) - latestHtmlRef.current = html - }, [html]) - - // Update reference when internally edited html changes - useEffect(() => { - latestHtmlRef.current = currentHtml - }, [currentHtml]) - - // Update reference when preview content changes - useEffect(() => { - currentPreviewHtmlRef.current = previewHtml - }, [previewHtml]) - - // Check and refresh preview every 2 seconds (only when content changes) - useEffect(() => { - if (!open) return - - // Set initial preview content immediately - setPreviewHtml(latestHtmlRef.current) - - // Set timer to check for content changes every 2 seconds - intervalRef.current = setInterval(() => { - if (latestHtmlRef.current !== currentPreviewHtmlRef.current) { - setPreviewHtml(latestHtmlRef.current) - } - }, 2000) - - // Cleanup function - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - } - }, [open]) + const codeEditorRef = useRef(null) // Prevent body scroll when fullscreen useEffect(() => { @@ -79,13 +36,14 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } }, [isFullscreen, open]) - const showCode = viewMode === 'split' || viewMode === 'code' - const showPreview = viewMode === 'split' || viewMode === 'preview' + const handleSave = () => { + codeEditorRef.current?.save?.() + } const renderHeader = () => ( setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}> - {title} + {title} @@ -93,7 +51,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } + icon={} onClick={() => setViewMode('split')}> {t('html_artifacts.split')} @@ -107,7 +65,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } + icon={} onClick={() => setViewMode('preview')}> {t('html_artifacts.preview')} @@ -126,6 +84,75 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht ) + const renderContent = () => { + const codePanel = ( + + + + + + + + } + /> + + ) +} + +const ErrorBoundaryCustomized = ({ + children, + fallbackComponent +}: { + children: ReactNode + fallbackComponent?: ComponentType +}) => { + return {children} +} + +const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + padding: 8px; +` + +export { ErrorBoundaryCustomized as ErrorBoundary } diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index 88598bb02..8cae9c94d 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps) { return ( ) { return ( ) { return ( ) } + +export function PoeLogo(props: SVGProps) { + return ( + + Poe + + + + + + + + + + + + + + + + ) +} diff --git a/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx b/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx index 9972d5213..3eb6ac40e 100644 --- a/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx +++ b/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx @@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC = ({ children }) => { } return ( -
+
{createPortal( diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 95f0f1b5d..226598dc5 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMinappsOpenLinkExternal } from '@renderer/store/settings' import { MinAppType } from '@renderer/types' @@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => { const isInDevelopment = process.env.NODE_ENV === 'development' + const { setTimeoutTimer } = useTimer() + useBridge() /** set the popup display status */ @@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => { window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) } if (appid == currentMinappId) { - setTimeout(() => setIsReady(true), 200) + setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200) } } diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index 3da6ccfc8..263292dca 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -8,20 +8,19 @@ import { } from '@renderer/config/models' import i18n from '@renderer/i18n' import { Model } from '@renderer/types' -import { isFreeModel } from '@renderer/utils' +import { isFreeModel } from '@renderer/utils/model' import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import CustomTag from './Tags/CustomTag' import { EmbeddingTag, + FreeTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag -} from './Tags/ModelCapabilities' +} from './Tags/Model' interface ModelTagsProps { model: Model @@ -44,7 +43,6 @@ const ModelTagsWithLabel: FC = ({ showTooltip = true, style }) => { - const { t } = useTranslation() const [shouldShowLabel, setShouldShowLabel] = useState(false) const containerRef = useRef(null) const resizeObserver = useRef(null) @@ -86,7 +84,7 @@ const ModelTagsWithLabel: FC = ({ )} {isEmbeddingModel(model) && } - {showFree && isFreeModel(model) && } + {showFree && isFreeModel(model) && } {isRerankModel(model) && } ) diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index b3ea93662..eecad5ec9 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -1,6 +1,7 @@ import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import { useSystemAgents } from '@renderer/pages/agents' import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -33,6 +34,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const loadingRef = useRef(false) const [selectedIndex, setSelectedIndex] = useState(0) const containerRef = useRef(null) + const { setTimeoutTimer } = useTimer() const agents = useMemo(() => { const allAgents = [...userAgents, ...systemAgents] as Agent[] @@ -80,11 +82,11 @@ const PopupContainer: React.FC = ({ resolve }) => { assistant = await createAssistantFromAgent(agent) } - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) + setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) resolve(assistant) setOpen(false) }, - [resolve, addAssistant, setOpen] + [setTimeoutTimer, resolve, addAssistant] ) // 添加函数内使用的依赖项 // 键盘导航处理 useEffect(() => { diff --git a/src/renderer/src/components/Popups/PromptPopup.tsx b/src/renderer/src/components/Popups/PromptPopup.tsx index 9d8b9ae75..0d254d3fb 100644 --- a/src/renderer/src/components/Popups/PromptPopup.tsx +++ b/src/renderer/src/components/Popups/PromptPopup.tsx @@ -1,6 +1,6 @@ import { Input, Modal } from 'antd' import { TextAreaProps } from 'antd/es/input' -import { useRef, useState } from 'react' +import { ReactNode, useRef, useState } from 'react' import { Box } from '../Layout' import { TopView } from '../TopView' @@ -11,6 +11,7 @@ interface PromptPopupShowParams { defaultValue?: string inputPlaceholder?: string inputProps?: TextAreaProps + extraNode?: ReactNode } interface Props extends PromptPopupShowParams { @@ -23,6 +24,7 @@ const PromptPopupContainer: React.FC = ({ defaultValue = '', inputPlaceholder = '', inputProps = {}, + extraNode = null, resolve }) => { const [value, setValue] = useState(defaultValue) @@ -88,6 +90,7 @@ const PromptPopupContainer: React.FC = ({ rows={1} {...inputProps} /> + {extraNode} ) } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index e7557f5e5..ef616e71a 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,16 +1,36 @@ import { PushpinOutlined } from '@ant-design/icons' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' +import { + EmbeddingTag, + FreeTag, + ReasoningTag, + RerankerTag, + ToolsCallingTag, + VisionTag, + WebSearchTag +} from '@renderer/components/Tags/Model' import { TopView } from '@renderer/components/TopView' import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' -import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { + getModelLogo, + isEmbeddingModel, + isFunctionCallingModel, + isReasoningModel, + isRerankModel, + isVisionModel, + isWebSearchModel +} from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, Provider } from '@renderer/types' +import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' -import { Avatar, Divider, Empty, Modal } from 'antd' +import { getModelTags, isFreeModel } from '@renderer/utils/model' +import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd' import { first, sortBy } from 'lodash' +import { SettingsIcon } from 'lucide-react' import React, { + ReactNode, startTransition, useCallback, useDeferredValue, @@ -24,22 +44,28 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import SelectModelSearchBar from './searchbar' -import { FlatListItem } from './types' +import { FlatListItem, FlatListModel } from './types' -const PAGE_SIZE = 11 +const PAGE_SIZE = 12 const ITEM_HEIGHT = 36 +type ModelPredict = (m: Model) => boolean + interface PopupParams { model?: Model modelFilter?: (model: Model) => boolean + userFilterDisabled?: boolean } interface Props extends PopupParams { resolve: (value: Model | undefined) => void - modelFilter?: (model: Model) => boolean } -const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { +export type FilterType = Exclude | 'free' + +// const logger = loggerService.withContext('SelectModelPopup') + +const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilterDisabled }) => { const { t } = useTranslation() const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() @@ -48,6 +74,11 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) + const allModels: Model[] = useMemo( + () => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)), + [modelFilter, providers] + ) + // 当前选中的模型ID const currentModelId = model ? getModelUniqId(model) : '' @@ -62,10 +93,99 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { }) }, []) + // 管理用户筛选状态 + /** 从模型列表获取的需要显示的标签 */ + const availableTags = useMemo( + () => + objectEntries(getModelTags(allModels)) + .filter(([, state]) => state) + .map(([tag]) => tag), + [allModels] + ) + + const filterConfig: Record = useMemo( + () => ({ + vision: isVisionModel, + embedding: isEmbeddingModel, + reasoning: isReasoningModel, + function_calling: isFunctionCallingModel, + web_search: isWebSearchModel, + rerank: isRerankModel, + free: isFreeModel + }), + [] + ) + + /** 当前选择的标签,表示是否启用特定tag的筛选 */ + const [filterTags, setFilterTags] = useState>({ + vision: false, + embedding: false, + reasoning: false, + function_calling: false, + web_search: false, + rerank: false, + free: false + }) + const selectedFilterTags = useMemo( + () => + objectEntries(filterTags) + .filter(([, state]) => state) + .map(([tag]) => tag), + [filterTags] + ) + + const userFilter = useCallback( + (model: Model) => { + return selectedFilterTags + .map((tag) => [tag, filterConfig[tag]] as const) + .reduce((prev, [tag, predict]) => { + return prev && (!filterTags[tag] || predict(model)) + }, true) + }, + [filterConfig, filterTags, selectedFilterTags] + ) + + const onClickTag = useCallback((type: ModelTag) => { + startTransition(() => { + setFilterTags((prev) => ({ ...prev, [type]: !prev[type] })) + }) + }, []) + + // 筛选项列表 + const tagsItems: Record = useMemo( + () => ({ + vision: onClickTag('vision')} />, + embedding: onClickTag('embedding')} />, + reasoning: onClickTag('reasoning')} />, + function_calling: ( + onClickTag('function_calling')} + /> + ), + web_search: onClickTag('web_search')} />, + rerank: onClickTag('rerank')} />, + free: onClickTag('free')} /> + }), + [ + filterTags.embedding, + filterTags.free, + filterTags.function_calling, + filterTags.reasoning, + filterTags.rerank, + filterTags.vision, + filterTags.web_search, + onClickTag + ] + ) + + // 要显示的筛选项 + const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems]) // 根据输入的文本筛选模型 - const getFilteredModels = useCallback( + const searchFilter = useCallback( (provider: Provider) => { - let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) + let models = provider.models if (searchText.trim()) { models = filterModelsByKeywords(searchText, models, provider) @@ -78,7 +198,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 创建模型列表项 const createModelItem = useCallback( - (model: Model, provider: Provider, isPinned: boolean): FlatListItem => { + (model: Model, provider: Provider, isPinned: boolean): FlatListModel => { const modelId = getModelUniqId(model) const groupName = getFancyProviderName(provider) @@ -113,7 +233,11 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const { listItems, modelItems } = useMemo(() => { const items: FlatListItem[] = [] const pinnedModelIds = new Set(pinnedModels) - const finalModelFilter = modelFilter || (() => true) + const finalModelFilter = (model: Model) => { + const _userFilter = userFilterDisabled || userFilter(model) + const _modelFilter = modelFilter === undefined || modelFilter(model) + return _userFilter && _modelFilter + } // 添加置顶模型分组(仅在无搜索文本时) if (searchText.length === 0 && pinnedModelIds.size > 0) { @@ -139,7 +263,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 添加常规模型分组 providers.forEach((p) => { - const filteredModels = getFilteredModels(p) + const filteredModels = searchFilter(p) .filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m))) .filter(finalModelFilter) @@ -150,6 +274,22 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { key: `provider-${p.id}`, type: 'group', name: getFancyProviderName(p), + actions: ( + + -
- } - /> - - )} - - - -
{t('code.cli_tool')}
- { - const label = typeof option?.label === 'string' ? option.label : String(option?.value || '') - return label.toLowerCase().includes(input.toLowerCase()) - }} - options={directories.map((dir) => ({ - value: dir, - label: ( + {/* Bun 安装状态提示 */} + {!isBunInstalled && ( + + - {dir} - handleRemoveDirectory(dir, e)} - /> + {t('code.bun_required_message')} +
- ) - }))} - /> - - - + } + /> + + )} - -
{t('code.update_options')}
- setAutoUpdateToLatest(e.target.checked)}> - {t('code.auto_update_to_latest')} - -
- + + +
{t('code.cli_tool')}
+ { + const label = typeof option?.label === 'string' ? option.label : String(option?.value || '') + return label.toLowerCase().includes(input.toLowerCase()) + }} + options={directories.map((dir) => ({ + value: dir, + label: ( +
+ {dir} + handleRemoveDirectory(dir, e)} + /> +
+ ) + }))} + /> + + +
+ + +
{t('code.environment_variables')}
+ setEnvVars(e.target.value)} + rows={2} + style={{ fontFamily: 'monospace' }} + /> +
{t('code.env_vars_help')}
+
+ + +
{t('code.update_options')}
+ setAutoUpdateToLatest(e.target.checked)}> + {t('code.auto_update_to_latest')} + +
+
+ + + + ) } -// 样式组件 const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; +` + +const ContentContainer = styled.div` + display: flex; + flex: 1; + overflow-y: auto; + padding: 20px 0; +` + +const MainContent = styled.div` width: 600px; margin: auto; + min-height: fit-content; ` const Title = styled.h1` font-size: 24px; font-weight: 600; margin-bottom: 8px; - margin-top: -50px; color: var(--color-text-1); ` diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 434a3f0a4..c94e8bc86 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -1 +1,135 @@ +import { EndpointType, Model, Provider } from '@renderer/types' +import { codeTools } from '@shared/config/constant' + +export interface LaunchValidationResult { + isValid: boolean + message?: string +} + +export interface ToolEnvironmentConfig { + tool: codeTools + model: Model + modelProvider: Provider + apiKey: string + baseUrl: string +} + +// CLI 工具选项 +export const CLI_TOOLS = [ + { value: codeTools.qwenCode, label: 'Qwen Code' }, + { value: codeTools.claudeCode, label: 'Claude Code' }, + { value: codeTools.geminiCli, label: 'Gemini CLI' }, + { value: codeTools.openaiCodex, label: 'OpenAI Codex' } +] + +export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api'] +export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu'] +export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS] + +// Provider 过滤映射 +export const CLI_TOOL_PROVIDER_MAP: Record Provider[]> = { + [codeTools.claudeCode]: (providers) => + providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id)), + [codeTools.geminiCli]: (providers) => + providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)), + [codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')), + [codeTools.openaiCodex]: (providers) => providers.filter((p) => p.id === 'openai') +} + +export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { + const CODE_TOOLS_API_ENDPOINTS = { + aihubmix: { + gemini: { + api_base_url: 'https://api.aihubmix.com/gemini' + } + }, + deepseek: { + anthropic: { + api_base_url: 'https://api.deepseek.com/anthropic' + } + }, + moonshot: { + anthropic: { + api_base_url: 'https://api.moonshot.cn/anthropic' + } + }, + zhipu: { + anthropic: { + api_base_url: 'https://open.bigmodel.cn/api/anthropic' + } + } + } + + const provider = model.provider + + return CODE_TOOLS_API_ENDPOINTS[provider]?.[type]?.api_base_url +} + +// 解析环境变量字符串为对象 +export const parseEnvironmentVariables = (envVars: string): Record => { + const env: Record = {} + if (!envVars) return env + + const lines = envVars.split('\n') + for (const line of lines) { + const trimmedLine = line.trim() + if (trimmedLine && trimmedLine.includes('=')) { + const [key, ...valueParts] = trimmedLine.split('=') + const trimmedKey = key.trim() + const value = valueParts.join('=').trim() + if (trimmedKey) { + env[trimmedKey] = value + } + } + } + return env +} + +// 为不同 CLI 工具生成环境变量配置 +export const generateToolEnvironment = ({ + tool, + model, + modelProvider, + apiKey, + baseUrl +}: { + tool: codeTools + model: Model + modelProvider: Provider + apiKey: string + baseUrl: string +}): Record => { + const env: Record = {} + + switch (tool) { + case codeTools.claudeCode: + env.ANTHROPIC_BASE_URL = getCodeToolsApiBaseUrl(model, 'anthropic') || modelProvider.apiHost + env.ANTHROPIC_MODEL = model.id + if (modelProvider.type === 'anthropic') { + env.ANTHROPIC_API_KEY = apiKey + } else { + env.ANTHROPIC_AUTH_TOKEN = apiKey + } + break + + case codeTools.geminiCli: { + const apiBaseUrl = getCodeToolsApiBaseUrl(model, 'gemini') || modelProvider.apiHost + env.GEMINI_API_KEY = apiKey + env.GEMINI_BASE_URL = apiBaseUrl + env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl + env.GEMINI_MODEL = model.id + break + } + + case codeTools.qwenCode: + case codeTools.openaiCodex: + env.OPENAI_API_KEY = apiKey + env.OPENAI_BASE_URL = baseUrl + env.OPENAI_MODEL = model.id + break + } + + return env +} + export { default } from './CodeToolsPage' diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index d20accfd8..7d0857f2e 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -22,7 +22,7 @@ let _stack: Route[] = ['topics'] let _topic: Topic | undefined let _message: Message | undefined -const TopicsPage: FC = () => { +const HistoryPage: FC = () => { const { t } = useTranslation() const [search, setSearch] = useState(_search) const [searchKeywords, setSearchKeywords] = useState(_search) @@ -52,7 +52,12 @@ const TopicsPage: FC = () => { setTopic(undefined) } - const onTopicClick = (topic: Topic) => { + // topic 不包含 messages,用到的时候才会获取 + const onTopicClick = (topic: Topic | null | undefined) => { + if (!topic) { + window.message.error(t('history.error.topic_not_found')) + return + } setStack((prev) => [...prev, 'topic']) setTopic(topic) } @@ -86,7 +91,7 @@ const TopicsPage: FC = () => { ) } - suffix={search.length >= 2 ? : null} + suffix={search.length ? : null} ref={inputRef} placeholder={t('history.search.placeholder')} value={search} @@ -146,4 +151,4 @@ const SearchIcon = styled.div` } ` -export default TopicsPage +export default HistoryPage diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 2fd299a38..a88e97dd0 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -1,15 +1,23 @@ +import { LoadingIcon } from '@renderer/components/Icons' import db from '@renderer/databases' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { getTopicById } from '@renderer/hooks/useTopic' +import { selectTopicsMap } from '@renderer/store/assistants' import { Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' -import { List, Typography } from 'antd' +import { List, Spin, Typography } from 'antd' import { useLiveQuery } from 'dexie-react-hooks' -import { FC, memo, useCallback, useEffect, useState } from 'react' +import { FC, memo, useCallback, useEffect, useRef, useState } from 'react' +import { useSelector } from 'react-redux' import styled from 'styled-components' const { Text, Title } = Typography +type SearchResult = { + message: Message + topic: Topic + content: string +} + interface Props extends React.HTMLAttributes { keywords: string onMessageClick: (message: Message) => void @@ -18,6 +26,7 @@ interface Props extends React.HTMLAttributes { const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('SearchResults') + const observerRef = useRef(null) const [searchTerms, setSearchTerms] = useState( keywords @@ -27,9 +36,12 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p ) const topics = useLiveQuery(() => db.topics.toArray(), []) + // FIXME: db 中没有 topic.name 等信息,只能从 store 获取 + const storeTopicsMap = useSelector(selectTopicsMap) - const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([]) + const [searchResults, setSearchResults] = useState([]) const [searchStats, setSearchStats] = useState({ count: 0, time: 0 }) + const [isLoading, setIsLoading] = useState(false) const removeMarkdown = (text: string) => { return text @@ -44,33 +56,40 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p const onSearch = useCallback(async () => { setSearchResults([]) + setIsLoading(true) if (keywords.length === 0) { setSearchStats({ count: 0, time: 0 }) setSearchTerms([]) + setIsLoading(false) return } const startTime = performance.now() - const results: { message: Message; topic: Topic; content: string }[] = [] const newSearchTerms = keywords .toLowerCase() .split(' ') .filter((term) => term.length > 0) + const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i')) - const blocksArray = await db.message_blocks.toArray() - const blocks = blocksArray + const blocks = (await db.message_blocks.toArray()) .filter((block) => block.type === MessageBlockType.MAIN_TEXT) - .filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term))) + .filter((block) => searchRegexes.some((regex) => regex.test(block.content))) - const messages = topics?.map((topic) => topic.messages).flat() + const messages = topics?.flatMap((topic) => topic.messages) - for (const block of blocks) { - const message = messages?.find((message) => message.id === block.messageId) - if (message) { - results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content }) - } - } + const results = await Promise.all( + blocks.map(async (block) => { + const message = messages?.find((message) => message.id === block.messageId) + if (message) { + const topic = storeTopicsMap.get(message.topicId) + if (topic) { + return { message, topic, content: block.content } + } + } + return null + }) + ).then((results) => results.filter(Boolean) as SearchResult[]) const endTime = performance.now() setSearchResults(results) @@ -79,7 +98,8 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p time: (endTime - startTime) / 1000 }) setSearchTerms(newSearchTerms) - }, [keywords, topics]) + setIsLoading(false) + }, [keywords, storeTopicsMap, topics]) const highlightText = (text: string) => { let highlightedText = removeMarkdown(text) @@ -98,9 +118,24 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p onSearch() }, [onSearch]) + useEffect(() => { + if (!containerRef.current) return + + observerRef.current = new MutationObserver(() => { + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) + }) + + observerRef.current.observe(containerRef.current, { + childList: true, + subtree: true + }) + + return () => observerRef.current?.disconnect() + }, [containerRef]) + return ( - + }> {searchResults.length > 0 && ( Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds @@ -111,19 +146,15 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p dataSource={searchResults} pagination={{ pageSize: 10, - onChange: () => { - setTimeout(() => containerRef.current?.scrollTo({ top: 0 }), 0) - } + hideOnSinglePage: true }} + style={{ opacity: isLoading ? 0 : 1 }} renderItem={({ message, topic, content }) => ( { - const _topic = await getTopicById(topic.id) - onTopicClick(_topic) - }}> + onClick={() => onTopicClick(topic)}> {topic.name}
onMessageClick(message)}> @@ -136,24 +167,17 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p )} />
- + ) } const Container = styled.div` width: 100%; - padding: 20px; + height: 100%; + padding: 20px 36px; overflow-y: auto; display: flex; - flex-direction: row; - justify-content: center; -` - -const ContainerWrapper = styled.div` - width: 100%; - padding: 0 16px; - display: flex; flex-direction: column; ` @@ -164,6 +188,7 @@ const SearchStats = styled.div` const SearchResultTime = styled.div` margin-top: 10px; + text-align: right; ` export default memo(SearchResults) diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 8c98c458d..9f5111a25 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -4,18 +4,18 @@ import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' +import { getTopicById } from '@renderer/hooks/useTopic' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' import NavigationService from '@renderer/services/NavigationService' -import { useAppDispatch } from '@renderer/store' -import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' -import { classNames } from '@renderer/utils' +import { classNames, runAsyncFunction } from '@renderer/utils' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' import { Forward } from 'lucide-react' -import { FC, useEffect } from 'react' +import { FC, useEffect, useState } from 'react' import styled from 'styled-components' import { default as MessageItem } from '../../home/Messages/Message' @@ -24,15 +24,22 @@ interface Props extends React.HTMLAttributes { topic?: Topic } -const TopicMessages: FC = ({ topic, ...props }) => { +const TopicMessages: FC = ({ topic: _topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') - const dispatch = useAppDispatch() const { messageStyle } = useSettings() + const { setTimeoutTimer } = useTimer() + + const [topic, setTopic] = useState(_topic) useEffect(() => { - topic && dispatch(loadTopicMessagesThunk(topic.id)) - }, [dispatch, topic]) + if (!_topic) return + + runAsyncFunction(async () => { + const topic = await getTopicById(_topic.id) + setTopic(topic) + }) + }, [_topic, topic]) const isEmpty = (topic?.messages || []).length === 0 @@ -45,7 +52,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { SearchPopup.hide() const assistant = getAssistantById(topic.assistantId) navigate('/', { state: { assistant, topic } }) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) + setTimeoutTimer('onContinueChat', () => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) } return ( diff --git a/src/renderer/src/pages/history/components/TopicsHistory.tsx b/src/renderer/src/pages/history/components/TopicsHistory.tsx index 2051d536b..37113891f 100644 --- a/src/renderer/src/pages/history/components/TopicsHistory.tsx +++ b/src/renderer/src/pages/history/components/TopicsHistory.tsx @@ -1,14 +1,14 @@ import { SearchOutlined } from '@ant-design/icons' import { VStack } from '@renderer/components/Layout' -import { useAssistants } from '@renderer/hooks/useAssistant' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { getTopicById } from '@renderer/hooks/useTopic' +import { selectAllTopics } from '@renderer/store/assistants' import { Topic } from '@renderer/types' import { Button, Divider, Empty, Segmented } from 'antd' import dayjs from 'dayjs' import { groupBy, isEmpty, orderBy } from 'lodash' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import styled from 'styled-components' type SortType = 'createdAt' | 'updatedAt' @@ -20,18 +20,18 @@ type Props = { } & React.HTMLAttributes const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props }) => { - const { assistants } = useAssistants() const { t } = useTranslation() const { handleScroll, containerRef } = useScrollPosition('TopicsHistory') const [sortType, setSortType] = useState('createdAt') - const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc') + // FIXME: db 中没有 topic.name 等信息,只能从 store 获取 + const topics = useSelector(selectAllTopics) const filteredTopics = topics.filter((topic) => { return topic.name.toLowerCase().includes(keywords.toLowerCase()) }) - const groupedTopics = groupBy(filteredTopics, (topic) => { + const groupedTopics = groupBy(orderBy(filteredTopics, sortType, 'desc'), (topic) => { return dayjs(topic[sortType]).format('MM/DD') }) @@ -66,19 +66,14 @@ const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props {date} {items.map((topic) => ( - { - const _topic = await getTopicById(topic.id) - onClick(_topic) - }}> + onClick(topic)}> {topic.name.substring(0, 50)} {dayjs(topic[sortType]).format('HH:mm')} ))} ))} - {keywords.length >= 2 && ( + {keywords && (