Merge remote-tracking branch 'origin/main' into feat/aisdk-package

This commit is contained in:
MyPrototypeWhat
2025-08-28 12:03:41 +08:00
291 changed files with 10876 additions and 3680 deletions
+12 -3
View File
@@ -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
+24 -23
View File
@@ -1,39 +1,40 @@
{
"version": "0.2.0",
"compounds": [
{
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"name": "Debug All",
"presentation": {
"order": 1
}
}
],
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--inspect", "--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
},
"envFile": "${workspaceFolder}/.env",
"name": "Debug Main Process",
"request": "launch",
"runtimeArgs": ["--inspect", "--sourcemap"],
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"type": "node",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 3000000,
"presentation": {
"hidden": true
}
},
"request": "attach",
"timeout": 3000000,
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer"
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
"version": "0.2.0"
}
+348
View File
@@ -0,0 +1,348 @@
diff --git a/src/constants/languages.d.ts b/src/constants/languages.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6a2ba5086187622b8ca8887bcc7406018fba8a89
--- /dev/null
+++ b/src/constants/languages.d.ts
@@ -0,0 +1,43 @@
+/**
+ * Languages with existing tesseract traineddata
+ * https://tesseract-ocr.github.io/tessdoc/Data-Files#data-files-for-version-400-november-29-2016
+ */
+
+// Define the language codes as string literals
+type LanguageCode =
+ | 'afr' | 'amh' | 'ara' | 'asm' | 'aze' | 'aze_cyrl' | 'bel' | 'ben' | 'bod' | 'bos'
+ | 'bul' | 'cat' | 'ceb' | 'ces' | 'chi_sim' | 'chi_tra' | 'chr' | 'cym' | 'dan' | 'deu'
+ | 'dzo' | 'ell' | 'eng' | 'enm' | 'epo' | 'est' | 'eus' | 'fas' | 'fin' | 'fra'
+ | 'frk' | 'frm' | 'gle' | 'glg' | 'grc' | 'guj' | 'hat' | 'heb' | 'hin' | 'hrv'
+ | 'hun' | 'iku' | 'ind' | 'isl' | 'ita' | 'ita_old' | 'jav' | 'jpn' | 'kan' | 'kat'
+ | 'kat_old' | 'kaz' | 'khm' | 'kir' | 'kor' | 'kur' | 'lao' | 'lat' | 'lav' | 'lit'
+ | 'mal' | 'mar' | 'mkd' | 'mlt' | 'msa' | 'mya' | 'nep' | 'nld' | 'nor' | 'ori'
+ | 'pan' | 'pol' | 'por' | 'pus' | 'ron' | 'rus' | 'san' | 'sin' | 'slk' | 'slv'
+ | 'spa' | 'spa_old' | 'sqi' | 'srp' | 'srp_latn' | 'swa' | 'swe' | 'syr' | 'tam' | 'tel'
+ | 'tgk' | 'tgl' | 'tha' | 'tir' | 'tur' | 'uig' | 'ukr' | 'urd' | 'uzb' | 'uzb_cyrl'
+ | 'vie' | 'yid';
+
+// Define the language keys as string literals
+type LanguageKey =
+ | 'AFR' | 'AMH' | 'ARA' | 'ASM' | 'AZE' | 'AZE_CYRL' | 'BEL' | 'BEN' | 'BOD' | 'BOS'
+ | 'BUL' | 'CAT' | 'CEB' | 'CES' | 'CHI_SIM' | 'CHI_TRA' | 'CHR' | 'CYM' | 'DAN' | 'DEU'
+ | 'DZO' | 'ELL' | 'ENG' | 'ENM' | 'EPO' | 'EST' | 'EUS' | 'FAS' | 'FIN' | 'FRA'
+ | 'FRK' | 'FRM' | 'GLE' | 'GLG' | 'GRC' | 'GUJ' | 'HAT' | 'HEB' | 'HIN' | 'HRV'
+ | 'HUN' | 'IKU' | 'IND' | 'ISL' | 'ITA' | 'ITA_OLD' | 'JAV' | 'JPN' | 'KAN' | 'KAT'
+ | 'KAT_OLD' | 'KAZ' | 'KHM' | 'KIR' | 'KOR' | 'KUR' | 'LAO' | 'LAT' | 'LAV' | 'LIT'
+ | 'MAL' | 'MAR' | 'MKD' | 'MLT' | 'MSA' | 'MYA' | 'NEP' | 'NLD' | 'NOR' | 'ORI'
+ | 'PAN' | 'POL' | 'POR' | 'PUS' | 'RON' | 'RUS' | 'SAN' | 'SIN' | 'SLK' | 'SLV'
+ | 'SPA' | 'SPA_OLD' | 'SQI' | 'SRP' | 'SRP_LATN' | 'SWA' | 'SWE' | 'SYR' | 'TAM' | 'TEL'
+ | 'TGK' | 'TGL' | 'THA' | 'TIR' | 'TUR' | 'UIG' | 'UKR' | 'URD' | 'UZB' | 'UZB_CYRL'
+ | 'VIE' | 'YID';
+
+// Create a mapped type to ensure each key maps to its specific value
+type LanguagesMap = {
+ [K in LanguageKey]: LanguageCode;
+};
+
+// Declare the exported constant with the specific type
+export const LANGUAGES: LanguagesMap;
+
+// Export the individual types for use in other modules
+export type { LanguageCode, LanguageKey, LanguagesMap };
\ No newline at end of file
diff --git a/src/index.d.ts b/src/index.d.ts
index 1f5a9c8094fe4de7983467f9efb43bdb4de535f2..16dc95cf68663673e37e189b719cb74897b7735f 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -1,31 +1,74 @@
+// Import the languages types
+import { LanguagesMap } from "./constants/languages";
+
+/// <reference types="node" />
+
declare namespace Tesseract {
- function createScheduler(): Scheduler
- function createWorker(langs?: string | string[] | Lang[], oem?: OEM, options?: Partial<WorkerOptions>, config?: string | Partial<InitOptions>): Promise<Worker>
- function setLogging(logging: boolean): void
- function recognize(image: ImageLike, langs?: string, options?: Partial<WorkerOptions>): Promise<RecognizeResult>
- function detect(image: ImageLike, options?: Partial<WorkerOptions>): any
+ function createScheduler(): Scheduler;
+ function createWorker(
+ langs?: LanguageCode | LanguageCode[] | Lang[],
+ oem?: OEM,
+ options?: Partial<WorkerOptions>,
+ config?: string | Partial<InitOptions>
+ ): Promise<Worker>;
+ function setLogging(logging: boolean): void;
+ function recognize(
+ image: ImageLike,
+ langs?: LanguageCode,
+ options?: Partial<WorkerOptions>
+ ): Promise<RecognizeResult>;
+ function detect(image: ImageLike, options?: Partial<WorkerOptions>): any;
+
+ // Export languages constant
+ const languages: LanguagesMap;
+
+ type LanguageCode = import("./constants/languages").LanguageCode;
+ type LanguageKey = import("./constants/languages").LanguageKey;
interface Scheduler {
- addWorker(worker: Worker): string
- addJob(action: 'recognize', ...args: Parameters<Worker['recognize']>): Promise<RecognizeResult>
- addJob(action: 'detect', ...args: Parameters<Worker['detect']>): Promise<DetectResult>
- terminate(): Promise<any>
- getQueueLen(): number
- getNumWorkers(): number
+ addWorker(worker: Worker): string;
+ addJob(
+ action: "recognize",
+ ...args: Parameters<Worker["recognize"]>
+ ): Promise<RecognizeResult>;
+ addJob(
+ action: "detect",
+ ...args: Parameters<Worker["detect"]>
+ ): Promise<DetectResult>;
+ terminate(): Promise<any>;
+ getQueueLen(): number;
+ getNumWorkers(): number;
}
interface Worker {
- load(jobId?: string): Promise<ConfigResult>
- writeText(path: string, text: string, jobId?: string): Promise<ConfigResult>
- readText(path: string, jobId?: string): Promise<ConfigResult>
- removeText(path: string, jobId?: string): Promise<ConfigResult>
- FS(method: string, args: any[], jobId?: string): Promise<ConfigResult>
- reinitialize(langs?: string | Lang[], oem?: OEM, config?: string | Partial<InitOptions>, jobId?: string): Promise<ConfigResult>
- setParameters(params: Partial<WorkerParams>, jobId?: string): Promise<ConfigResult>
- getImage(type: imageType): string
- recognize(image: ImageLike, options?: Partial<RecognizeOptions>, output?: Partial<OutputFormats>, jobId?: string): Promise<RecognizeResult>
- detect(image: ImageLike, jobId?: string): Promise<DetectResult>
- terminate(jobId?: string): Promise<ConfigResult>
+ load(jobId?: string): Promise<ConfigResult>;
+ writeText(
+ path: string,
+ text: string,
+ jobId?: string
+ ): Promise<ConfigResult>;
+ readText(path: string, jobId?: string): Promise<ConfigResult>;
+ removeText(path: string, jobId?: string): Promise<ConfigResult>;
+ FS(method: string, args: any[], jobId?: string): Promise<ConfigResult>;
+ reinitialize(
+ langs?: string | Lang[],
+ oem?: OEM,
+ config?: string | Partial<InitOptions>,
+ jobId?: string
+ ): Promise<ConfigResult>;
+ setParameters(
+ params: Partial<WorkerParams>,
+ jobId?: string
+ ): Promise<ConfigResult>;
+ getImage(type: imageType): string;
+ recognize(
+ image: ImageLike,
+ options?: Partial<RecognizeOptions>,
+ output?: Partial<OutputFormats>,
+ jobId?: string
+ ): Promise<RecognizeResult>;
+ detect(image: ImageLike, jobId?: string): Promise<DetectResult>;
+ terminate(jobId?: string): Promise<ConfigResult>;
}
interface Lang {
@@ -34,43 +77,43 @@ declare namespace Tesseract {
}
interface InitOptions {
- load_system_dawg: string
- load_freq_dawg: string
- load_unambig_dawg: string
- load_punc_dawg: string
- load_number_dawg: string
- load_bigram_dawg: string
- }
-
- type LoggerMessage = {
- jobId: string
- progress: number
- status: string
- userJobId: string
- workerId: string
+ load_system_dawg: string;
+ load_freq_dawg: string;
+ load_unambig_dawg: string;
+ load_punc_dawg: string;
+ load_number_dawg: string;
+ load_bigram_dawg: string;
}
-
+
+ type LoggerMessage = {
+ jobId: string;
+ progress: number;
+ status: string;
+ userJobId: string;
+ workerId: string;
+ };
+
interface WorkerOptions {
- corePath: string
- langPath: string
- cachePath: string
- dataPath: string
- workerPath: string
- cacheMethod: string
- workerBlobURL: boolean
- gzip: boolean
- legacyLang: boolean
- legacyCore: boolean
- logger: (arg: LoggerMessage) => void,
- errorHandler: (arg: any) => void
+ corePath: string;
+ langPath: string;
+ cachePath: string;
+ dataPath: string;
+ workerPath: string;
+ cacheMethod: string;
+ workerBlobURL: boolean;
+ gzip: boolean;
+ legacyLang: boolean;
+ legacyCore: boolean;
+ logger: (arg: LoggerMessage) => void;
+ errorHandler: (arg: any) => void;
}
interface WorkerParams {
- tessedit_pageseg_mode: PSM
- tessedit_char_whitelist: string
- tessedit_char_blacklist: string
- preserve_interword_spaces: string
- user_defined_dpi: string
- [propName: string]: any
+ tessedit_pageseg_mode: PSM;
+ tessedit_char_whitelist: string;
+ tessedit_char_blacklist: string;
+ preserve_interword_spaces: string;
+ user_defined_dpi: string;
+ [propName: string]: any;
}
interface OutputFormats {
text: boolean;
@@ -88,36 +131,36 @@ declare namespace Tesseract {
debug: boolean;
}
interface RecognizeOptions {
- rectangle: Rectangle
- pdfTitle: string
- pdfTextOnly: boolean
- rotateAuto: boolean
- rotateRadians: number
+ rectangle: Rectangle;
+ pdfTitle: string;
+ pdfTextOnly: boolean;
+ rotateAuto: boolean;
+ rotateRadians: number;
}
interface ConfigResult {
- jobId: string
- data: any
+ jobId: string;
+ data: any;
}
interface RecognizeResult {
- jobId: string
- data: Page
+ jobId: string;
+ data: Page;
}
interface DetectResult {
- jobId: string
- data: DetectData
+ jobId: string;
+ data: DetectData;
}
interface DetectData {
- tesseract_script_id: number | null
- script: string | null
- script_confidence: number | null
- orientation_degrees: number | null
- orientation_confidence: number | null
+ tesseract_script_id: number | null;
+ script: string | null;
+ script_confidence: number | null;
+ orientation_degrees: number | null;
+ orientation_confidence: number | null;
}
interface Rectangle {
- left: number
- top: number
- width: number
- height: number
+ left: number;
+ top: number;
+ width: number;
+ height: number;
}
enum OEM {
TESSERACT_ONLY,
@@ -126,28 +169,36 @@ declare namespace Tesseract {
DEFAULT,
}
enum PSM {
- OSD_ONLY = '0',
- AUTO_OSD = '1',
- AUTO_ONLY = '2',
- AUTO = '3',
- SINGLE_COLUMN = '4',
- SINGLE_BLOCK_VERT_TEXT = '5',
- SINGLE_BLOCK = '6',
- SINGLE_LINE = '7',
- SINGLE_WORD = '8',
- CIRCLE_WORD = '9',
- SINGLE_CHAR = '10',
- SPARSE_TEXT = '11',
- SPARSE_TEXT_OSD = '12',
- RAW_LINE = '13'
+ OSD_ONLY = "0",
+ AUTO_OSD = "1",
+ AUTO_ONLY = "2",
+ AUTO = "3",
+ SINGLE_COLUMN = "4",
+ SINGLE_BLOCK_VERT_TEXT = "5",
+ SINGLE_BLOCK = "6",
+ SINGLE_LINE = "7",
+ SINGLE_WORD = "8",
+ CIRCLE_WORD = "9",
+ SINGLE_CHAR = "10",
+ SPARSE_TEXT = "11",
+ SPARSE_TEXT_OSD = "12",
+ RAW_LINE = "13",
}
const enum imageType {
COLOR = 0,
GREY = 1,
- BINARY = 2
+ BINARY = 2,
}
- type ImageLike = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement
- | CanvasRenderingContext2D | File | Blob | Buffer | OffscreenCanvas;
+ type ImageLike =
+ | string
+ | HTMLImageElement
+ | HTMLCanvasElement
+ | HTMLVideoElement
+ | CanvasRenderingContext2D
+ | File
+ | Blob
+ | (typeof Buffer extends undefined ? never : Buffer)
+ | OffscreenCanvas;
interface Block {
paragraphs: Paragraph[];
text: string;
@@ -179,7 +230,7 @@ declare namespace Tesseract {
text: string;
confidence: number;
baseline: Baseline;
- rowAttributes: RowAttributes
+ rowAttributes: RowAttributes;
bbox: Bbox;
}
interface Paragraph {
+7 -5
View File
@@ -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 内容样式
修复知识库文档预处理失败问题
稳定性改进和错误修复
+31 -19
View File
@@ -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": {
@@ -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<T extends ProviderId = ProviderId> {
throw error
}
}
// === 静态工厂方法 ===
/**
* 创建 OpenAI Compatible 客户端
*/
static createOpenAICompatible(
config: ProviderSettingsMap['openai-compatible'],
plugins: AiPlugin[] = []
): PluginEngine<'openai-compatible'> {
return new PluginEngine('openai-compatible', plugins)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type ProviderId } from '../types'
import { type ProviderId } from '../core/providers/types'
export function isOpenAIChatCompletionOnlyModel(modelId: string): boolean {
if (!modelId) {
+7 -1
View File
@@ -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'
}
+7
View File
@@ -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'
}
+2 -2
View File
@@ -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'
}
+11 -1
View File
@@ -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<typeof ocrService.ocr>) => ocrService.ocr(...args))
}
@@ -91,7 +91,7 @@ export default abstract class BasePreprocessProvider {
}
public async readPdf(buffer: Buffer) {
const pdfDoc = await PDFDocument.load(buffer)
const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true })
return {
numPages: pdfDoc.getPageCount()
}
@@ -201,20 +201,14 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
*/
private async putFile(filePath: string, url: string): Promise<void> {
try {
// 获取文件大小用于设置 Content-Length
const stats = await fs.promises.stat(filePath)
const fileSize = stats.size
// 创建可读流
const fileStream = fs.createReadStream(filePath)
const response = await net.fetch(url, {
method: 'PUT',
body: fileStream as any, // TypeScript 类型转换,net.fetch 支持 ReadableStream
headers: {
'Content-Length': fileSize.toString()
}
})
duplex: 'half'
} as any) // TypeScript 类型转换,net.fetch 需要 duplex 选项
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+13 -8
View File
@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search'
import DifyKnowledgeServer from './dify-knowledge'
@@ -11,30 +12,34 @@ import ThinkingServer from './sequentialthinking'
const logger = loggerService.withContext('MCPFactory')
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
export function createInMemoryMCPServer(
name: BuiltinMCPServerName,
args: string[] = [],
envs: Record<string, string> = {}
): Server {
logger.debug(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
switch (name) {
case '@cherry/memory': {
case BuiltinMCPServerNames.memory: {
const envPath = envs.MEMORY_FILE_PATH
return new MemoryServer(envPath).server
}
case '@cherry/sequentialthinking': {
case BuiltinMCPServerNames.sequentialThinking: {
return new ThinkingServer().server
}
case '@cherry/brave-search': {
case BuiltinMCPServerNames.braveSearch: {
return new BraveSearchServer(envs.BRAVE_API_KEY).server
}
case '@cherry/fetch': {
case BuiltinMCPServerNames.fetch: {
return new FetchServer().server
}
case '@cherry/filesystem': {
case BuiltinMCPServerNames.filesystem: {
return new FileSystemServer(args).server
}
case '@cherry/dify-knowledge': {
case BuiltinMCPServerNames.difyKnowledge: {
const difyKey = envs.DIFY_KEY
return new DifyKnowledgeServer(difyKey, args).server
}
case '@cherry/python': {
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
default:
+44 -20
View File
@@ -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<boolean> {
@@ -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
+31 -10
View File
@@ -1,9 +1,10 @@
import { loggerService } from '@logger'
import { net } from 'electron'
import { app, safeStorage } from 'electron'
import fs from 'fs/promises'
import { app, net, safeStorage } from 'electron'
import fs from 'fs'
import path from 'path'
import { getConfigDir } from '../utils/file'
const logger = loggerService.withContext('CopilotService')
// 配置常量,集中管理
@@ -28,7 +29,8 @@ const CONFIG = {
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token',
COPILOT_TOKEN: 'https://api.github.com/copilot_internal/v2/token'
}
},
TOKEN_FILE_NAME: '.copilot_token'
}
// 接口定义移到顶部,便于查阅
@@ -67,8 +69,20 @@ class CopilotService {
private headers: Record<string, string>
constructor() {
this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token')
this.headers = { ...CONFIG.DEFAULT_HEADERS }
this.tokenFilePath = this.getTokenFilePath()
this.headers = {
...CONFIG.DEFAULT_HEADERS,
accept: 'application/json',
'user-agent': 'Visual Studio Code (desktop)'
}
}
private getTokenFilePath = (): string => {
const oldTokenFilePath = path.join(app.getPath('userData'), CONFIG.TOKEN_FILE_NAME)
if (fs.existsSync(oldTokenFilePath)) {
return oldTokenFilePath
}
return path.join(getConfigDir(), CONFIG.TOKEN_FILE_NAME)
}
/**
@@ -93,6 +107,7 @@ class CopilotService {
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Dest': 'empty',
accept: 'application/json',
authorization: `token ${token}`
}
})
@@ -204,7 +219,13 @@ class CopilotService {
public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => {
try {
const encryptedToken = safeStorage.encryptString(token)
await fs.writeFile(this.tokenFilePath, encryptedToken)
// 确保目录存在
const dir = path.dirname(this.tokenFilePath)
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, { recursive: true })
}
await fs.promises.writeFile(this.tokenFilePath, encryptedToken)
} catch (error) {
logger.error('Failed to save token:', error as Error)
throw new CopilotServiceError('无法保存访问令牌', error)
@@ -221,7 +242,7 @@ class CopilotService {
try {
this.updateHeaders(headers)
const encryptedToken = await fs.readFile(this.tokenFilePath)
const encryptedToken = await fs.promises.readFile(this.tokenFilePath)
const access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
@@ -249,8 +270,8 @@ class CopilotService {
public logout = async (): Promise<void> => {
try {
try {
await fs.access(this.tokenFilePath)
await fs.unlink(this.tokenFilePath)
await fs.promises.access(this.tokenFilePath)
await fs.promises.unlink(this.tokenFilePath)
logger.debug('Successfully logged out from Copilot')
} catch (error) {
// 文件不存在不是错误,只是记录一下
+31 -1
View File
@@ -1,7 +1,8 @@
import { loggerService } from '@logger'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
import { documentExts, imageExts, MB } from '@shared/config/constant'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import chardet from 'chardet'
import * as crypto from 'crypto'
import {
dialog,
@@ -15,6 +16,7 @@ import {
import * as fs from 'fs'
import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises'
import { isBinaryFile } from 'isbinaryfile'
import officeParser from 'officeparser'
import * as path from 'path'
import { PDFDocument } from 'pdf-lib'
@@ -630,6 +632,34 @@ class FileStorage {
public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext)
}
public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> => {
try {
const isBinary = await isBinaryFile(filePath)
if (isBinary) {
return false
}
const length = 8 * KB
const fileHandle = await fs.promises.open(filePath, 'r')
const buffer = Buffer.alloc(length)
const { bytesRead } = await fileHandle.read(buffer, 0, length, 0)
await fileHandle.close()
const sampleBuffer = buffer.subarray(0, bytesRead)
const matches = chardet.analyse(sampleBuffer)
// 如果检测到的编码置信度较高,认为是文本文件
if (matches.length > 0 && matches[0].confidence > 0.8) {
return true
}
return false
} catch (error) {
logger.error('Failed to check if file is text:', error as Error)
return false
}
}
}
export const fileStorage = new FileStorage()
+12
View File
@@ -1,3 +1,4 @@
import { readTextFileWithAutoEncoding } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import fs from 'fs/promises'
@@ -8,4 +9,15 @@ export default class FileService {
if (encoding) return fs.readFile(path, { encoding })
return fs.readFile(path)
}
/**
* 自动识别编码,读取文本文件
* @param _ event
* @param pathOrUrl
* @throws 路径不存在时抛出错误
*/
@TraceMethod({ spanName: 'readTextFileWithAutoEncoding', tag: 'FileService' })
public static async readTextFileWithAutoEncoding(_: Electron.IpcMainInvokeEvent, path: string): Promise<string> {
return readTextFileWithAutoEncoding(path)
}
}
+16 -12
View File
@@ -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
+5 -10
View File
@@ -1,14 +1,9 @@
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
import { Notification as ElectronNotification } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { 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()
+43 -11
View File
@@ -11,14 +11,42 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher
const logger = loggerService.withContext('ProxyManager')
let byPassRules: string[] = []
const isByPass = (hostname: string) => {
const isByPass = (url: string) => {
if (byPassRules.length === 0) {
return false
}
return byPassRules.includes(hostname)
}
try {
const subjectUrlTokens = new URL(url)
for (const rule of byPassRules) {
const ruleMatch = rule.replace(/^(?<leadingDot>\.)/, '*').match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/)
if (!ruleMatch || !ruleMatch.groups) {
logger.warn('Failed to parse bypass rule:', { rule })
continue
}
if (!ruleMatch.groups.hostname) {
continue
}
const hostnameIsMatch = subjectUrlTokens.hostname === ruleMatch.groups.hostname
if (
hostnameIsMatch &&
(!ruleMatch.groups ||
!ruleMatch.groups.port ||
(subjectUrlTokens.port && subjectUrlTokens.port === ruleMatch.groups.port))
) {
return true
}
}
return false
} catch (error) {
logger.error('Failed to check bypass:', error as Error)
return false
}
}
class SelectiveDispatcher extends Dispatcher {
private proxyDispatcher: Dispatcher
private directDispatcher: Dispatcher
@@ -31,9 +59,7 @@ class SelectiveDispatcher extends Dispatcher {
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
if (opts.origin) {
const url = new URL(opts.origin)
// 检查是否为 localhost 或本地地址
if (isByPass(url.hostname)) {
if (isByPass(opts.origin.toString())) {
return this.directDispatcher.dispatch(opts, handler)
}
}
@@ -93,15 +119,20 @@ export class ProxyManager {
// Set new interval
this.systemProxyInterval = setInterval(async () => {
const currentProxy = await getSystemProxy()
if (currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules) {
if (
currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules &&
currentProxy?.noProxy.join(',').toLowerCase() === this.config?.proxyBypassRules?.toLowerCase()
) {
return
}
logger.info(`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}`)
logger.info(
`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}, this.config.proxyBypassRules: ${this.config.proxyBypassRules}`
)
await this.configureProxy({
mode: 'system',
proxyRules: currentProxy?.proxyUrl.toLowerCase(),
proxyBypassRules: undefined
proxyBypassRules: currentProxy?.noProxy.join(',')
})
}, 1000 * 60)
}
@@ -151,6 +182,7 @@ export class ProxyManager {
delete process.env.grpc_proxy
delete process.env.http_proxy
delete process.env.https_proxy
delete process.env.no_proxy
delete process.env.SOCKS_PROXY
delete process.env.ALL_PROXY
@@ -162,6 +194,7 @@ export class ProxyManager {
process.env.HTTPS_PROXY = url
process.env.http_proxy = url
process.env.https_proxy = url
process.env.no_proxy = byPassRules.join(',')
if (url.startsWith('socks')) {
process.env.SOCKS_PROXY = url
@@ -229,8 +262,7 @@ export class ProxyManager {
// filter localhost
if (url) {
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname
if (isByPass(hostname)) {
if (isByPass(url.toString())) {
return originalMethod(url, options, callback)
}
}
+4
View File
@@ -1,6 +1,9 @@
import { is } from '@electron-toolkit/utils'
import { loggerService } from '@logger'
import { BrowserWindow } from 'electron'
const logger = loggerService.withContext('SearchService')
export class SearchService {
private static instance: SearchService | null = null
private searchWindows: Record<string, BrowserWindow> = {}
@@ -55,6 +58,7 @@ export class SearchService {
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
let window = this.searchWindows[uid]
logger.debug(`Searching with URL: ${url}`)
if (window) {
await window.loadURL(url)
} else {
+1 -1
View File
@@ -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))
+22 -22
View File
@@ -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
+34
View File
@@ -0,0 +1,34 @@
import { loggerService } from '@logger'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { tesseractService } from './tesseract/TesseractService'
const logger = loggerService.withContext('OcrService')
export class OcrService {
private registry: Map<string, OcrHandler> = new Map()
register(providerId: string, handler: OcrHandler): void {
if (this.registry.has(providerId)) {
logger.warn(`Provider ${providerId} has existing handler. Overwrited.`)
}
this.registry.set(providerId, handler)
}
unregister(providerId: string): void {
this.registry.delete(providerId)
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id)
if (!handler) {
throw new Error(`Provider ${provider.id} is not registered`)
}
return handler(file)
}
}
export const ocrService = new OcrService()
// Register built-in providers
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
@@ -0,0 +1,82 @@
import { loggerService } from '@logger'
import { getIpCountry } from '@main/utils/ipService'
import { loadOcrImage } from '@main/utils/ocr'
import { MB } from '@shared/config/constant'
import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types'
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import Tesseract, { createWorker, LanguageCode } from 'tesseract.js'
const logger = loggerService.withContext('TesseractService')
// config
const MB_SIZE_THRESHOLD = 50
const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[]
enum TesseractLangsDownloadUrl {
CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/',
GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/'
}
export class TesseractService {
private worker: Tesseract.Worker | null = null
async getWorker(): Promise<Tesseract.Worker> {
if (!this.worker) {
// for now, only support limited languages
this.worker = await createWorker(tesseractLangs, undefined, {
langPath: await this._getLangPath(),
cachePath: await this._getCacheDir(),
gzip: false,
logger: (m) => logger.debug('From worker', m)
})
}
return this.worker
}
async imageOcr(file: ImageFileMetadata): Promise<OcrResult> {
const worker = await this.getWorker()
const stat = await fs.promises.stat(file.path)
if (stat.size > MB_SIZE_THRESHOLD * MB) {
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
}
const buffer = await loadOcrImage(file)
const result = await worker.recognize(buffer)
return { text: result.data.text }
}
async ocr(file: SupportedOcrFile): Promise<OcrResult> {
if (!isImageFile(file)) {
throw new Error('Only image files are supported currently')
}
return this.imageOcr(file)
}
private async _getLangPath(): Promise<string> {
const country = await getIpCountry()
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL
}
private async _getCacheDir(): Promise<string> {
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
// use access to check if the directory exists
if (
!(await fs.promises
.access(cacheDir, fs.constants.F_OK)
.then(() => true)
.catch(() => false))
) {
await fs.promises.mkdir(cacheDir, { recursive: true })
}
return cacheDir
}
async dispose(): Promise<void> {
if (this.worker) {
await this.worker.terminate()
this.worker = null
}
}
}
export const tesseractService = new TesseractService()
+1
View File
@@ -168,6 +168,7 @@ export function getMcpDir() {
*
* @param filePath -
* @returns
* @throws
*/
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
const encoding = (await chardet.detectFile(filePath, { sampleSize: MB })) || 'UTF-8'
+27
View File
@@ -0,0 +1,27 @@
import { ImageFileMetadata } from '@types'
import { readFile } from 'fs/promises'
import sharp from 'sharp'
const preprocessImage = async (buffer: Buffer) => {
return await sharp(buffer)
.grayscale() // 转为灰度
.normalize()
.sharpen()
.toBuffer()
}
/**
* OCR图像
* @param file -
* @returns Buffer
* @throws {Error}
*
* :
* 1.
* 2.
* 3.
*/
export const loadOcrImage = async (file: ImageFileMetadata): Promise<Buffer> => {
const buffer = await readFile(file.path)
return await preprocessImage(buffer)
}
+17 -5
View File
@@ -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<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
mac: {
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise<boolean> => 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<FileMetadata[] | null> =>
ipcRenderer.invoke(IpcChannel.File_Select, options),
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext),
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
get: (filePath: string): Promise<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/**
*
* @param fileName
@@ -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<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
readText: (pathOrUrl: string): Promise<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
@@ -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<string | null> =>
ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
},
python: {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
@@ -403,6 +411,10 @@ const api = {
env: Record<string, string>,
options?: { autoUpdateToLatest?: boolean }
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
}
}
+15 -12
View File
@@ -4,6 +4,7 @@ import { FC, useMemo } from 'react'
import { HashRouter, Route, Routes } from 'react-router-dom'
import Sidebar from './components/app/Sidebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useSettings'
@@ -23,18 +24,20 @@ const Router: FC = () => {
const routes = useMemo(() => {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
</ErrorBoundary>
)
}, [])
@@ -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)
}
@@ -32,7 +32,7 @@ export class ToolCallChunkHandler {
>()
constructor(
private onChunk: (chunk: Chunk) => void,
private mcpTools: BaseTool[]
private mcpTools: MCPTool[]
) {}
// /**
@@ -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' })
@@ -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<GenerateContentConfig> {
return {
systemInstruction: undefined,
responseModalities: [Modality.TEXT, Modality.IMAGE],
responseMimeType: 'text/plain'
responseModalities: [Modality.TEXT, Modality.IMAGE]
}
}
@@ -476,16 +476,20 @@ export class GeminiAPIClient extends BaseApiClient<
}
}
if (enableWebSearch) {
tools.push({
googleSearch: {}
})
}
if (tools.length === 0 || !isToolUseModeFunction(assistant)) {
if (enableWebSearch) {
tools.push({
googleSearch: {}
})
}
if (enableUrlContext) {
tools.push({
urlContext: {}
})
if (enableUrlContext) {
tools.push({
urlContext: {}
})
}
} else if (enableWebSearch || enableUrlContext) {
logger.warn('Native tools cannot be used with function calling for now.')
}
if (isGemmaModel(model) && assistant.prompt) {
@@ -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]) {
@@ -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. 设置工具
+11 -9
View File
@@ -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)
@@ -21,32 +21,38 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
return result
}
// 获取当前消息的ID用于abort管理
// 优先使用处理过的消息,如果没有则使用原始消息
let messageId: string | undefined
if (typeof params.messages === 'string') {
messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
} else {
const processedMessages = params.messages
const lastUserMessage = processedMessages.findLast((m) => m.role === 'user')
messageId = lastUserMessage?.id
}
if (!messageId) {
logger.warn(`No messageId found, abort functionality will not be available.`)
return next(ctx, params)
}
const abortController = new AbortController()
const abortFn = (): void => abortController.abort()
addAbortController(messageId, abortFn)
let abortSignal: AbortSignal | null = abortController.signal
let abortKey: string
// 如果参数中传入了abortKey则优先使用
if (params.abortKey) {
abortKey = params.abortKey
} else {
// 获取当前消息的ID用于abort管理
// 优先使用处理过的消息,如果没有则使用原始消息
let messageId: string | undefined
if (typeof params.messages === 'string') {
messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
} else {
const processedMessages = params.messages
const lastUserMessage = processedMessages.findLast((m) => m.role === 'user')
messageId = lastUserMessage?.id
}
if (!messageId) {
logger.warn(`No messageId found, abort functionality will not be available.`)
return next(ctx, params)
}
abortKey = messageId
}
addAbortController(abortKey, abortFn)
const cleanup = (): void => {
removeAbortController(messageId as string, abortFn)
removeAbortController(abortKey, abortFn)
if (ctx._internal?.flowControl) {
ctx._internal.flowControl.abortController = undefined
ctx._internal.flowControl.abortSignal = undefined
@@ -1,5 +1,4 @@
import { AnthropicAPIClient } from '@renderer/aiCore/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<AnthropicSdkRawChunk> = {
onMessage: (message) => {
if (ctx._internal?.toolProcessingState) {
@@ -7,6 +7,7 @@ import {
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { getLowerBaseModelName } from '@renderer/utils'
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
@@ -22,13 +23,16 @@ const reasoningTags: TagConfig[] = [
{ openingTag: '<thought>', closingTag: '</thought>', separator: '\n' },
{ openingTag: '###Thinking', closingTag: '###Response', separator: '\n' },
{ openingTag: '◁think▷', closingTag: '◁/think▷', separator: '\n' },
{ openingTag: '<thinking>', closingTag: '</thinking>', separator: '\n' }
{ openingTag: '<thinking>', closingTag: '</thinking>', separator: '\n' },
{ openingTag: '<seed:think>', closingTag: '</seed:think>', separator: '\n' }
]
const getAppropriateTag = (model?: Model): TagConfig => {
if (model?.id?.includes('qwen3')) return reasoningTags[0]
if (model?.id?.includes('gemini-2.5')) return reasoningTags[1]
if (model?.id?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3]
const modelId = model?.id ? getLowerBaseModelName(model.id) : undefined
if (modelId?.includes('qwen3')) return reasoningTags[0]
if (modelId?.includes('gemini-2.5')) return reasoningTags[1]
if (modelId?.includes('kimi-vl-a3b-thinking')) return reasoningTags[3]
if (modelId?.includes('seed-oss-36b')) return reasoningTags[5]
// 可以在这里添加更多模型特定的标签配置
return reasoningTags[0] // 默认使用 <think> 标签
}
@@ -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
}
@@ -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或特性添加特定中间件
+4 -12
View File
@@ -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 =
+5 -4
View File
@@ -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<string, Tool> | undefined {
let tools: Record<string, Tool> = {}
let tools: ToolSet = {}
if (!mcpTools?.length) {
return undefined
@@ -25,8 +26,8 @@ export function setupToolsConfig(mcpTools?: MCPTool[]): Record<string, Tool> | u
/**
* MCPTool AI SDK
*/
export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): Record<string, Tool> {
const tools: Record<string, Tool> = {}
export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): ToolSet {
const tools: ToolSet = {}
for (const mcpTool of mcpTools) {
tools[mcpTool.name] = tool({
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Poe</title><path d="M20.708 6.876a1.412 1.412 0 00-1.029-.415h-.006a2.019 2.019 0 01-2.02-2.023A1.415 1.415 0 0016.254 3H4.871A1.412 1.412 0 003.47 4.434a2.026 2.026 0 01-2.025 2.025v.002A1.414 1.414 0 000 7.883v3.642a1.414 1.414 0 001.444 1.42 2.025 2.025 0 012.025 2.02v3.693a.5.5 0 00.89.313l2.051-2.567h9.843a1.412 1.412 0 001.4-1.434v-.002c0-1.12.904-2.025 2.026-2.025a1.412 1.412 0 001.446-1.42V7.88c0-.363-.14-.727-.417-1.005zm-2.42 4.687a2.025 2.025 0 01-2.025 2.005H4.861a2.025 2.025 0 01-2.025-2.005v-3.72A2.026 2.026 0 014.86 5.838h11.4a2.026 2.026 0 012.026 2.005v3.72h.002z"></path><path d="M7.413 7.57A1.422 1.422 0 005.99 8.99v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422zm6.297 0a1.422 1.422 0 00-1.422 1.421v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422z"></path><path d="M7.292 22.643l1.993-2.492h9.844a1.413 1.413 0 001.4-1.434 2.025 2.025 0 012.017-2.027h.01A1.409 1.409 0 0024 15.27v-3.594c0-.344-.113-.68-.324-.951l-.397-.519v4.127a1.415 1.415 0 01-1.444 1.42h-.007a2.026 2.026 0 00-2.018 2.025 1.415 1.415 0 01-1.402 1.436H8.565l-2.169 2.712a.574.574 0 00.896.715v.002z" fill="url(#lobe-icons-poe-fill-0)"></path><path d="M5.004 19.992l2.12-2.65h9.844a1.414 1.414 0 001.402-1.437c0-1.116.9-2.021 2.014-2.025h.012a1.413 1.413 0 001.443-1.422v-4.13l.52.68c.21.273.324.607.324.95v3.594a1.416 1.416 0 01-1.443 1.42h-.01a2.026 2.026 0 00-2.016 2.026 1.414 1.414 0 01-1.402 1.435H7.97l-1.916 2.4a.671.671 0 01-1.049-.839v-.002z" fill="url(#lobe-icons-poe-fill-1)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-poe-fill-0" x1="34.01" x2="1.086" y1="7.303" y2="27.715"><stop stop-color="#46A6F7"></stop><stop offset="1" stop-color="#8364FF"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-poe-fill-1" x1="4.915" x2="24.34" y1="23.511" y2="9.464"><stop stop-color="#FF44D3"></stop><stop offset="1" stop-color="#CF4BFF"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -68,3 +68,23 @@
transform-origin: center;
animation: animation-rotate 0.75s linear infinite;
}
// 定位高亮动画
@keyframes animation-locate-highlight {
0% {
background-color: transparent;
}
10% {
background-color: var(--color-primary-mute);
}
70% {
background-color: var(--color-primary-mute);
}
100% {
background-color: transparent;
}
}
.animation-locate-highlight {
animation: animation-locate-highlight 2.5s ease-in-out;
}
+50 -2
View File
@@ -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;
}
}
}
+4 -4
View File
@@ -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 {
@@ -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);
}
@@ -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,
/<!DOCTYPE\s+html/i,
/<\/body\s*>/i,
/<\/div\s*>/i,
/<\/script\s*>/i,
/<\/style\s*>/i
]
interface Props {
html: string
}
function hasUnmatchedTags(html: string): boolean {
const stack: string[] = []
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
let match
while ((match = tagRegex.exec(html)) !== null) {
const [fullTag, tagName] = match
const isClosing = fullTag.startsWith('</')
const isSelfClosing = fullTag.endsWith('/>') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
if (isSelfClosing) continue
if (isClosing) {
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
return true
}
} else {
stack.push(tagName.toLowerCase())
}
}
return stack.length > 0
}
function checkIsStreaming(html: string): boolean {
if (!html?.trim()) return false
const trimmed = html.trim()
// 快速检查:如果有明显的完成标志,直接返回false
for (const pattern of HTML_COMPLETION_PATTERNS) {
if (pattern.test(trimmed)) {
// 特殊情况:同时有DOCTYPE和</body>
if (trimmed.includes('<!DOCTYPE') && /<\/body\s*>/i.test(trimmed)) {
return false
}
// 如果只是以</html>结尾,也认为是完成的
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<Props> = ({ html }) => {
const HtmlArtifactsCard: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ html }) => {
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="text" disabled={!hasContent}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
<Button icon={<LinkIcon size={14} />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<Download size={16} />} onClick={handleDownload} type="text" disabled={!hasContent}>
<Button icon={<DownloadIcon size={14} />} onClick={handleDownload} type="text" disabled={!hasContent}>
{t('code_block.download.label')}
</Button>
</ButtonContainer>
@@ -192,7 +109,13 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
</Content>
</Container>
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={() => setIsPopupOpen(false)} />
<HtmlArtifactsPopup
open={isPopupOpen}
title={title}
html={htmlContent}
onSave={onSave}
onClose={() => 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 }>`
@@ -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<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onSave, onClose }) => {
const { t } = useTranslation()
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [currentHtml, setCurrentHtml] = useState(html)
const [isFullscreen, setIsFullscreen] = useState(false)
// Preview refresh related state
const [previewHtml, setPreviewHtml] = useState(html)
const intervalRef = useRef<NodeJS.Timeout | null>(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<CodeEditorHandles>(null)
// Prevent body scroll when fullscreen
useEffect(() => {
@@ -79,13 +36,14 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
}
}, [isFullscreen, open])
const showCode = viewMode === 'split' || viewMode === 'code'
const showPreview = viewMode === 'split' || viewMode === 'preview'
const handleSave = () => {
codeEditorRef.current?.save?.()
}
const renderHeader = () => (
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
<HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText>
<TitleText ellipsis={{ tooltip: true }}>{title}</TitleText>
</HeaderLeft>
<HeaderCenter>
@@ -93,7 +51,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
<ViewButton
size="small"
type={viewMode === 'split' ? 'primary' : 'default'}
icon={<MonitorSpeaker size={14} />}
icon={<SquareSplitHorizontal size={14} />}
onClick={() => setViewMode('split')}>
{t('html_artifacts.split')}
</ViewButton>
@@ -107,7 +65,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
<ViewButton
size="small"
type={viewMode === 'preview' ? 'primary' : 'default'}
icon={<Monitor size={14} />}
icon={<Eye size={14} />}
onClick={() => setViewMode('preview')}>
{t('html_artifacts.preview')}
</ViewButton>
@@ -126,6 +84,75 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
</ModalHeader>
)
const renderContent = () => {
const codePanel = (
<CodeSection>
<CodeEditor
ref={codeEditorRef}
value={html}
language="html"
editable={true}
onSave={onSave}
style={{ height: '100%' }}
expanded
unwrapped={false}
options={{
stream: true, // FIXME: 避免多余空行
lineNumbers: true,
keymap: true
}}
/>
<ToolbarWrapper>
<Tooltip title={t('code_block.edit.save.label')} mouseLeaveDelay={0}>
<Button
shape="circle"
size="large"
icon={<SaveIcon size={16} className="custom-lucide" />}
onClick={handleSave}
/>
</Tooltip>
</ToolbarWrapper>
</CodeSection>
)
const previewPanel = (
<PreviewSection>
{html.trim() ? (
<PreviewFrame
key={html} // Force recreate iframe when preview content changes
srcDoc={html}
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
) : (
<EmptyPreview>
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
</EmptyPreview>
)}
</PreviewSection>
)
switch (viewMode) {
case 'split':
return (
<Splitter>
<Splitter.Panel defaultSize="50%" min="25%">
{codePanel}
</Splitter.Panel>
<Splitter.Panel defaultSize="50%" min="25%">
{previewPanel}
</Splitter.Panel>
</Splitter>
)
case 'code':
return codePanel
case 'preview':
return previewPanel
default:
return null
}
}
return (
<StyledModal
$isFullscreen={isFullscreen}
@@ -144,41 +171,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
zIndex={isFullscreen ? 10000 : 1000}
footer={null}
closable={false}>
<Container>
{showCode && (
<CodeSection>
<CodeEditor
value={currentHtml}
language="html"
editable={true}
onSave={setCurrentHtml}
style={{ height: '100%' }}
expanded
unwrapped={false}
options={{
stream: false
}}
/>
</CodeSection>
)}
{showPreview && (
<PreviewSection>
{previewHtml.trim() ? (
<PreviewFrame
key={previewHtml} // Force recreate iframe when preview content changes
srcDoc={previewHtml}
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
) : (
<EmptyPreview>
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
</EmptyPreview>
)}
</PreviewSection>
)}
</Container>
<Container>{renderContent()}</Container>
</StyledModal>
)
}
@@ -213,7 +206,6 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
: `
.ant-modal-body {
height: 80vh !important;
min-height: 600px !important;
}
`}
@@ -238,6 +230,10 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
margin-bottom: 0 !important;
border-radius: 0 !important;
}
::-webkit-scrollbar {
width: 8px;
}
`
const ModalHeader = styled.div`
@@ -270,13 +266,13 @@ const HeaderRight = styled.div<{ $isFullscreen?: boolean }>`
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
`
const TitleText = styled.span`
const TitleText = styled(Typography.Text)`
font-size: 16px;
font-weight: 600;
font-weight: bold;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 50%;
`
const ViewControls = styled.div`
@@ -315,13 +311,24 @@ const Container = styled.div`
width: 100%;
flex: 1;
background: var(--color-background);
overflow: hidden;
.ant-splitter {
width: 100%;
height: 100%;
border: none;
.ant-splitter-pane {
overflow: hidden;
}
}
`
const CodeSection = styled.div`
flex: 1;
min-width: 300px;
border-right: 1px solid var(--color-border);
height: 100%;
width: 100%;
overflow: hidden;
position: relative;
.monaco-editor,
.cm-editor,
@@ -331,8 +338,8 @@ const CodeSection = styled.div`
`
const PreviewSection = styled.div`
flex: 1;
min-width: 300px;
height: 100%;
width: 100%;
background: white;
overflow: hidden;
`
@@ -355,4 +362,15 @@ const EmptyPreview = styled.div`
font-size: 14px;
`
const ToolbarWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
gap: 4px;
right: 1rem;
bottom: 1rem;
z-index: 1;
`
export default HtmlArtifactsPopup
@@ -1,2 +1,3 @@
export { default as HtmlArtifactsCard } from './HtmlArtifactsCard'
export * from './types'
export * from './view'
@@ -18,15 +18,14 @@ import { BasicPreviewHandles } from '@renderer/components/Preview'
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { getExtensionByLanguage } from '@renderer/utils/code-language'
import { extractTitle } from '@renderer/utils/formats'
import { getExtensionByLanguage, isHtmlCode } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import StatusBar from './StatusBar'
import { ViewMode } from './types'
@@ -301,11 +300,6 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
)
}, [specialView, sourceView, viewMode])
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
if (language === 'html' && isHtmlCode(children)) {
return <HtmlArtifactsCard html={children} />
}
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}
+16 -15
View File
@@ -1,10 +1,11 @@
import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup
import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Extension, keymap } from '@uiw/react-codemirror'
import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
const logger = loggerService.withContext('CodeEditorHooks')
// 语言对应的 linter 加载器
@@ -17,32 +18,33 @@ const linterLoaders: Record<string, () => Promise<any>> = {
/**
*
* key: 语言文件扩展名 `.`
*/
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
dot: async () => {
const mod = await import('@viz-js/lang-dot')
return mod.dot()
},
// @uiw/codemirror-extensions-langs 4.25.1 移除了 mermaid 支持,这里加回来
mmd: async () => {
const mod = await import('codemirror-lang-mermaid')
return mod.mermaid()
}
}
/**
*
*/
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
async function loadLanguageExtension(language: string): Promise<Extension | null> {
const fileExt = await getNormalizedExtension(language)
// 尝试加载特殊语言
const specialLoader = specialLanguageLoaders[normalizedLang]
const specialLoader = specialLanguageLoaders[fileExt]
if (specialLoader) {
try {
return await specialLoader()
} catch (error) {
logger.debug(`Failed to load language ${normalizedLang}`, error as Error)
logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
return null
}
}
@@ -50,10 +52,10 @@ async function loadLanguageExtension(language: string, languageMap: Record<strin
// 回退到 uiw/codemirror 包含的语言
try {
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
const extension = loadLanguage(normalizedLang as any)
const extension = loadLanguage(fileExt as any)
return extension || null
} catch (error) {
logger.debug(`Failed to load language ${normalizedLang}`, error as Error)
logger.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
return null
}
}
@@ -77,7 +79,6 @@ async function loadLinterExtension(language: string): Promise<Extension | null>
*
*/
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
useEffect(() => {
@@ -87,7 +88,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
try {
// 加载所有扩展
const [languageResult, linterResult] = await Promise.allSettled([
loadLanguageExtension(language, languageMap),
loadLanguageExtension(language),
lint ? loadLinterExtension(language) : Promise.resolve(null)
])
@@ -119,7 +120,7 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => {
return () => {
cancelled = true
}
}, [language, lint, languageMap])
}, [language, lint])
return extensions
}
@@ -0,0 +1,34 @@
import { getExtensionByLanguage } from '@renderer/utils/code-language'
// 自定义语言文件扩展名映射
// key: 语言名小写
// value: 扩展名
const _customLanguageExtensions: Record<string, string> = {
svg: 'xml',
vab: 'vb',
graphviz: 'dot'
}
/**
* @uiw/codemirror-extensions-langs
* -
* - github linguist
* @param language
* @returns `.`
*/
export async function getNormalizedExtension(language: string) {
const lowerLanguage = language.toLowerCase()
const customExt = _customLanguageExtensions[lowerLanguage]
if (customExt) {
return customExt
}
const linguistExt = getExtensionByLanguage(language)
if (linguistExt) {
return linguistExt.slice(1)
}
// 回退到语言名称
return language
}
@@ -1,21 +1,30 @@
import i18n from '@renderer/i18n'
import { Input, InputRef, Tooltip } from 'antd'
import { Search } from 'lucide-react'
import { motion } from 'motion/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface CollapsibleSearchBarProps {
onSearch: (text: string) => void
placeholder?: string
tooltip?: string
icon?: React.ReactNode
maxWidth?: string | number
style?: React.CSSProperties
}
/**
* A collapsible search bar for list headers
* Renders as an icon initially, expands to full search input when clicked
*/
const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, icon, maxWidth }) => {
const { t } = useTranslation()
const CollapsibleSearchBar = ({
onSearch,
placeholder = i18n.t('common.search'),
tooltip = i18n.t('common.search'),
icon = <Search size={14} color="var(--color-icon)" />,
maxWidth = '100%',
style
}: CollapsibleSearchBarProps) => {
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
@@ -46,22 +55,23 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
initial="collapsed"
animate={searchVisible ? 'expanded' : 'collapsed'}
variants={{
expanded: { maxWidth: maxWidth || '100%', opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
expanded: { maxWidth: maxWidth, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
}}
style={{ overflow: 'hidden', flex: 1 }}>
<Input
ref={inputRef}
type="text"
placeholder={t('models.search')}
placeholder={placeholder}
size="small"
suffix={icon || <Search size={14} color="var(--color-icon)" />}
suffix={icon}
value={searchText}
autoFocus
allowClear
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation()
handleTextChange('')
if (!searchText) setSearchVisible(false)
}
@@ -70,7 +80,7 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
if (!searchText) setSearchVisible(false)
}}
onClear={handleClear}
style={{ width: '100%' }}
style={{ width: '100%', ...style }}
/>
</motion.div>
<motion.div
@@ -82,8 +92,8 @@ const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, i
}}
style={{ cursor: 'pointer', display: 'flex' }}
onClick={() => setSearchVisible(true)}>
<Tooltip title={t('models.search')} mouseLeaveDelay={0}>
{icon || <Search size={14} color="var(--color-icon)" />}
<Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
{icon}
</Tooltip>
</motion.div>
</div>
@@ -282,6 +282,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
implementation.searchNext()
}
} else if (event.key === 'Escape') {
event.stopPropagation()
implementation.disable()
}
},
@@ -29,20 +29,6 @@ vi.mock('@hello-pangea/dnd', () => {
}
})
// mock VirtualList 只做简单渲染
vi.mock('rc-virtual-list', () => ({
__esModule: true,
default: ({ data, itemKey, children }: any) => (
<div data-testid="virtual-list">
{data.map((item: any, idx: number) => (
<div key={item[itemKey] || item} data-testid="virtual-list-item">
{children(item, idx)}
</div>
))}
</div>
)
}))
declare global {
interface Window {
triggerOnDragEnd: (result?: any, provided?: any) => void
@@ -73,14 +59,15 @@ describe('DraggableList', () => {
const list = [{ id: 'a', name: 'A' }]
const style = { background: 'red' }
const listStyle = { color: 'blue' }
render(
const { container } = render(
<DraggableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DraggableList>
)
// 检查 style 是否传递到外层容器
const virtualList = screen.getByTestId('virtual-list')
expect(virtualList.parentElement).toHaveStyle({ background: 'red' })
const listContainer = container.querySelector('.draggable-list-container')
expect(listContainer).not.toBeNull()
expect(listContainer?.parentElement).toHaveStyle({ background: 'red' })
})
it('should render nothing when list is empty', () => {
@@ -157,8 +144,7 @@ describe('DraggableList', () => {
// 模拟拖拽到自身
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
expect(onUpdate).toHaveBeenCalledTimes(0)
})
})
@@ -175,8 +161,7 @@ describe('DraggableList', () => {
// 拖拽自身
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
expect(onUpdate).toHaveBeenCalledTimes(0)
})
it('should not crash if callbacks are undefined', () => {
@@ -1,5 +1,3 @@
/// <reference types="@vitest/browser/context" />
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
@@ -34,7 +32,7 @@ vi.mock('@hello-pangea/dnd', () => ({
}))
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }) => ({
useVirtualizer: ({ count, getScrollElement }) => ({
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
@@ -43,15 +41,16 @@ vi.mock('@tanstack/react-virtual', () => ({
size: 50
})),
getTotalSize: () => count * 50,
measureElement: vi.fn()
measureElement: vi.fn(),
scrollToIndex: vi.fn(),
scrollToOffset: vi.fn(),
scrollElement: getScrollElement(),
measure: vi.fn(),
resizeItem: vi.fn(),
getVirtualIndexes: () => Array.from({ length: count }, (_, i) => i)
})
}))
vi.mock('react-virtualized-auto-sizer', () => ({
__esModule: true,
default: ({ children }) => <div data-testid="auto-sizer">{children({ height: 500, width: 300 })}</div>
}))
vi.mock('@renderer/components/Scrollbar', () => ({
__esModule: true,
default: ({ ref, children, ...props }) => (
@@ -10,56 +10,44 @@ exports[`DraggableList > snapshot > should match snapshot 1`] = `
>
<div>
<div
data-testid="virtual-list"
class="draggable-list-container"
>
<div
data-testid="virtual-list-item"
data-testid="draggable-a-0"
>
<div
data-testid="draggable-a-0"
style="margin-bottom: 8px;"
>
<div
style="margin-bottom: 8px;"
data-testid="item"
>
<div
data-testid="item"
>
A
</div>
A
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
data-testid="draggable-b-1"
>
<div
data-testid="draggable-b-1"
style="margin-bottom: 8px;"
>
<div
style="margin-bottom: 8px;"
data-testid="item"
>
<div
data-testid="item"
>
B
</div>
B
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
data-testid="draggable-c-2"
>
<div
data-testid="draggable-c-2"
style="margin-bottom: 8px;"
>
<div
style="margin-bottom: 8px;"
data-testid="item"
>
<div
data-testid="item"
>
C
</div>
C
</div>
</div>
</div>
@@ -1,3 +1,7 @@
export { default as DraggableList } from './list'
export { useDraggableReorder } from './useDraggableReorder'
export { default as DraggableVirtualList } from './virtual-list'
export {
default as DraggableVirtualList,
type DraggableVirtualListProps,
type DraggableVirtualListRef
} from './virtual-list'
@@ -9,13 +9,13 @@ import {
ResponderProvided
} from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils'
import VirtualList from 'rc-virtual-list'
import { FC } from 'react'
import { FC, HTMLAttributes } from 'react'
interface Props<T> {
list: T[]
style?: React.CSSProperties
listStyle?: React.CSSProperties
listProps?: HTMLAttributes<HTMLDivElement>
children: (item: T, index: number) => React.ReactNode
onUpdate: (list: T[]) => void
onDragStart?: OnDragStartResponder
@@ -28,6 +28,7 @@ const DraggableList: FC<Props<any>> = ({
list,
style,
listStyle,
listProps,
droppableProps,
onDragStart,
onUpdate,
@@ -38,8 +39,10 @@ const DraggableList: FC<Props<any>> = ({
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
if (sourceIndex !== destIndex) {
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
}
}
}
@@ -48,8 +51,8 @@ const DraggableList: FC<Props<any>> = ({
<Droppable droppableId="droppable" {...droppableProps}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
<VirtualList data={list} itemKey="id">
{(item, index) => {
<div {...listProps} className="draggable-list-container">
{list.map((item, index) => {
const id = item.id || item
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
@@ -68,8 +71,8 @@ const DraggableList: FC<Props<any>> = ({
)}
</Draggable>
)
}}
</VirtualList>
})}
</div>
{provided.placeholder}
</div>
)}
@@ -10,8 +10,19 @@ import {
} from '@hello-pangea/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { droppableReorder } from '@renderer/utils'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type Key, memo, useCallback, useRef } from 'react'
import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react'
export interface DraggableVirtualListRef {
measure: () => void
scrollElement: () => HTMLDivElement | null
scrollToOffset: (offset: number, options?: ScrollToOptions) => void
scrollToIndex: (index: number, options?: ScrollToOptions) => void
resizeItem: (index: number, size: number) => void
getTotalSize: () => number
getVirtualItems: () => VirtualItem[]
getVirtualIndexes: () => number[]
}
/**
* Props DraggableVirtualList
@@ -31,8 +42,8 @@ import { type Key, memo, useCallback, useRef } from 'react'
* @property {React.ReactNode} [header]
* @property {(item: T, index: number) => React.ReactNode} children
*/
interface DraggableVirtualListProps<T> {
ref?: React.Ref<HTMLDivElement>
export interface DraggableVirtualListProps<T> {
ref?: React.Ref<DraggableVirtualListRef>
className?: string
style?: React.CSSProperties
scrollerStyle?: React.CSSProperties
@@ -82,8 +93,10 @@ function DraggableVirtualList<T>({
if (onUpdate && result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
if (sourceIndex !== destIndex) {
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
}
}
}
@@ -98,9 +111,23 @@ function DraggableVirtualList<T>({
overscan
})
useImperativeHandle(
ref,
() => ({
measure: () => virtualizer.measure(),
scrollElement: () => virtualizer.scrollElement,
scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options),
scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options),
resizeItem: (index, size) => virtualizer.resizeItem(index, size),
getTotalSize: () => virtualizer.getTotalSize(),
getVirtualItems: () => virtualizer.getVirtualItems(),
getVirtualIndexes: () => virtualizer.getVirtualIndexes()
}),
[virtualizer]
)
return (
<div
ref={ref}
className={`${className} draggable-virtual-list`}
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
@@ -63,6 +63,7 @@ const EditableNumber: FC<EditableNumberProps> = ({
if (e.key === 'Enter') {
handleBlur()
} else if (e.key === 'Escape') {
e.stopPropagation()
setInputValue(value)
setIsEditing(false)
}
@@ -0,0 +1,57 @@
import { formatErrorMessage } from '@renderer/utils/error'
import { Alert, Button, Space } from 'antd'
import { ComponentType, ReactNode } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const DefaultFallback: ComponentType<FallbackProps> = (props: FallbackProps): ReactNode => {
const { t } = useTranslation()
const { error } = props
const debug = async () => {
await window.api.devTools.toggle()
}
const reload = async () => {
await window.api.reload()
}
return (
<ErrorContainer>
<Alert
message={t('error.boundary.default.message')}
showIcon
description={formatErrorMessage(error)}
type="error"
action={
<Space>
<Button size="small" danger onClick={debug}>
{t('error.boundary.default.devtools')}
</Button>
<Button size="small" danger onClick={reload}>
{t('error.boundary.default.reload')}
</Button>
</Space>
}
/>
</ErrorContainer>
)
}
const ErrorBoundaryCustomized = ({
children,
fallbackComponent
}: {
children: ReactNode
fallbackComponent?: ComponentType<FallbackProps>
}) => {
return <ErrorBoundary FallbackComponent={fallbackComponent ?? DefaultFallback}>{children}</ErrorBoundary>
}
const ErrorContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 8px;
`
export { ErrorBoundaryCustomized as ErrorBoundary }
+55 -10
View File
@@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fill-rule="evenodd"
fillRule="evenodd"
width="1em"
height="1em"
viewBox="0 0 24 24"
@@ -193,7 +193,7 @@ export function ExaLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fill-rule="evenodd"
fillRule="evenodd"
width="1em"
height="1em"
viewBox="0 0 24 24"
@@ -211,30 +211,75 @@ export function BochaLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg width="1em" height="1em" viewBox="0 0 135 116" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M12.5754 13.8123C24.6109 7.94459 39.1223 12.9435 44.9955 24.9805L57.5355 50.6805C60.4695 56.6936 57.9756 63.9478 51.9652 66.8832C51.9627 66.8844 51.9602 66.8856 51.9577 66.8868C45.94 69.8206 38.6843 67.3212 35.7477 61.3027L12.5754 13.8123Z"
fill="currentColor"
/>
<path
opacity="0.64774"
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M0 38.3013C9.46916 28.836 24.813 28.836 34.2822 38.3013L55.2526 59.2631C59.9819 63.9904 59.9852 71.6582 55.2601 76.3896C55.2576 76.3921 55.2551 76.3946 55.2526 76.397C50.5181 81.1297 42.8461 81.1297 38.1116 76.397L0 38.3013Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M86.8777 18.0444C113.939 18.0444 135.876 39.9725 135.876 67.0222C135.876 80.2286 129.086 93.6477 120.585 102.457L117.065 98.2367C111.026 90.9998 108.882 81.2777 111.314 72.1702C111.755 70.5198 111.976 69.0033 111.976 67.6209C111.976 53.6689 100.661 42.3586 86.7029 42.3586C72.7452 42.3586 61.4303 53.6689 61.4303 67.6209C61.4303 81.5728 72.7452 92.8831 86.7029 92.8831C89.3159 92.8831 91.8363 92.4867 94.2071 91.7508C101.312 89.5455 109.054 91.3768 114.419 96.5322L120.585 102.457C111.83 110.626 99.7992 116 86.8777 116C59.8168 116 37.8796 94.0719 37.8796 67.0222C37.8796 39.9725 59.8168 18.0444 86.8777 18.0444Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M37.8796 0C51.2677 0 62.1208 10.8581 62.1208 24.2522V41.7389C62.1208 55.133 51.2677 65.9911 37.8796 65.9911V0Z"
fill="currentColor"
/>
</svg>
)
}
export function PoeLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill="currentColor"
fillRule="evenodd"
height="1em"
width="1em"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<title>Poe</title>
<path d="M20.708 6.876a1.412 1.412 0 00-1.029-.415h-.006a2.019 2.019 0 01-2.02-2.023A1.415 1.415 0 0016.254 3H4.871A1.412 1.412 0 003.47 4.434a2.026 2.026 0 01-2.025 2.025v.002A1.414 1.414 0 000 7.883v3.642a1.414 1.414 0 001.444 1.42 2.025 2.025 0 012.025 2.02v3.693a.5.5 0 00.89.313l2.051-2.567h9.843a1.412 1.412 0 001.4-1.434v-.002c0-1.12.904-2.025 2.026-2.025a1.412 1.412 0 001.446-1.42V7.88c0-.363-.14-.727-.417-1.005zm-2.42 4.687a2.025 2.025 0 01-2.025 2.005H4.861a2.025 2.025 0 01-2.025-2.005v-3.72A2.026 2.026 0 014.86 5.838h11.4a2.026 2.026 0 012.026 2.005v3.72h.002z"></path>
<path d="M7.413 7.57A1.422 1.422 0 005.99 8.99v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422zm6.297 0a1.422 1.422 0 00-1.422 1.421v1.422a1.422 1.422 0 102.844 0V8.99c0-.784-.636-1.422-1.422-1.422z"></path>
<path
d="M7.292 22.643l1.993-2.492h9.844a1.413 1.413 0 001.4-1.434 2.025 2.025 0 012.017-2.027h.01A1.409 1.409 0 0024 15.27v-3.594c0-.344-.113-.68-.324-.951l-.397-.519v4.127a1.415 1.415 0 01-1.444 1.42h-.007a2.026 2.026 0 00-2.018 2.025 1.415 1.415 0 01-1.402 1.436H8.565l-2.169 2.712a.574.574 0 00.896.715v.002z"
fill="url(#lobe-icons-poe-fill-0)"></path>
<path
d="M5.004 19.992l2.12-2.65h9.844a1.414 1.414 0 001.402-1.437c0-1.116.9-2.021 2.014-2.025h.012a1.413 1.413 0 001.443-1.422v-4.13l.52.68c.21.273.324.607.324.95v3.594a1.416 1.416 0 01-1.443 1.42h-.01a2.026 2.026 0 00-2.016 2.026 1.414 1.414 0 01-1.402 1.435H7.97l-1.916 2.4a.671.671 0 01-1.049-.839v-.002z"
fill="url(#lobe-icons-poe-fill-1)"></path>
<defs>
<linearGradient
gradientUnits="userSpaceOnUse"
id="lobe-icons-poe-fill-0"
x1="34.01"
x2="1.086"
y1="7.303"
y2="27.715">
<stop stopColor="#46A6F7"></stop>
<stop offset="1" stop-color="#8364FF"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
id="lobe-icons-poe-fill-1"
x1="4.915"
x2="24.34"
y1="23.511"
y2="9.464">
<stop stopColor="#FF44D3"></stop>
<stop offset="1" stop-color="#CF4BFF"></stop>
</linearGradient>
</defs>
</svg>
)
}
@@ -47,7 +47,7 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
}
return (
<div ref={hostRef}>
<div ref={hostRef} style={{ display: 'none' }}>
{createPortal(
<StyleSheetManager target={shadowRoot}>
<StyleProvider container={shadowRoot} layer>
@@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
@@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => {
const isInDevelopment = process.env.NODE_ENV === 'development'
const { setTimeoutTimer } = useTimer()
useBridge()
/** set the popup display status */
@@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
if (appid == currentMinappId) {
setTimeout(() => setIsReady(true), 200)
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
}
}
@@ -8,20 +8,19 @@ import {
} from '@renderer/config/models'
import i18n from '@renderer/i18n'
import { Model } from '@renderer/types'
import { isFreeModel } from '@renderer/utils'
import { isFreeModel } from '@renderer/utils/model'
import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CustomTag from './Tags/CustomTag'
import {
EmbeddingTag,
FreeTag,
ReasoningTag,
RerankerTag,
ToolsCallingTag,
VisionTag,
WebSearchTag
} from './Tags/ModelCapabilities'
} from './Tags/Model'
interface ModelTagsProps {
model: Model
@@ -44,7 +43,6 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
showTooltip = true,
style
}) => {
const { t } = useTranslation()
const [shouldShowLabel, setShouldShowLabel] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const resizeObserver = useRef<ResizeObserver | null>(null)
@@ -86,7 +84,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
<ToolsCallingTag size={size} showTooltip={showTooltip} showLabel={shouldShowLabel} />
)}
{isEmbeddingModel(model) && <EmbeddingTag size={size} />}
{showFree && isFreeModel(model) && <CustomTag size={size} color="#7cb305" icon={t('models.type.free')} />}
{showFree && isFreeModel(model) && <FreeTag size={size} />}
{isRerankModel(model) && <RerankerTag size={size} />}
</Container>
)
@@ -1,6 +1,7 @@
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { useSystemAgents } from '@renderer/pages/agents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@@ -33,6 +34,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const { setTimeoutTimer } = useTimer()
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
@@ -80,11 +82,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
assistant = await createAssistantFromAgent(agent)
}
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant)
setOpen(false)
},
[resolve, addAssistant, setOpen]
[setTimeoutTimer, resolve, addAssistant]
) // 添加函数内使用的依赖项
// 键盘导航处理
useEffect(() => {
@@ -1,6 +1,6 @@
import { Input, Modal } from 'antd'
import { TextAreaProps } from 'antd/es/input'
import { useRef, useState } from 'react'
import { ReactNode, useRef, useState } from 'react'
import { Box } from '../Layout'
import { TopView } from '../TopView'
@@ -11,6 +11,7 @@ interface PromptPopupShowParams {
defaultValue?: string
inputPlaceholder?: string
inputProps?: TextAreaProps
extraNode?: ReactNode
}
interface Props extends PromptPopupShowParams {
@@ -23,6 +24,7 @@ const PromptPopupContainer: React.FC<Props> = ({
defaultValue = '',
inputPlaceholder = '',
inputProps = {},
extraNode = null,
resolve
}) => {
const [value, setValue] = useState(defaultValue)
@@ -88,6 +90,7 @@ const PromptPopupContainer: React.FC<Props> = ({
rows={1}
{...inputProps}
/>
{extraNode}
</Modal>
)
}
@@ -1,16 +1,36 @@
import { PushpinOutlined } from '@ant-design/icons'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import {
EmbeddingTag,
FreeTag,
ReasoningTag,
RerankerTag,
ToolsCallingTag,
VisionTag,
WebSearchTag
} from '@renderer/components/Tags/Model'
import { TopView } from '@renderer/components/TopView'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import {
getModelLogo,
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model, Provider } from '@renderer/types'
import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types'
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
import { Avatar, Divider, Empty, Modal } from 'antd'
import { getModelTags, isFreeModel } from '@renderer/utils/model'
import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd'
import { first, sortBy } from 'lodash'
import { SettingsIcon } from 'lucide-react'
import React, {
ReactNode,
startTransition,
useCallback,
useDeferredValue,
@@ -24,22 +44,28 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SelectModelSearchBar from './searchbar'
import { FlatListItem } from './types'
import { FlatListItem, FlatListModel } from './types'
const PAGE_SIZE = 11
const PAGE_SIZE = 12
const ITEM_HEIGHT = 36
type ModelPredict = (m: Model) => boolean
interface PopupParams {
model?: Model
modelFilter?: (model: Model) => boolean
userFilterDisabled?: boolean
}
interface Props extends PopupParams {
resolve: (value: Model | undefined) => void
modelFilter?: (model: Model) => boolean
}
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
export type FilterType = Exclude<ModelType, 'text'> | 'free'
// const logger = loggerService.withContext('SelectModelPopup')
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilterDisabled }) => {
const { t } = useTranslation()
const { providers } = useProviders()
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
@@ -48,6 +74,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const allModels: Model[] = useMemo(
() => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)),
[modelFilter, providers]
)
// 当前选中的模型ID
const currentModelId = model ? getModelUniqId(model) : ''
@@ -62,10 +93,99 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
})
}, [])
// 管理用户筛选状态
/** 从模型列表获取的需要显示的标签 */
const availableTags = useMemo(
() =>
objectEntries(getModelTags(allModels))
.filter(([, state]) => state)
.map(([tag]) => tag),
[allModels]
)
const filterConfig: Record<ModelTag, ModelPredict> = useMemo(
() => ({
vision: isVisionModel,
embedding: isEmbeddingModel,
reasoning: isReasoningModel,
function_calling: isFunctionCallingModel,
web_search: isWebSearchModel,
rerank: isRerankModel,
free: isFreeModel
}),
[]
)
/** 当前选择的标签,表示是否启用特定tag的筛选 */
const [filterTags, setFilterTags] = useState<Record<ModelTag, boolean>>({
vision: false,
embedding: false,
reasoning: false,
function_calling: false,
web_search: false,
rerank: false,
free: false
})
const selectedFilterTags = useMemo(
() =>
objectEntries(filterTags)
.filter(([, state]) => state)
.map(([tag]) => tag),
[filterTags]
)
const userFilter = useCallback(
(model: Model) => {
return selectedFilterTags
.map((tag) => [tag, filterConfig[tag]] as const)
.reduce((prev, [tag, predict]) => {
return prev && (!filterTags[tag] || predict(model))
}, true)
},
[filterConfig, filterTags, selectedFilterTags]
)
const onClickTag = useCallback((type: ModelTag) => {
startTransition(() => {
setFilterTags((prev) => ({ ...prev, [type]: !prev[type] }))
})
}, [])
// 筛选项列表
const tagsItems: Record<ModelTag, ReactNode> = useMemo(
() => ({
vision: <VisionTag showLabel inactive={!filterTags.vision} onClick={() => onClickTag('vision')} />,
embedding: <EmbeddingTag inactive={!filterTags.embedding} onClick={() => onClickTag('embedding')} />,
reasoning: <ReasoningTag showLabel inactive={!filterTags.reasoning} onClick={() => onClickTag('reasoning')} />,
function_calling: (
<ToolsCallingTag
showLabel
inactive={!filterTags.function_calling}
onClick={() => onClickTag('function_calling')}
/>
),
web_search: <WebSearchTag showLabel inactive={!filterTags.web_search} onClick={() => onClickTag('web_search')} />,
rerank: <RerankerTag inactive={!filterTags.rerank} onClick={() => onClickTag('rerank')} />,
free: <FreeTag inactive={!filterTags.free} onClick={() => onClickTag('free')} />
}),
[
filterTags.embedding,
filterTags.free,
filterTags.function_calling,
filterTags.reasoning,
filterTags.rerank,
filterTags.vision,
filterTags.web_search,
onClickTag
]
)
// 要显示的筛选项
const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems])
// 根据输入的文本筛选模型
const getFilteredModels = useCallback(
const searchFilter = useCallback(
(provider: Provider) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
let models = provider.models
if (searchText.trim()) {
models = filterModelsByKeywords(searchText, models, provider)
@@ -78,7 +198,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 创建模型列表项
const createModelItem = useCallback(
(model: Model, provider: Provider, isPinned: boolean): FlatListItem => {
(model: Model, provider: Provider, isPinned: boolean): FlatListModel => {
const modelId = getModelUniqId(model)
const groupName = getFancyProviderName(provider)
@@ -113,7 +233,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const { listItems, modelItems } = useMemo(() => {
const items: FlatListItem[] = []
const pinnedModelIds = new Set(pinnedModels)
const finalModelFilter = modelFilter || (() => true)
const finalModelFilter = (model: Model) => {
const _userFilter = userFilterDisabled || userFilter(model)
const _modelFilter = modelFilter === undefined || modelFilter(model)
return _userFilter && _modelFilter
}
// 添加置顶模型分组(仅在无搜索文本时)
if (searchText.length === 0 && pinnedModelIds.size > 0) {
@@ -139,7 +263,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 添加常规模型分组
providers.forEach((p) => {
const filteredModels = getFilteredModels(p)
const filteredModels = searchFilter(p)
.filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m)))
.filter(finalModelFilter)
@@ -150,6 +274,22 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
key: `provider-${p.id}`,
type: 'group',
name: getFancyProviderName(p),
actions: (
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
<Button
type="text"
size="small"
shape="circle"
icon={<SettingsIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />}
onClick={(e) => {
e.stopPropagation()
setOpen(false)
resolve(undefined)
window.navigate(`/settings/provider?id=${p.id}`)
}}
/>
</Tooltip>
),
isSelected: false
})
@@ -157,9 +297,20 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
})
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[]
const modelItems = items.filter((item) => item.type === 'model')
return { listItems: items, modelItems }
}, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels])
}, [
pinnedModels,
searchText.length,
providers,
userFilterDisabled,
userFilter,
modelFilter,
createModelItem,
t,
searchFilter,
resolve
])
const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
@@ -256,6 +407,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setOpen(false)
resolve(undefined)
break
@@ -306,7 +458,12 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
(item: FlatListItem) => {
const isFocused = item.key === focusedItemKey
if (item.type === 'group') {
return <GroupItem>{item.name}</GroupItem>
return (
<GroupItem>
{item.name}
{item.actions}
</GroupItem>
)
}
return (
<ModelItem
@@ -351,7 +508,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 16
paddingBottom: 16,
// 需要稳定高度避免布局偏移
height: userFilterDisabled ? undefined : 530
},
body: {
maxHeight: 'inherit',
@@ -363,6 +522,17 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
{/* 搜索框 */}
<SelectModelSearchBar onSearch={setSearchText} />
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
{!userFilterDisabled && (
<>
<FilterContainer>
<Flex wrap="wrap" gap={4}>
<FilterText>{t('models.filter.by_tag')}</FilterText>
{displayedTags.map((item) => item)}
</Flex>
</FilterContainer>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
</>
)}
{listItems.length > 0 ? (
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
@@ -388,6 +558,16 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
)
}
const FilterContainer = styled.div`
padding: 8px;
padding-left: 18px;
`
const FilterText = styled.span`
color: var(--color-text-3);
font-size: 12px;
`
const ListContainer = styled.div`
position: relative;
overflow: hidden;
@@ -396,11 +576,12 @@ const ListContainer = styled.div`
const GroupItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
font-size: 12px;
font-weight: 500;
font-weight: normal;
height: ${ITEM_HEIGHT}px;
padding: 5px 10px 5px 18px;
padding: 5px 12px 5px 18px;
color: var(--color-text-3);
z-index: 1;
background: var(--modal-background);
@@ -41,7 +41,7 @@ const SelectModelSearchBar: React.FC<SelectModelSearchBarProps> = ({ onSearch })
</SearchIcon>
}
ref={inputRef}
placeholder={t('models.search')}
placeholder={t('models.search.placeholder')}
value={searchText}
onChange={(e) => handleTextChange(e.target.value)}
onClear={handleClear}
@@ -1,20 +1,46 @@
import { Model } from '@renderer/types'
import { ReactNode } from 'react'
// 列表项类型,组名也作为列表项
export type ListItemType = 'group' | 'model'
// 滚动触发来源类型
/**
*
*/
export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
// 扁平化列表项接口
export interface FlatListItem {
/**
*
*/
export type ListItemType = 'group' | 'model'
/**
*
*/
export type FlatListBaseItem = {
key: string
type: ListItemType
icon?: ReactNode
name: ReactNode
tags?: ReactNode
model?: Model
isPinned?: boolean
icon?: ReactNode
isSelected?: boolean
}
/**
*
*/
export type FlatListGroup = FlatListBaseItem & {
type: 'group'
actions?: ReactNode
}
/**
*
*/
export type FlatListModel = FlatListBaseItem & {
type: 'model'
model: Model
tags?: ReactNode
isPinned?: boolean
}
/**
*
*/
export type FlatListItem = FlatListGroup | FlatListModel
@@ -1,8 +1,8 @@
import { LoadingOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useSettings } from '@renderer/hooks/useSettings'
import useTranslate from '@renderer/hooks/useTranslate'
import { translateText } from '@renderer/services/TranslateService'
import { getLanguageByLangcode } from '@renderer/utils/translate'
import { Modal, ModalProps } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { TextAreaProps } from 'antd/lib/input'
@@ -38,6 +38,7 @@ const PopupContainer: React.FC<Props> = ({
}) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { getLanguageByLangcode } = useTranslate()
const [textValue, setTextValue] = useState(text)
const [isTranslating, setIsTranslating] = useState(false)
const textareaRef = useRef<TextAreaRef>(null)
@@ -2,7 +2,7 @@ import { memo, useCallback } from 'react'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { ShadowTransparentContainer } from './styles'
import { BasicPreviewHandles } from './types'
import { renderSvgInShadowHost } from './utils'
@@ -35,7 +35,8 @@ const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPrev
ref={ref}
imageRef={containerRef}
source="svg">
<ShadowWhiteContainer ref={containerRef} className={className ?? 'svg-preview special-preview'} />
{/* 使用透明容器,把背景色完全交给 SVG 自己控制 */}
<ShadowTransparentContainer ref={containerRef} className={className ?? 'svg-preview special-preview'} />
</ImagePreviewLayout>
)
}
@@ -2,9 +2,9 @@
exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
.c0 {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
--shadow-host-background-color: transparent;
--shadow-host-border: unset;
--shadow-host-border-radius: unset;
}
<div>
+1 -3
View File
@@ -17,9 +17,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
// Sanitize the SVG content
const sanitizedContent = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
RETURN_DOM_FRAGMENT: false,
RETURN_DOM: false
ADD_TAGS: ['foreignObject']
})
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
@@ -0,0 +1,113 @@
import { SearchOutlined } from '@ant-design/icons'
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
import { getProviderLabel } from '@renderer/i18n/label'
import { Input, Tooltip } from 'antd'
import { FC, useMemo, useState } from 'react'
import styled from 'styled-components'
interface Props {
onProviderClick: (providerId: string) => void
}
// 用于选择内置头像的提供商Logo选择器组件
const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
const [searchText, setSearchText] = useState('')
const filteredProviders = useMemo(() => {
const providers = Object.entries(PROVIDER_LOGO_MAP).map(([id, logo]) => ({
id,
logo,
name: getProviderLabel(id)
}))
if (!searchText) return providers
const searchLower = searchText.toLowerCase()
return providers.filter((p) => p.name.toLowerCase().includes(searchLower))
}, [searchText])
const handleProviderClick = (event: React.MouseEvent, providerId: string) => {
event.stopPropagation()
onProviderClick(providerId)
}
return (
<Container>
<SearchContainer>
<Input
placeholder="search"
prefix={<SearchOutlined style={{ color: 'var(--color-text-3)' }} />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
size="small"
allowClear
style={{
borderRadius: 'var(--list-item-border-radius)',
background: 'var(--color-background-soft)'
}}
/>
</SearchContainer>
<LogoGrid>
{filteredProviders.map(({ id, logo, name }) => (
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
<img src={logo} alt={name} draggable={false} />
</LogoItem>
</Tooltip>
))}
</LogoGrid>
</Container>
)
}
const Container = styled.div`
width: 350px;
max-height: 300px;
display: flex;
flex-direction: column;
padding: 12px;
background: var(--color-background);
border-radius: 8px;
`
const SearchContainer = styled.div`
margin-bottom: 12px;
`
const LogoGrid = styled.div`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
overflow-y: auto;
flex: 1;
padding: 4px;
`
const LogoItem = styled.div`
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
&:hover {
background: var(--color-background-mute);
transform: scale(1.05);
border-color: var(--color-primary);
}
img {
width: 32px;
height: 32px;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
`
export default ProviderLogoPicker
@@ -54,6 +54,12 @@ export type QuickPanelListItem = {
isSelected?: boolean
isMenu?: boolean
disabled?: boolean
/**
*
* alwaysVisible
*
*/
alwaysVisible?: boolean
action?: (options: QuickPanelCallBackOptions) => void
}
+116 -101
View File
@@ -1,10 +1,12 @@
import { RightOutlined } from '@ant-design/icons'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { isMac } from '@renderer/config/constant'
import { useTimer } from '@renderer/hooks/useTimer'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { t } from 'i18next'
import { debounce } from 'lodash'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
@@ -62,18 +64,32 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 缓存:按 item 缓存拼音文本,避免重复转换
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
// 轻量防抖:减少高频输入时的过滤调用
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 无匹配项自动关闭的定时器
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 处理搜索,过滤列表
const { setTimeoutTimer } = useTimer()
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
const newList = ctx.list?.filter((item) => {
const _searchText = searchText.replace(/^[/@]/, '')
const _searchText = searchText.replace(/^[/@]/, '')
const lowerSearchText = _searchText.toLowerCase()
const fuzzyPattern = lowerSearchText
.split('')
.map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*')
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
// 拆分:固定显示项(不参与过滤)与普通项
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
const filteredNormalItems = normalItems.filter((item) => {
if (!_searchText) return true
let filterText = item.filterText || ''
@@ -85,29 +101,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = _searchText.toLowerCase()
if (lowerFilterText.includes(lowerSearchText)) {
return true
}
const pattern = lowerSearchText
.split('')
.map((char) => {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
})
.join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
const regex = new RegExp(pattern, 'ig')
return regex.test(pinyinText)
let pinyinText = pinyinCacheRef.current.get(item)
if (!pinyinText) {
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
pinyinCacheRef.current.set(item, pinyinText)
}
return fuzzyRegex.test(pinyinText)
} catch (error) {
return true
}
} else {
const regex = new RegExp(pattern, 'ig')
return regex.test(filterText.toLowerCase())
return fuzzyRegex.test(filterText.toLowerCase())
}
})
@@ -120,8 +131,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
} else {
// 如果当前index超出范围,调整到有效范围内
setIndex((prevIndex) => {
if (prevIndex >= newList.length) {
return newList.length > 0 ? newList.length - 1 : -1
const combinedLength = pinnedItems.length + filteredNormalItems.length
if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
}
return prevIndex
})
@@ -130,76 +142,52 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
return newList
// 固定项置顶 + 过滤后的普通项
return [...pinnedItems, ...filteredNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
}, [list, historyPanel])
// 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板
useEffect(() => {
const _searchText = searchText.replace(/^[/@]/, '')
// 清除之前的定时器(无论面板是否可见都要清理)
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
}
// 面板不可见时不设置新定时器
if (!ctx.isVisible) {
return
}
// 只有在有搜索文本但无匹配项时才设置延迟关闭
if (_searchText && _searchText.length > 0 && list.length === 0) {
noMatchTimeoutRef.current = setTimeout(() => {
ctx.close('no-matches')
}, 300)
}
// 清理函数
return () => {
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染
}, [ctx.isVisible, searchText, list.length, ctx.close])
const clearSearchText = useCallback(
(includeSymbol = false) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return
const cursorPosition = textArea.selectionStart ?? 0
const prevChar = textArea.value[cursorPosition - 1]
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
searchTextRef.current = prevChar
}
const textBeforeCursor = textArea.value.slice(0, cursorPosition)
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
if (!_searchText) return
// 查找最后一个 @ 或 / 符号的位置
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex)
const inputText = textArea.value
let newText = inputText
const searchPattern = new RegExp(`${_searchText}$`)
if (lastSymbolIndex === -1) return
const match = inputText.slice(0, cursorPosition).match(searchPattern)
if (match) {
const start = match.index || 0
const end = start + match[0].length
newText = inputText.slice(0, start) + inputText.slice(end)
setInputText(newText)
// 根据 includeSymbol 决定是否删除符号
const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1
const deleteEnd = cursorPosition
setTimeout(() => {
if (deleteStart >= deleteEnd) return
// 删除文本
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
setInputText(newText)
// 设置光标位置
setTimeoutTimer(
'quickpanel_focus',
() => {
textArea.focus()
textArea.setSelectionRange(start, start)
}, 0)
}
textArea.setSelectionRange(deleteStart, deleteStart)
},
0
)
setSearchText('')
},
[setInputText]
[setInputText, setTimeoutTimer]
)
const handleClose = useCallback(
@@ -310,9 +298,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (lastSymbolIndex !== -1) {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchText(newSearchText)
setSearchTextDebounced(newSearchText)
} else {
ctx.close('delete-symbol')
// 使用本地 handleClose,确保在删除触发符时同步受控输入值
handleClose('delete-symbol')
}
}
@@ -333,9 +322,14 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
textArea.removeEventListener('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
textArea.removeEventListener('compositionend', handleCompositionEnd)
setTimeout(() => {
setSearchText('')
}, 200) // 等待面板关闭动画结束后,再清空搜索词
setSearchTextDebounced.cancel()
setTimeoutTimer(
'quickpanel_clear_search',
() => {
setSearchText('')
},
200
) // 等待面板关闭动画结束后,再清空搜索词
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
@@ -349,9 +343,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'none'
}, [index])
// 处理键盘事件
// 处理键盘事件(折叠时不拦截全局键盘)
useEffect(() => {
if (!ctx.isVisible) return
const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0
const isCollapsed = hasSearchTextFlag && list.length === 0
if (!ctx.isVisible || isCollapsed) return
const handleKeyDown = (e: KeyboardEvent) => {
if (isMac ? e.metaKey : e.ctrlKey) {
@@ -458,6 +454,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
break
case 'Escape':
e.stopPropagation()
handleClose('esc')
break
}
@@ -477,16 +474,26 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('click', handleClickOutside)
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('keyup', handleKeyUp, true)
window.addEventListener('click', handleClickOutside, true)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('keyup', handleKeyUp, true)
window.removeEventListener('click', handleClickOutside, true)
}
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
}, [
index,
isAssistiveKeyPressed,
historyPanel,
ctx,
list,
handleItemAction,
handleClose,
clearSearchText,
searchText
])
const [footerWidth, setFooterWidth] = useState(0)
@@ -506,6 +513,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const listHeight = useMemo(() => {
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
}, [ctx.pageSize, list.length])
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
const collapsed = hasSearchText && visibleNonPinnedCount === 0
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
@@ -553,6 +564,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$pageSize={ctx.pageSize}
$selectedColor={selectedColor}
$selectedColorHover={selectedColorHover}
$collapsed={collapsed}
className={ctx.isVisible ? 'visible' : ''}
data-testid="quick-panel">
<QuickPanelBody
@@ -563,17 +575,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true
})
}>
<DynamicVirtualList
ref={listRef}
list={list}
size={listHeight}
estimateSize={estimateSize}
overscan={5}
scrollerStyle={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{rowRenderer}
</DynamicVirtualList>
{!collapsed && (
<DynamicVirtualList
ref={listRef}
list={list}
size={listHeight}
estimateSize={estimateSize}
overscan={5}
scrollerStyle={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{rowRenderer}
</DynamicVirtualList>
)}
<QuickPanelFooter ref={footerRef}>
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
<QuickPanelFooterTips $footerWidth={footerWidth}>
@@ -617,6 +631,7 @@ const QuickPanelContainer = styled.div<{
$pageSize: number
$selectedColor: string
$selectedColorHover: string
$collapsed?: boolean
}>`
--focused-color: rgba(0, 0, 0, 0.06);
--selected-color: ${(props) => props.$selectedColor};
@@ -635,8 +650,8 @@ const QuickPanelContainer = styled.div<{
pointer-events: none;
&.visible {
pointer-events: auto;
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px;
pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')};
max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px;
}
body[theme-mode='dark'] & {
--focused-color: rgba(255, 255, 255, 0.1);
@@ -14,6 +14,7 @@ import { Tooltip } from 'antd'
import {
FileSearch,
Folder,
Hammer,
Home,
Languages,
LayoutGrid,
@@ -22,7 +23,6 @@ import {
Palette,
Settings,
Sparkle,
SquareTerminal,
Sun,
Terminal,
X
@@ -53,7 +53,7 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => {
case 'knowledge':
return <FileSearch size={14} />
case 'mcp':
return <SquareTerminal size={14} />
return <Hammer size={14} />
case 'files':
return <Folder size={14} />
case 'settings':
@@ -206,8 +206,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
-webkit-app-region: drag;
height: var(--navbar-height);
position: relative;
-webkit-app-region: drag;
/* 确保交互元素在拖拽区域之上 */
> * {
position: relative;
z-index: 1;
-webkit-app-region: no-drag;
}
`
const Tab = styled.div<{ active?: boolean }>`
@@ -220,7 +228,6 @@ const Tab = styled.div<{ active?: boolean }>`
border-radius: var(--list-item-border-radius);
cursor: pointer;
user-select: none;
-webkit-app-region: none;
height: 30px;
min-width: 90px;
transition: background 0.2s;
@@ -273,7 +280,6 @@ const AddTabButton = styled.div`
height: 30px;
cursor: pointer;
color: var(--color-text-2);
-webkit-app-region: none;
border-radius: var(--list-item-border-radius);
&.active {
background: var(--color-list-item);
@@ -298,7 +304,6 @@ const ThemeButton = styled.div`
height: 30px;
cursor: pointer;
color: var(--color-text);
-webkit-app-region: none;
&:hover {
background: var(--color-list-item);
@@ -314,7 +319,6 @@ const SettingsButton = styled.div<{ $active: boolean }>`
height: 30px;
cursor: pointer;
color: var(--color-text);
-webkit-app-region: none;
border-radius: 8px;
background: ${(props) => (props.$active ? 'var(--color-list-item)' : 'transparent')};
&:hover {
@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next'
import CustomTag, { CustomTagProps } from '../CustomTag'
type Props = {
size?: number
showTooltip?: boolean
} & Omit<CustomTagProps, 'size' | 'tooltip' | 'icon' | 'color' | 'children'>
export const FreeTag = ({ size, showTooltip, ...restProps }: Props) => {
const { t } = useTranslation()
return (
<CustomTag
size={size}
color="#7cb305"
icon={t('models.type.free')}
tooltip={showTooltip ? t('models.type.free') : undefined}
{...restProps}></CustomTag>
)
}
@@ -1,8 +1,9 @@
import { EmbeddingTag } from './EmbeddingTag'
import { FreeTag } from './FreeTag'
import { ReasoningTag } from './ReasoningTag'
import { RerankerTag } from './RerankerTag'
import { ToolsCallingTag } from './ToolsCallingTag'
import { VisionTag } from './VisionTag'
import { WebSearchTag } from './WebSearchTag'
export { EmbeddingTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag }
export { EmbeddingTag, FreeTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag }
@@ -1,5 +1,7 @@
// import { loggerService } from '@logger'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { message, Modal } from 'antd'
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
@@ -24,6 +26,8 @@ type ElementItem = {
element: React.FC | React.ReactNode
}
// const logger = loggerService.withContext('TopView')
const TopViewContainer: React.FC<Props> = ({ children }) => {
const [elements, setElements] = useState<ElementItem[]>([])
const elementsRef = useRef<ElementItem[]>([])
@@ -31,6 +35,8 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
const [messageApi, messageContextHolder] = message.useMessage()
const [modal, modalContextHolder] = Modal.useModal()
const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
useAppInit()
@@ -72,6 +78,21 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
)
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// logger.debug('keydown', e)
if (!enableQuitFullScreen) return
if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
window.api.setFullScreen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
})
return (
<>
{children}
+8 -23
View File
@@ -1,6 +1,6 @@
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
import { isMac } from '@renderer/config/constant'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import { UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
@@ -9,13 +9,12 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { getSidebarIconLabel, getThemeModeLabel } from '@renderer/i18n/label'
import { ThemeMode } from '@renderer/types'
import { isEmoji } from '@renderer/utils'
import { Avatar, Tooltip } from 'antd'
import {
CircleHelp,
Code,
FileSearch,
Folder,
Languages,
@@ -37,8 +36,8 @@ import UserPopup from '../Popups/UserPopup'
import { SidebarOpenedMinappTabs, SidebarPinnedApps } from './PinnedMinapps'
const Sidebar: FC = () => {
const { hideMinappPopup, openMinapp } = useMinappPopup()
const { minappShow, currentMinappId } = useRuntime()
const { hideMinappPopup } = useMinappPopup()
const { minappShow } = useRuntime()
const { sidebarIcons } = useSettings()
const { pinned } = useMinapps()
@@ -60,17 +59,6 @@ const Sidebar: FC = () => {
navigate(path)
}
const docsId = 'cherrystudio-docs'
const onOpenDocs = () => {
const isChinese = i18n.language.startsWith('zh')
openMinapp({
id: docsId,
name: t('docs.title'),
url: isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us',
logo: AppLogo
})
}
const isFullscreen = useFullscreen()
return (
@@ -100,11 +88,6 @@ const Sidebar: FC = () => {
)}
</MainMenusContainer>
<Menus>
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
<CircleHelp size={20} className="icon" />
</Icon>
</Tooltip>
<Tooltip
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
mouseEnterDelay={0.8}
@@ -153,7 +136,8 @@ const MainMenus: FC = () => {
translate: <Languages size={18} className="icon" />,
minapp: <LayoutGrid size={18} className="icon" />,
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={17} className="icon" />
files: <Folder size={17} className="icon" />,
code_tools: <Code size={18} className="icon" />
}
const pathMap = {
@@ -163,7 +147,8 @@ const MainMenus: FC = () => {
translate: '/translate',
minapp: '/apps',
knowledge: '/knowledge',
files: '/files'
files: '/files',
code_tools: '/code'
}
return sidebarIcons.visible.map((icon) => {
@@ -0,0 +1,110 @@
import { DraggableSyntheticListeners } from '@dnd-kit/core'
import { Transform } from '@dnd-kit/utilities'
import { classNames } from '@renderer/utils'
import React, { useEffect } from 'react'
import styled from 'styled-components'
interface ItemRendererProps<T> {
ref?: React.Ref<HTMLDivElement>
item: T
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
dragging?: boolean
dragOverlay?: boolean
ghost?: boolean
transform?: Transform | null
transition?: string | null
listeners?: DraggableSyntheticListeners
}
export function ItemRenderer<T>({
ref,
item,
renderItem,
dragging,
dragOverlay,
ghost,
transform,
transition,
listeners,
...props
}: ItemRendererProps<T>) {
useEffect(() => {
if (!dragOverlay) {
return
}
document.body.style.cursor = 'grabbing'
return () => {
document.body.style.cursor = ''
}
}, [dragOverlay])
const wrapperStyle = {
transition,
'--translate-x': transform ? `${Math.round(transform.x)}px` : undefined,
'--translate-y': transform ? `${Math.round(transform.y)}px` : undefined,
'--scale-x': transform?.scaleX ? `${transform.scaleX}` : undefined,
'--scale-y': transform?.scaleY ? `${transform.scaleY}` : undefined
} as React.CSSProperties
return (
<ItemWrapper ref={ref} className={classNames({ dragOverlay: dragOverlay })} style={{ ...wrapperStyle }}>
<DraggableItem
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
{...listeners}
{...props}>
{renderItem(item, { dragging: !!dragging })}
</DraggableItem>
</ItemWrapper>
)
}
const ItemWrapper = styled.div`
box-sizing: border-box;
transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1))
scaleY(var(--scale-y, 1));
transform-origin: 0 0;
touch-action: manipulation;
&.dragOverlay {
--scale: 1.02;
z-index: 999;
position: relative;
}
`
const DraggableItem = styled.div`
position: relative;
box-sizing: border-box;
cursor: pointer; /* default cursor for items */
touch-action: manipulation;
transform-origin: 50% 50%;
transform: scale(var(--scale, 1));
&.dragging:not(.dragOverlay) {
z-index: 0;
opacity: 0.25;
&:not(.ghost) {
opacity: 0;
}
}
&.dragOverlay {
cursor: inherit;
animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22);
transform: scale(var(--scale));
opacity: 1;
pointer-events: none; /* prevent pointer events on drag overlay */
}
@keyframes pop {
0% {
transform: scale(1);
}
100% {
transform: scale(var(--scale));
}
}
`
@@ -0,0 +1,192 @@
import {
Active,
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
DropAnimation,
KeyboardSensor,
Over,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors
} from '@dnd-kit/core'
import { restrictToHorizontalAxis, restrictToVerticalAxis } from '@dnd-kit/modifiers'
import {
horizontalListSortingStrategy,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import React, { useCallback, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import styled from 'styled-components'
import { ItemRenderer } from './ItemRenderer'
import { SortableItem } from './SortableItem'
import { PortalSafePointerSensor } from './utils'
interface SortableProps<T> {
/** Array of sortable items */
items: T[]
/** Function or key to get unique identifier for each item */
itemKey: keyof T | ((item: T) => string | number)
/** Callback when sorting is complete, receives old and new indices */
onSortEnd: (event: { oldIndex: number; newIndex: number }) => void
/** Callback when drag starts, will be passed to dnd-kit's onDragStart */
onDragStart?: (event: { active: Active }) => void
/** Callback when drag ends, will be passed to dnd-kit's onDragEnd */
onDragEnd?: (event: { over: Over }) => void
/** Function to render individual item, receives item data and drag state */
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
/** Layout type - 'list' for vertical/horizontal list, 'grid' for grid layout */
layout?: 'list' | 'grid'
/** Whether sorting is horizontal */
horizontal?: boolean
/** Whether to use drag overlay
* If you want to hide ghost item, set showGhost to false rather than useDragOverlay.
*/
useDragOverlay?: boolean
/** Whether to show ghost item, only works when useDragOverlay is true */
showGhost?: boolean
/** Item list class name */
className?: string
/** Item list style */
listStyle?: React.CSSProperties
/** Ghost item style */
ghostItemStyle?: React.CSSProperties
}
function Sortable<T>({
items,
itemKey,
onSortEnd,
onDragStart: customOnDragStart,
onDragEnd: customOnDragEnd,
renderItem,
layout = 'list',
horizontal = false,
useDragOverlay = true,
showGhost = false,
className,
listStyle
}: SortableProps<T>) {
const sensors = useSensors(
useSensor(PortalSafePointerSensor, {
activationConstraint: {
distance: 8
}
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 5
}
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)
const getId = useCallback(
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as string | number)),
[itemKey]
)
const itemIds = useMemo(() => items.map(getId), [items, getId])
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const activeItem = activeId ? items.find((item) => getId(item) === activeId) : null
const getIndex = (id: UniqueIdentifier) => itemIds.indexOf(id)
const activeIndex = activeId ? getIndex(activeId) : -1
const handleDragStart = ({ active }) => {
customOnDragStart?.({ active })
if (active) {
setActiveId(active.id)
}
}
const handleDragEnd = ({ over }) => {
setActiveId(null)
customOnDragEnd?.({ over })
if (over) {
const overIndex = getIndex(over.id)
if (activeIndex !== overIndex) {
onSortEnd({ oldIndex: activeIndex, newIndex: overIndex })
}
}
}
const handleDragCancel = () => {
setActiveId(null)
}
const strategy =
layout === 'list' ? (horizontal ? horizontalListSortingStrategy : verticalListSortingStrategy) : rectSortingStrategy
const modifiers = layout === 'list' ? (horizontal ? [restrictToHorizontalAxis] : [restrictToVerticalAxis]) : []
const dropAnimation: DropAnimation = useMemo(
() => ({
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: { opacity: showGhost ? '0.25' : '0' }
}
})
}),
[showGhost]
)
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={modifiers}>
<SortableContext items={itemIds} strategy={strategy}>
<ListWrapper className={className} data-layout={layout} style={listStyle}>
{items.map((item, index) => (
<SortableItem
key={itemIds[index]}
item={item}
getId={getId}
renderItem={renderItem}
useDragOverlay={useDragOverlay}
showGhost={showGhost}
/>
))}
</ListWrapper>
</SortableContext>
{useDragOverlay
? createPortal(
<DragOverlay adjustScale dropAnimation={dropAnimation}>
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
</DragOverlay>,
document.body
)
: null}
</DndContext>
)
}
const ListWrapper = styled.div`
&[data-layout='grid'] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
width: 100%;
gap: 12px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
`
export default Sortable
@@ -0,0 +1,41 @@
import { useSortable } from '@dnd-kit/sortable'
import React from 'react'
import { ItemRenderer } from './ItemRenderer'
interface SortableItemProps<T> {
item: T
getId: (item: T) => string | number
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
useDragOverlay?: boolean
showGhost?: boolean
}
export function SortableItem<T>({
item,
getId,
renderItem,
useDragOverlay = true,
showGhost = true
}: SortableItemProps<T>) {
const id = getId(item)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id
})
return (
<ItemRenderer
ref={setNodeRef}
item={item}
renderItem={renderItem}
dragging={isDragging}
dragOverlay={!useDragOverlay && isDragging}
ghost={showGhost && useDragOverlay && isDragging}
transform={transform}
transition={transition}
listeners={listeners}
{...attributes}
/>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { default as Sortable } from './Sortable'
export * from './useDndReorder'
export * from './useDndState'
@@ -0,0 +1,74 @@
import { Key, useCallback, useMemo } from 'react'
interface UseDndReorderParams<T> {
/** 原始的、完整的数据列表 */
originalList: T[]
/** 当前在界面上渲染的、可能被过滤的列表 */
filteredList: T[]
/** 用于更新原始列表状态的函数 */
onUpdate: (newList: T[]) => void
/** 用于从列表项中获取唯一ID的属性名或函数 */
idKey: keyof T | ((item: T) => Key)
}
/**
*
*
* @template T
* @param params - { originalList, filteredList, onUpdate, idKey }
* @returns Sortable onSortEnd
*/
export function useDndReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams<T>) {
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
// 创建从 item ID 到其在 *原始列表* 中索引的映射
const itemIndexMap = useMemo(() => {
const map = new Map<Key, number>()
originalList.forEach((item, index) => {
map.set(getId(item), index)
})
return map
}, [originalList, getId])
// 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引
const getItemKey = useCallback(
(index: number): Key => {
const item = filteredList[index]
// 如果找不到item,返回视图索引兜底
if (!item) return index
const originalIndex = itemIndexMap.get(getId(item))
return originalIndex ?? index
},
[filteredList, itemIndexMap, getId]
)
// 创建 onSortEnd 回调,封装了所有重排逻辑
const onSortEnd = useCallback(
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
// 使用 getItemKey 将视图索引转换为数据索引
const sourceOriginalIndex = getItemKey(oldIndex) as number
const destOriginalIndex = getItemKey(newIndex) as number
// 如果索引转换失败,不进行任何操作
if (sourceOriginalIndex === undefined || destOriginalIndex === undefined) {
return
}
if (sourceOriginalIndex === destOriginalIndex) {
return
}
// 操作原始列表的副本
const newList = [...originalList]
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
newList.splice(destOriginalIndex, 0, movedItem)
// 调用外部更新函数
onUpdate(newList)
},
[getItemKey, originalList, onUpdate]
)
return { onSortEnd, itemKey: getItemKey }
}
@@ -0,0 +1,28 @@
import { useDndContext } from '@dnd-kit/core'
interface DndState {
/** 是否有元素正在拖拽 */
isDragging: boolean
/** 当前拖拽元素的ID */
draggedId: string | number | null
/** 当前悬停位置的ID */
overId: string | number | null
/** 是否正在悬停在某个可放置区域 */
isOver: boolean
}
/**
* dnd-kit
*
* @returns
*/
export function useDndState(): DndState {
const { active, over } = useDndContext()
return {
isDragging: active !== null,
draggedId: active?.id ?? null,
overId: over?.id ?? null,
isOver: over !== null
}
}

Some files were not shown because too many files have changed in this diff Show More