Compare commits
6 Commits
feat/provi
...
v1.5.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cd453ca4 | ||
|
|
ca3c9927d2 | ||
|
|
8e82f54c2b | ||
|
|
ea797ac88a | ||
|
|
fe097a937c | ||
|
|
1ac32bad14 |
12
.github/workflows/pr-ci.yml
vendored
12
.github/workflows/pr-ci.yml
vendored
@@ -45,14 +45,8 @@ 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
|
||||
|
||||
45
.vscode/launch.json
vendored
45
.vscode/launch.json
vendored
@@ -1,40 +1,39 @@
|
||||
{
|
||||
"compounds": [
|
||||
{
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"name": "Debug All",
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"cwd": "${workspaceRoot}",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "0.2.0"
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
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 {
|
||||
@@ -118,6 +118,7 @@ releaseInfo:
|
||||
releaseNotes: |
|
||||
输入框快捷菜单增加清除按钮
|
||||
侧边栏增加代码工具入口,代码工具增加环境变量设置
|
||||
翻译列表增加搜索功能
|
||||
小程序增加多语言显示
|
||||
优化 MCP 服务器列表
|
||||
新增 Web 搜索图标
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.5.7-rc.2",
|
||||
"version": "1.5.7",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -79,8 +79,6 @@
|
||||
"officeparser": "^4.2.0",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"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": {
|
||||
@@ -105,10 +103,6 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@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 +144,7 @@
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^22.17.1",
|
||||
"@types/node": "^18.19.9",
|
||||
"@types/pako": "^1.0.2",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -185,7 +179,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "37.3.1",
|
||||
"electron": "37.2.3",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-store": "^8.2.0",
|
||||
@@ -209,7 +203,6 @@
|
||||
"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",
|
||||
@@ -233,7 +226,6 @@
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
@@ -298,8 +290,7 @@
|
||||
"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"
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -156,9 +156,7 @@ 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',
|
||||
@@ -282,8 +280,5 @@ export enum IpcChannel {
|
||||
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
|
||||
|
||||
// CodeTools
|
||||
CodeTools_Run = 'code-tools:run',
|
||||
|
||||
// OCR
|
||||
OCR_ocr = 'ocr:ocr'
|
||||
CodeTools_Run = 'code-tools:run'
|
||||
}
|
||||
|
||||
@@ -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: isWin ? 'rgba(0,0,0,0.02)' : 'rgba(255,255,255,0)',
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic
|
||||
import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
@@ -72,7 +71,7 @@ const dxtService = new DxtService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
const notificationService = new NotificationService()
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
|
||||
// Initialize Python service with main window
|
||||
pythonService.setMainWindow(mainWindow)
|
||||
@@ -445,7 +444,6 @@ 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) => {
|
||||
@@ -470,7 +468,6 @@ 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))
|
||||
@@ -711,7 +708,4 @@ 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))
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ class CodeToolsService {
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||
|
||||
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||
const updateCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
|
||||
logger.info(`Executing update command: ${updateCommand}`)
|
||||
|
||||
await execAsync(updateCommand, { timeout: 60000 })
|
||||
@@ -307,7 +307,7 @@ class CodeToolsService {
|
||||
}
|
||||
|
||||
// Build command to execute
|
||||
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
|
||||
let baseCommand = isWin ? `${executablePath}` : `${bunPath} ${executablePath}`
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
if (isInstalled) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { app, net, safeStorage } from 'electron'
|
||||
import fs from 'fs'
|
||||
import { net } from 'electron'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
import { getConfigDir } from '../utils/file'
|
||||
|
||||
const logger = loggerService.withContext('CopilotService')
|
||||
|
||||
// 配置常量,集中管理
|
||||
@@ -29,8 +28,7 @@ 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'
|
||||
}
|
||||
}
|
||||
|
||||
// 接口定义移到顶部,便于查阅
|
||||
@@ -69,20 +67,8 @@ class CopilotService {
|
||||
private headers: Record<string, string>
|
||||
|
||||
constructor() {
|
||||
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)
|
||||
this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token')
|
||||
this.headers = { ...CONFIG.DEFAULT_HEADERS }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +93,6 @@ class CopilotService {
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
accept: 'application/json',
|
||||
authorization: `token ${token}`
|
||||
}
|
||||
})
|
||||
@@ -219,13 +204,7 @@ class CopilotService {
|
||||
public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => {
|
||||
try {
|
||||
const encryptedToken = safeStorage.encryptString(token)
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(this.tokenFilePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(this.tokenFilePath, encryptedToken)
|
||||
await fs.writeFile(this.tokenFilePath, encryptedToken)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save token:', error as Error)
|
||||
throw new CopilotServiceError('无法保存访问令牌', error)
|
||||
@@ -242,7 +221,7 @@ class CopilotService {
|
||||
try {
|
||||
this.updateHeaders(headers)
|
||||
|
||||
const encryptedToken = await fs.promises.readFile(this.tokenFilePath)
|
||||
const encryptedToken = await fs.readFile(this.tokenFilePath)
|
||||
const access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
|
||||
|
||||
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
|
||||
@@ -270,8 +249,8 @@ class CopilotService {
|
||||
public logout = async (): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
await fs.promises.access(this.tokenFilePath)
|
||||
await fs.promises.unlink(this.tokenFilePath)
|
||||
await fs.access(this.tokenFilePath)
|
||||
await fs.unlink(this.tokenFilePath)
|
||||
logger.debug('Successfully logged out from Copilot')
|
||||
} catch (error) {
|
||||
// 文件不存在不是错误,只是记录一下
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
|
||||
import { documentExts, imageExts, MB } from '@shared/config/constant'
|
||||
import { FileMetadata } from '@types'
|
||||
import chardet from 'chardet'
|
||||
import * as crypto from 'crypto'
|
||||
import {
|
||||
dialog,
|
||||
@@ -16,7 +15,6 @@ 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'
|
||||
@@ -632,34 +630,6 @@ 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()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
@@ -9,15 +8,4 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
CancelledNotificationSchema,
|
||||
type GetPromptResult,
|
||||
LoggingMessageNotificationSchema,
|
||||
ProgressNotificationSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
@@ -431,6 +432,15 @@ 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)
|
||||
@@ -619,11 +629,6 @@ 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
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Notification as ElectronNotification } from 'electron'
|
||||
import { BrowserWindow, 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({
|
||||
@@ -12,8 +17,8 @@ class NotificationService {
|
||||
})
|
||||
|
||||
electronNotification.on('click', () => {
|
||||
windowService.getMainWindow()?.show()
|
||||
windowService.getMainWindow()?.webContents.send('notification-click', notification)
|
||||
this.window.show()
|
||||
this.window.webContents.send('notification-click', notification)
|
||||
})
|
||||
|
||||
electronNotification.show()
|
||||
|
||||
@@ -11,42 +11,14 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher
|
||||
const logger = loggerService.withContext('ProxyManager')
|
||||
let byPassRules: string[] = []
|
||||
|
||||
const isByPass = (url: string) => {
|
||||
const isByPass = (hostname: string) => {
|
||||
if (byPassRules.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return byPassRules.includes(hostname)
|
||||
}
|
||||
|
||||
class SelectiveDispatcher extends Dispatcher {
|
||||
private proxyDispatcher: Dispatcher
|
||||
private directDispatcher: Dispatcher
|
||||
@@ -59,7 +31,9 @@ class SelectiveDispatcher extends Dispatcher {
|
||||
|
||||
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
|
||||
if (opts.origin) {
|
||||
if (isByPass(opts.origin.toString())) {
|
||||
const url = new URL(opts.origin)
|
||||
// 检查是否为 localhost 或本地地址
|
||||
if (isByPass(url.hostname)) {
|
||||
return this.directDispatcher.dispatch(opts, handler)
|
||||
}
|
||||
}
|
||||
@@ -119,20 +93,15 @@ export class ProxyManager {
|
||||
// Set new interval
|
||||
this.systemProxyInterval = setInterval(async () => {
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (
|
||||
currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules &&
|
||||
currentProxy?.noProxy.join(',').toLowerCase() === this.config?.proxyBypassRules?.toLowerCase()
|
||||
) {
|
||||
if (currentProxy?.proxyUrl.toLowerCase() === this.config?.proxyRules) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}, this.config.proxyBypassRules: ${this.config.proxyBypassRules}`
|
||||
)
|
||||
logger.info(`system proxy changed: ${currentProxy?.proxyUrl}, this.config.proxyRules: ${this.config.proxyRules}`)
|
||||
await this.configureProxy({
|
||||
mode: 'system',
|
||||
proxyRules: currentProxy?.proxyUrl.toLowerCase(),
|
||||
proxyBypassRules: currentProxy?.noProxy.join(',')
|
||||
proxyBypassRules: undefined
|
||||
})
|
||||
}, 1000 * 60)
|
||||
}
|
||||
@@ -182,7 +151,6 @@ 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
|
||||
@@ -194,7 +162,6 @@ 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
|
||||
@@ -262,7 +229,8 @@ export class ProxyManager {
|
||||
|
||||
// filter localhost
|
||||
if (url) {
|
||||
if (isByPass(url.toString())) {
|
||||
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname
|
||||
if (isByPass(hostname)) {
|
||||
return originalMethod(url, options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
//the following ZOOMs will register shortcuts separately, so will return
|
||||
//the following ZOOMs will register shortcuts seperately, so will return
|
||||
case 'zoom_in':
|
||||
globalShortcut.register('CommandOrControl+=', () => handler(window))
|
||||
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
|
||||
|
||||
@@ -555,9 +555,9 @@ export class WindowService {
|
||||
|
||||
// [Windows] hacky fix
|
||||
// the window is minimized only when in Windows platform
|
||||
// because it's a workaround for Windows, see `hideMiniWindow()`
|
||||
// because it's a workround for Windows, see `hideMiniWindow()`
|
||||
if (this.miniWindow?.isMinimized()) {
|
||||
// don't let the window being seen before we finish adjusting the position across screens
|
||||
// don't let the window being seen before we finish adusting 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
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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))
|
||||
@@ -1,82 +0,0 @@
|
||||
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()
|
||||
@@ -168,7 +168,6 @@ 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'
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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,12 +17,9 @@ import {
|
||||
MemoryConfig,
|
||||
MemoryListOptions,
|
||||
MemorySearchOptions,
|
||||
OcrProvider,
|
||||
OcrResult,
|
||||
Provider,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
SupportedOcrFile,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
@@ -136,15 +133,14 @@ const api = {
|
||||
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions): Promise<FileMetadata[] | null> =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
select: (options?: OpenDialogOptions) => 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): Promise<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
/**
|
||||
* 创建一个空的临时文件
|
||||
* @param fileName 文件名
|
||||
@@ -174,12 +170,10 @@ 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),
|
||||
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath)
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
|
||||
readText: (pathOrUrl: string): Promise<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
||||
@@ -411,10 +405,6 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -24,20 +23,18 @@ const Router: FC = () => {
|
||||
|
||||
const routes = useMemo(() => {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
getOpenAIWebSearchParams,
|
||||
getThinkModelType,
|
||||
isClaudeReasoningModel,
|
||||
isDeepSeekHybridInferenceModel,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isGeminiReasoningModel,
|
||||
isGPT5SeriesModel,
|
||||
isGrokReasoningModel,
|
||||
isNotSupportSystemMessageModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isOpenAIReasoningModel,
|
||||
isQwenAlwaysThinkModel,
|
||||
isQwenMTModel,
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
Assistant,
|
||||
EFFORT_RATIO,
|
||||
FileTypes,
|
||||
isSystemProvider,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
@@ -115,7 +112,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
*/
|
||||
// Method for reasoning effort, moved from OpenAIProvider
|
||||
override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams {
|
||||
if (this.provider.id === SystemProviderIds.groq) {
|
||||
if (this.provider.id === 'groq') {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -124,6 +121,22 @@ 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' } }
|
||||
@@ -132,14 +145,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if (!reasoningEffort) {
|
||||
// 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) {
|
||||
if (model.provider === 'openrouter') {
|
||||
// Don't disable reasoning for Gemini models that support thinking tokens
|
||||
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
return {}
|
||||
@@ -151,22 +157,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
}
|
||||
|
||||
// providers that use enable_thinking
|
||||
if (
|
||||
isSupportEnableThinkingProvider(this.provider) &&
|
||||
(isSupportedThinkingTokenQwenModel(model) ||
|
||||
isSupportedThinkingTokenHunyuanModel(model) ||
|
||||
(this.provider.id === SystemProviderIds.dashscope && isDeepSeekHybridInferenceModel(model)))
|
||||
(isSupportedThinkingTokenQwenModel(model) || isSupportedThinkingTokenHunyuanModel(model))
|
||||
) {
|
||||
return { enable_thinking: false }
|
||||
}
|
||||
|
||||
// claude
|
||||
if (isSupportedThinkingTokenClaudeModel(model)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// gemini
|
||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
return {
|
||||
@@ -195,48 +196,8 @@ 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 === SystemProviderIds.openrouter) {
|
||||
if (model.provider === 'openrouter') {
|
||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||
return {
|
||||
reasoning: {
|
||||
@@ -246,18 +207,6 @@ 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 = {
|
||||
@@ -265,7 +214,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
isQwenAlwaysThinkModel(model) || !isSupportEnableThinkingProvider(this.provider) ? undefined : true,
|
||||
thinking_budget: budgetTokens
|
||||
}
|
||||
if (this.provider.id === SystemProviderIds.dashscope) {
|
||||
if (this.provider.id === 'dashscope') {
|
||||
return {
|
||||
...thinkConfig,
|
||||
incremental_output: true
|
||||
@@ -582,12 +531,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// 1. 处理系统消息
|
||||
const systemMessage = { role: 'system', content: assistant.prompt || '' }
|
||||
|
||||
if (
|
||||
isSupportedReasoningEffortOpenAIModel(model) &&
|
||||
isSupportDeveloperRoleProvider(this.provider) &&
|
||||
!isOpenAIOpenWeightModel(model)
|
||||
) {
|
||||
systemMessage.role = 'developer'
|
||||
if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
if (isSupportDeveloperRoleProvider(this.provider)) {
|
||||
systemMessage.role = 'developer'
|
||||
} else {
|
||||
systemMessage.role = 'system'
|
||||
}
|
||||
}
|
||||
|
||||
if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) {
|
||||
@@ -611,9 +560,6 @@ 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)
|
||||
@@ -621,10 +567,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
|
||||
if (lastUserMsg) {
|
||||
if (isSupportedThinkingTokenQwenModel(model) && !isSupportEnableThinkingProvider(this.provider)) {
|
||||
const postsuffix = '/no_think'
|
||||
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
|
||||
const currentContent = lastUserMsg.content
|
||||
|
||||
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled)
|
||||
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, postsuffix, qwenThinkModeEnabled) as any
|
||||
}
|
||||
if (this.provider.id === SystemProviderIds.poe) {
|
||||
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
|
||||
@@ -640,17 +587,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// 4. 最终请求消息
|
||||
let reqMessages: OpenAISdkMessageParam[]
|
||||
if (!systemMessage.content) {
|
||||
if (!systemMessage.content || isNotSupportSystemMessageModel(model)) {
|
||||
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[]
|
||||
}
|
||||
@@ -985,19 +923,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
if ('index' in toolCall) {
|
||||
const { id, index, function: fun } = toolCall
|
||||
if (fun?.name) {
|
||||
const toolCallObject = {
|
||||
toolCalls[index] = {
|
||||
id: id || '',
|
||||
function: {
|
||||
name: fun.name,
|
||||
arguments: fun.arguments || ''
|
||||
},
|
||||
type: 'function' as const
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
toolCalls.push(toolCallObject)
|
||||
} else {
|
||||
toolCalls[index] = toolCallObject
|
||||
type: 'function'
|
||||
}
|
||||
} else if (fun?.arguments) {
|
||||
if (toolCalls[index] && toolCalls[index].type === 'function' && 'function' in toolCalls[index]) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
isGPT5SeriesModel,
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAILLMModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
isSupportVerbosityModel,
|
||||
isVisionModel
|
||||
@@ -375,12 +374,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
text: assistant.prompt || '',
|
||||
type: 'input_text'
|
||||
}
|
||||
if (
|
||||
isSupportedReasoningEffortOpenAIModel(model) &&
|
||||
isSupportDeveloperRoleProvider(this.provider) &&
|
||||
isOpenAIOpenWeightModel(model)
|
||||
) {
|
||||
systemMessage.role = 'developer'
|
||||
if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
if (isSupportDeveloperRoleProvider(this.provider)) {
|
||||
systemMessage.role = 'developer'
|
||||
} else {
|
||||
systemMessage.role = 'system'
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 设置工具
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient'
|
||||
import { isAnthropicModel } from '@renderer/config/models'
|
||||
import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk'
|
||||
|
||||
import { AnthropicStreamListener } from '../../clients/types'
|
||||
@@ -15,8 +16,9 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 在这里可以监听到从SDK返回的最原始流
|
||||
if (result.rawOutput) {
|
||||
const model = params.assistant.model
|
||||
// TODO: 后面下放到AnthropicAPIClient
|
||||
if (ctx.apiClientInstance instanceof AnthropicAPIClient) {
|
||||
if (isAnthropicModel(model)) {
|
||||
const anthropicListener: AnthropicStreamListener<AnthropicSdkRawChunk> = {
|
||||
onMessage: (message) => {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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'
|
||||
@@ -23,16 +22,13 @@ 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: '<seed:think>', closingTag: '</seed:think>', separator: '\n' }
|
||||
{ openingTag: '<thinking>', closingTag: '</thinking>', separator: '\n' }
|
||||
]
|
||||
|
||||
const getAppropriateTag = (model?: Model): TagConfig => {
|
||||
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]
|
||||
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]
|
||||
// 可以在这里添加更多模型特定的标签配置
|
||||
return reasoningTags[0] // 默认使用 <think> 标签
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
1
src/renderer/src/assets/images/providers/poe.svg
Normal file
1
src/renderer/src/assets/images/providers/poe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,26 +1,5 @@
|
||||
@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;
|
||||
}
|
||||
@@ -87,7 +66,6 @@
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
/* 普通 drawer header 不应该可拖拽,除非被 minapp-drawer 覆盖 */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@@ -98,7 +76,7 @@
|
||||
}
|
||||
|
||||
.ant-dropdown-menu .ant-dropdown-menu-sub {
|
||||
max-height: 80vh;
|
||||
max-height: 50vh;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -110,7 +88,7 @@
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
user-select: none;
|
||||
.ant-dropdown-menu {
|
||||
max-height: 80vh;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
@@ -170,7 +148,6 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ant-modal-body {
|
||||
/* 保持 body 在视口内,使用标准的最大高度 */
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 0 16px;
|
||||
|
||||
@@ -6,8 +6,10 @@ 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
|
||||
@@ -52,6 +54,8 @@ 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;
|
||||
}
|
||||
@@ -68,5 +72,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -157,7 +157,6 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: ${(props) =>
|
||||
@@ -178,16 +177,13 @@ const TitleSection = styled.div`
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Title = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
const Title = styled.h3`
|
||||
margin: 0 !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
font-family: 'Ubuntu';
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const TypeBadge = styled.div`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal, Splitter, Tooltip, Typography } from 'antd'
|
||||
import { Button, Modal, Splitter, Tooltip } from 'antd'
|
||||
import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -43,7 +43,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
const renderHeader = () => (
|
||||
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText ellipsis={{ tooltip: true }}>{title}</TitleText>
|
||||
<TitleText>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderCenter>
|
||||
@@ -266,13 +266,13 @@ const HeaderRight = styled.div<{ $isFullscreen?: boolean }>`
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
|
||||
`
|
||||
|
||||
const TitleText = styled(Typography.Text)`
|
||||
const TitleText = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 50%;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const ViewControls = styled.div`
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
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 = ({
|
||||
onSearch,
|
||||
placeholder = i18n.t('common.search'),
|
||||
tooltip = i18n.t('common.search'),
|
||||
icon = <Search size={14} color="var(--color-icon)" />,
|
||||
maxWidth = '100%',
|
||||
style
|
||||
}: CollapsibleSearchBarProps) => {
|
||||
const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, icon, maxWidth }) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
@@ -55,16 +46,16 @@ const CollapsibleSearchBar = ({
|
||||
initial="collapsed"
|
||||
animate={searchVisible ? 'expanded' : 'collapsed'}
|
||||
variants={{
|
||||
expanded: { maxWidth: maxWidth, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
expanded: { maxWidth: maxWidth || '100%', 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={placeholder}
|
||||
placeholder={t('models.search')}
|
||||
size="small"
|
||||
suffix={icon}
|
||||
suffix={icon || <Search size={14} color="var(--color-icon)" />}
|
||||
value={searchText}
|
||||
autoFocus
|
||||
allowClear
|
||||
@@ -80,7 +71,7 @@ const CollapsibleSearchBar = ({
|
||||
if (!searchText) setSearchVisible(false)
|
||||
}}
|
||||
onClear={handleClear}
|
||||
style={{ width: '100%', ...style }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -92,8 +83,8 @@ const CollapsibleSearchBar = ({
|
||||
}}
|
||||
style={{ cursor: 'pointer', display: 'flex' }}
|
||||
onClick={() => setSearchVisible(true)}>
|
||||
<Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
{icon}
|
||||
<Tooltip title={t('models.search')} mouseLeaveDelay={0}>
|
||||
{icon || <Search size={14} color="var(--color-icon)" />}
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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 }
|
||||
@@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -193,7 +193,7 @@ export function ExaLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -211,75 +211,30 @@ 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
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="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"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="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
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="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
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="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} style={{ display: 'none' }}>
|
||||
<div ref={hostRef}>
|
||||
{createPortal(
|
||||
<StyleSheetManager target={shadowRoot}>
|
||||
<StyleProvider container={shadowRoot} layer>
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'
|
||||
import { getFancyProviderName } from '@renderer/utils'
|
||||
import { ConflictInfo, ConflictResolution } from '@renderer/utils/provider'
|
||||
import { Button, Card, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
interface Props {
|
||||
conflicts: ConflictInfo[]
|
||||
onResolve: (resolutions: ConflictResolution[]) => void
|
||||
onCancel: () => void
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const ConflictResolutionPopup: FC<Props> = ({ conflicts, onResolve, onCancel, visible }) => {
|
||||
const { t } = useTranslation()
|
||||
const [resolutions, setResolutions] = useState<Record<string, string>>({})
|
||||
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({})
|
||||
|
||||
const handleProviderSelect = (conflictId: string, providerId: string) => {
|
||||
setResolutions((prev) => ({
|
||||
...prev,
|
||||
[conflictId]: providerId
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleApiKeyVisibility = (providerKey: string) => {
|
||||
setShowApiKeys((prev) => ({
|
||||
...prev,
|
||||
[providerKey]: !prev[providerKey]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleResolve = () => {
|
||||
const conflictResolutions: ConflictResolution[] = Object.entries(resolutions).map(
|
||||
([conflictId, selectedProviderId]) => ({
|
||||
conflictId,
|
||||
selectedProviderId
|
||||
})
|
||||
)
|
||||
onResolve(conflictResolutions)
|
||||
}
|
||||
|
||||
const isAllResolved = conflicts.every((conflict) => resolutions[conflict.id])
|
||||
|
||||
const renderProviderCard = (provider: ConflictInfo['providers'][0], conflictId: string, isSelected: boolean) => {
|
||||
const providerName = getFancyProviderName(provider)
|
||||
const providerKey = `${conflictId}-${provider._tempIndex}`
|
||||
const isApiKeyVisible = showApiKeys[providerKey]
|
||||
|
||||
const renderApiKeyValue = () => {
|
||||
if (!provider.apiKey) {
|
||||
return <DetailValue>未设置</DetailValue>
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiKeyContainer>
|
||||
<DetailValue>{isApiKeyVisible ? provider.apiKey : '●●●●●●●●'}</DetailValue>
|
||||
<ApiKeyToggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation() // 防止触发卡片选择
|
||||
toggleApiKeyVisibility(providerKey)
|
||||
}}>
|
||||
{isApiKeyVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
</ApiKeyToggle>
|
||||
</ApiKeyContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
key={provider._tempIndex}
|
||||
size="small"
|
||||
$selected={isSelected}
|
||||
onClick={() => handleProviderSelect(conflictId, provider._tempIndex!.toString())}>
|
||||
<ProviderHeader>
|
||||
<Radio checked={isSelected} />
|
||||
<ProviderName>{providerName}</ProviderName>
|
||||
{provider.enabled && <Tag color="green">ON</Tag>}
|
||||
</ProviderHeader>
|
||||
<ProviderDetails>
|
||||
<DetailRow>
|
||||
<DetailLabel>API Key:</DetailLabel>
|
||||
{renderApiKeyValue()}
|
||||
</DetailRow>
|
||||
<DetailRow>
|
||||
<DetailLabel>API Host:</DetailLabel>
|
||||
<DetailValue>{provider.apiHost || '默认'}</DetailValue>
|
||||
</DetailRow>
|
||||
</ProviderDetails>
|
||||
</ProviderCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.provider.cleanup.conflict.resolution_title')}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" onClick={handleResolve} disabled={!isAllResolved}>
|
||||
{t('settings.provider.cleanup.conflict.apply_resolution')}
|
||||
</Button>
|
||||
</Space>
|
||||
}>
|
||||
<ConflictContainer>
|
||||
<Text type="secondary">{t('settings.provider.cleanup.conflict.resolution_desc')}</Text>
|
||||
|
||||
{conflicts.map((conflict, index) => (
|
||||
<ConflictSection key={conflict.id}>
|
||||
<Title level={5}>
|
||||
{t('settings.provider.cleanup.conflict.provider_conflict', {
|
||||
provider: getFancyProviderName({ name: conflict.id, id: conflict.id } as any)
|
||||
})}
|
||||
</Title>
|
||||
|
||||
<ProvidersGrid>
|
||||
{conflict.providers.map((provider) =>
|
||||
renderProviderCard(provider, conflict.id, resolutions[conflict.id] === provider._tempIndex!.toString())
|
||||
)}
|
||||
</ProvidersGrid>
|
||||
|
||||
{index < conflicts.length - 1 && <ConflictDivider />}
|
||||
</ConflictSection>
|
||||
))}
|
||||
</ConflictContainer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const ConflictContainer = styled.div`
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const ConflictSection = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`
|
||||
|
||||
const ProvidersGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const ProviderCard = styled(Card)<{ $selected: boolean }>`
|
||||
cursor: pointer;
|
||||
border: 2px solid ${(props) => (props.$selected ? 'var(--color-primary)' : 'var(--color-border)')};
|
||||
background: ${(props) => (props.$selected ? 'var(--color-primary-bg)' : 'var(--color-background)')};
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ProviderDetails = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const DetailRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const DetailLabel = styled(Text)`
|
||||
min-width: 80px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const DetailValue = styled(Text)`
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
`
|
||||
|
||||
const ApiKeyContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ApiKeyToggle = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-3);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-fill-tertiary);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`
|
||||
|
||||
const ConflictDivider = styled.div`
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 24px 0;
|
||||
`
|
||||
|
||||
export default ConflictResolutionPopup
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Input, Modal } from 'antd'
|
||||
import { TextAreaProps } from 'antd/es/input'
|
||||
import { ReactNode, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { Box } from '../Layout'
|
||||
import { TopView } from '../TopView'
|
||||
@@ -11,7 +11,6 @@ interface PromptPopupShowParams {
|
||||
defaultValue?: string
|
||||
inputPlaceholder?: string
|
||||
inputProps?: TextAreaProps
|
||||
extraNode?: ReactNode
|
||||
}
|
||||
|
||||
interface Props extends PromptPopupShowParams {
|
||||
@@ -24,7 +23,6 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
defaultValue = '',
|
||||
inputPlaceholder = '',
|
||||
inputProps = {},
|
||||
extraNode = null,
|
||||
resolve
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue)
|
||||
@@ -90,7 +88,6 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
rows={1}
|
||||
{...inputProps}
|
||||
/>
|
||||
{extraNode}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ const SelectModelSearchBar: React.FC<SelectModelSearchBarProps> = ({ onSearch })
|
||||
</SearchIcon>
|
||||
}
|
||||
ref={inputRef}
|
||||
placeholder={t('models.search.placeholder')}
|
||||
placeholder={t('models.search')}
|
||||
value={searchText}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onClear={handleClear}
|
||||
|
||||
@@ -17,7 +17,8 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
ADD_TAGS: ['foreignObject']
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['style', 'defs', 'foreignObject']
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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,12 +54,6 @@ export type QuickPanelListItem = {
|
||||
isSelected?: boolean
|
||||
isMenu?: boolean
|
||||
disabled?: boolean
|
||||
/**
|
||||
* 固定显示项:不参与过滤,始终出现在列表顶部。
|
||||
* 例如“清除”按钮可设置为 alwaysVisible,从而在有匹配项时始终可见;
|
||||
* 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。
|
||||
*/
|
||||
alwaysVisible?: boolean
|
||||
action?: (options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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'
|
||||
@@ -64,32 +62,20 @@ 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 { setTimeoutTimer } = useTimer()
|
||||
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
|
||||
|
||||
// 无匹配项自动关闭的定时器
|
||||
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const clearSearchTimerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
const focusTimerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
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) => {
|
||||
const newList = ctx.list?.filter((item) => {
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
if (!_searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
@@ -101,24 +87,29 @@ 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 {
|
||||
let pinyinText = pinyinCacheRef.current.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCacheRef.current.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(filterText.toLowerCase())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,9 +122,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
const combinedLength = pinnedItems.length + filteredNormalItems.length
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
if (prevIndex >= newList.length) {
|
||||
return newList.length > 0 ? newList.length - 1 : -1
|
||||
}
|
||||
return prevIndex
|
||||
})
|
||||
@@ -142,52 +132,81 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
// 固定项置顶 + 过滤后的普通项
|
||||
return [...pinnedItems, ...filteredNormalItems]
|
||||
return newList
|
||||
}, [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
|
||||
clearTimeout(clearSearchTimerRef.current)
|
||||
clearTimeout(focusTimerRef.current)
|
||||
}
|
||||
|
||||
// 面板不可见时不设置新定时器
|
||||
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
|
||||
}
|
||||
clearTimeout(clearSearchTimerRef.current)
|
||||
clearTimeout(focusTimerRef.current)
|
||||
}
|
||||
// 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 textBeforeCursor = textArea.value.slice(0, cursorPosition)
|
||||
const prevChar = textArea.value[cursorPosition - 1]
|
||||
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
|
||||
searchTextRef.current = prevChar
|
||||
}
|
||||
|
||||
// 查找最后一个 @ 或 / 符号的位置
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||
const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex)
|
||||
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
|
||||
if (!_searchText) return
|
||||
|
||||
if (lastSymbolIndex === -1) return
|
||||
const inputText = textArea.value
|
||||
let newText = inputText
|
||||
const searchPattern = new RegExp(`${_searchText}$`)
|
||||
|
||||
// 根据 includeSymbol 决定是否删除符号
|
||||
const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1
|
||||
const deleteEnd = cursorPosition
|
||||
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)
|
||||
|
||||
if (deleteStart >= deleteEnd) return
|
||||
|
||||
// 删除文本
|
||||
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
|
||||
setInputText(newText)
|
||||
|
||||
// 设置光标位置
|
||||
setTimeoutTimer(
|
||||
'quickpanel_focus',
|
||||
() => {
|
||||
clearTimeout(focusTimerRef.current)
|
||||
focusTimerRef.current = setTimeout(() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(deleteStart, deleteStart)
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
textArea.setSelectionRange(start, start)
|
||||
}, 0)
|
||||
}
|
||||
setSearchText('')
|
||||
},
|
||||
[setInputText, setTimeoutTimer]
|
||||
[setInputText]
|
||||
)
|
||||
|
||||
const handleClose = useCallback(
|
||||
@@ -298,10 +317,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
if (lastSymbolIndex !== -1) {
|
||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||
setSearchTextDebounced(newSearchText)
|
||||
setSearchText(newSearchText)
|
||||
} else {
|
||||
// 使用本地 handleClose,确保在删除触发符时同步受控输入值
|
||||
handleClose('delete-symbol')
|
||||
ctx.close('delete-symbol')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,14 +340,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
textArea.removeEventListener('input', handleInput)
|
||||
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
||||
setSearchTextDebounced.cancel()
|
||||
setTimeoutTimer(
|
||||
'quickpanel_clear_search',
|
||||
() => {
|
||||
setSearchText('')
|
||||
},
|
||||
200
|
||||
) // 等待面板关闭动画结束后,再清空搜索词
|
||||
clearTimeout(clearSearchTimerRef.current)
|
||||
clearSearchTimerRef.current = setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible])
|
||||
@@ -343,11 +357,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
scrollTriggerRef.current = 'none'
|
||||
}, [index])
|
||||
|
||||
// 处理键盘事件(折叠时不拦截全局键盘)
|
||||
// 处理键盘事件
|
||||
useEffect(() => {
|
||||
const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0
|
||||
const isCollapsed = hasSearchTextFlag && list.length === 0
|
||||
if (!ctx.isVisible || isCollapsed) return
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isMac ? e.metaKey : e.ctrlKey) {
|
||||
@@ -483,17 +495,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
window.removeEventListener('keyup', handleKeyUp, true)
|
||||
window.removeEventListener('click', handleClickOutside, true)
|
||||
}
|
||||
}, [
|
||||
index,
|
||||
isAssistiveKeyPressed,
|
||||
historyPanel,
|
||||
ctx,
|
||||
list,
|
||||
handleItemAction,
|
||||
handleClose,
|
||||
clearSearchText,
|
||||
searchText
|
||||
])
|
||||
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
|
||||
|
||||
const [footerWidth, setFooterWidth] = useState(0)
|
||||
|
||||
@@ -513,10 +515,6 @@ 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, [])
|
||||
|
||||
@@ -564,7 +562,6 @@ 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
|
||||
@@ -575,19 +572,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
{!collapsed && (
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
size={listHeight}
|
||||
estimateSize={estimateSize}
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
pointerEvents: isMouseOver ? 'auto' : 'none'
|
||||
}}>
|
||||
{rowRenderer}
|
||||
</DynamicVirtualList>
|
||||
)}
|
||||
<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}>
|
||||
@@ -631,7 +626,6 @@ 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};
|
||||
@@ -650,8 +644,8 @@ const QuickPanelContainer = styled.div<{
|
||||
pointer-events: none;
|
||||
|
||||
&.visible {
|
||||
pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')};
|
||||
max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px;
|
||||
pointer-events: auto;
|
||||
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px;
|
||||
}
|
||||
body[theme-mode='dark'] & {
|
||||
--focused-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -206,16 +206,8 @@ 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')};
|
||||
height: var(--navbar-height);
|
||||
position: relative;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
/* 确保交互元素在拖拽区域之上 */
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
height: var(--navbar-height);
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
@@ -228,6 +220,7 @@ 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;
|
||||
@@ -280,6 +273,7 @@ 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);
|
||||
@@ -304,6 +298,7 @@ const ThemeButton = styled.div`
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-list-item);
|
||||
@@ -319,6 +314,7 @@ 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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import { loggerService } from '@logger'
|
||||
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
|
||||
import { useAppInit } from '@renderer/hooks/useAppInit'
|
||||
import { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
@@ -26,7 +26,7 @@ type ElementItem = {
|
||||
element: React.FC | React.ReactNode
|
||||
}
|
||||
|
||||
// const logger = loggerService.withContext('TopView')
|
||||
const logger = loggerService.withContext('TopView')
|
||||
|
||||
const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
const [elements, setElements] = useState<ElementItem[]>([])
|
||||
@@ -80,7 +80,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// logger.debug('keydown', e)
|
||||
logger.debug('keydown', e)
|
||||
if (!enableQuitFullScreen) return
|
||||
|
||||
if (e.key === 'Escape' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -1,192 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as Sortable } from './Sortable'
|
||||
export * from './useDndReorder'
|
||||
export * from './useDndState'
|
||||
@@ -1,74 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { defaultDropAnimationSideEffects, type DropAnimation, PointerSensor } from '@dnd-kit/core'
|
||||
|
||||
export const PORTAL_NO_DND_SELECTORS = [
|
||||
'.ant-dropdown',
|
||||
'.ant-select-dropdown',
|
||||
'.ant-popover',
|
||||
'.ant-tooltip',
|
||||
'.ant-modal'
|
||||
].join(',')
|
||||
|
||||
/**
|
||||
* Default drop animation config.
|
||||
* The opacity is set so to match the drag overlay case.
|
||||
*/
|
||||
export const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: '0.25'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent drag on elements with specific classes or data-no-dnd attribute
|
||||
*/
|
||||
export class PortalSafePointerSensor extends PointerSensor {
|
||||
static activators = [
|
||||
{
|
||||
eventName: 'onPointerDown',
|
||||
handler: ({ nativeEvent: event }) => {
|
||||
let target = event.target as HTMLElement
|
||||
|
||||
while (target) {
|
||||
if (target.closest(PORTAL_NO_DND_SELECTORS) || target.dataset?.noDnd) {
|
||||
return false
|
||||
}
|
||||
target = target.parentElement as HTMLElement
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
] as (typeof PointerSensor)['activators']
|
||||
}
|
||||
@@ -150,7 +150,6 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
|
||||
import NomicLogo from '@renderer/assets/images/providers/nomic.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import {
|
||||
isSystemProviderId,
|
||||
Model,
|
||||
ReasoningEffortConfig,
|
||||
SystemProviderId,
|
||||
@@ -291,7 +290,6 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||
)
|
||||
|
||||
// 模型类型到支持的reasoning_effort的映射表
|
||||
// TODO: refactor this. too many identical options
|
||||
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
default: ['low', 'medium', 'high'] as const,
|
||||
o: ['low', 'medium', 'high'] as const,
|
||||
@@ -305,8 +303,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
doubao_no_auto: ['high'] as const,
|
||||
hunyuan: ['auto'] as const,
|
||||
zhipu: ['auto'] as const,
|
||||
perplexity: ['low', 'medium', 'high'] as const,
|
||||
deepseek_hybrid: ['auto'] as const
|
||||
perplexity: ['low', 'medium', 'high'] as const
|
||||
} as const
|
||||
|
||||
// 模型类型到支持选项的映射表
|
||||
@@ -323,8 +320,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
|
||||
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
||||
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
|
||||
deepseek_hybrid: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
|
||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity
|
||||
} as const
|
||||
|
||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
@@ -354,7 +350,6 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
} else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
|
||||
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
|
||||
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
|
||||
else if (isDeepSeekHybridInferenceModel(model)) thinkingModelType = 'deepseek_hybrid'
|
||||
return thinkingModelType
|
||||
}
|
||||
|
||||
@@ -377,21 +372,11 @@ export function isFunctionCallingModel(model?: Model): boolean {
|
||||
return FUNCTION_CALLING_REGEX.test(modelId) || FUNCTION_CALLING_REGEX.test(model.name)
|
||||
}
|
||||
|
||||
if (['deepseek', 'anthropic', 'kimi', 'moonshot'].includes(model.provider)) {
|
||||
if (['deepseek', 'anthropic'].includes(model.provider)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2025/08/26 百炼与火山引擎均不支持 v3.1 函数调用
|
||||
// 先默认支持
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
if (isSystemProviderId(model.provider)) {
|
||||
switch (model.provider) {
|
||||
case 'dashscope':
|
||||
case 'doubao':
|
||||
// case 'nvidia': // nvidia api 太烂了 测不了能不能用 先假设能用
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (['kimi', 'moonshot'].includes(model.provider)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1416,7 +1401,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
dashscope: [
|
||||
{ id: 'qwen-vl-plus', name: 'qwen-vl-plus', provider: 'dashscope', group: 'qwen-vl', owned_by: 'system' },
|
||||
{ id: 'qwen-coder-plus', name: 'qwen-coder-plus', provider: 'dashscope', group: 'qwen-coder', owned_by: 'system' },
|
||||
{ id: 'qwen-flash', name: 'qwen-flash', provider: 'dashscope', group: 'qwen-flash', owned_by: 'system' },
|
||||
{ id: 'qwen-turbo', name: 'qwen-turbo', provider: 'dashscope', group: 'qwen-turbo', owned_by: 'system' },
|
||||
{ id: 'qwen-plus', name: 'qwen-plus', provider: 'dashscope', group: 'qwen-plus', owned_by: 'system' },
|
||||
{ id: 'qwen-max', name: 'qwen-max', provider: 'dashscope', group: 'qwen-max', owned_by: 'system' }
|
||||
],
|
||||
@@ -2642,13 +2627,6 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Specifically for DeepSeek V3.1. White list for now
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
return (['openrouter', 'dashscope', 'doubao', 'silicon', 'nvidia'] satisfies SystemProviderId[]).some(
|
||||
(id) => id === model.provider
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
isSupportedThinkingTokenGeminiModel(model) ||
|
||||
isSupportedThinkingTokenQwenModel(model) ||
|
||||
@@ -2786,9 +2764,7 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
|
||||
'qwen-turbo-0428',
|
||||
'qwen-turbo-2025-04-28',
|
||||
'qwen-turbo-0715',
|
||||
'qwen-turbo-2025-07-15',
|
||||
'qwen-flash',
|
||||
'qwen-flash-2025-07-28'
|
||||
'qwen-turbo-2025-07-15'
|
||||
].includes(modelId)
|
||||
}
|
||||
|
||||
@@ -2862,15 +2838,6 @@ export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
|
||||
return modelId.includes('glm-4.5')
|
||||
}
|
||||
|
||||
export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||
return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId === 'deepseek-chat-v3.1'
|
||||
}
|
||||
|
||||
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||
|
||||
export const isZhipuReasoningModel = (model?: Model): boolean => {
|
||||
if (!model) {
|
||||
return false
|
||||
@@ -2903,8 +2870,6 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
REASONING_REGEX.test(modelId) ||
|
||||
REASONING_REGEX.test(model.name) ||
|
||||
isSupportedThinkingTokenDoubaoModel(model) ||
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
isDeepSeekHybridInferenceModel({ ...model, id: model.name }) ||
|
||||
false
|
||||
)
|
||||
}
|
||||
@@ -2919,7 +2884,6 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
isPerplexityReasoningModel(model) ||
|
||||
isZhipuReasoningModel(model) ||
|
||||
isStepReasoningModel(model) ||
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
modelId.includes('magistral') ||
|
||||
modelId.includes('minimax-m1') ||
|
||||
modelId.includes('pangu-pro-moe')
|
||||
@@ -2945,11 +2909,7 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
(isOpenAIReasoningModel(model) && !isOpenAIOpenWeightModel(model)) ||
|
||||
isOpenAIChatCompletionOnlyModel(model) ||
|
||||
isQwenMTModel(model)
|
||||
) {
|
||||
if (isOpenAIReasoningModel(model) || isOpenAIChatCompletionOnlyModel(model) || isQwenMTModel(model)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3028,7 +2988,7 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
if (provider.id === 'dashscope') {
|
||||
const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq', 'qwen-flash']
|
||||
const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq']
|
||||
// matches id like qwen-max-0919, qwen-max-latest
|
||||
return models.some((i) => modelId.startsWith(i))
|
||||
}
|
||||
@@ -3044,26 +3004,6 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isMandatoryWebSearchModel(model: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
const provider = getProviderByModel(model)
|
||||
|
||||
if (!provider) {
|
||||
return false
|
||||
}
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
|
||||
if (provider.id === 'perplexity' || provider.id === 'openrouter') {
|
||||
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
@@ -3232,7 +3172,6 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
'qwen3-0\\.6b$': { min: 0, max: 30_720 },
|
||||
'qwen-plus.*$': { min: 0, max: 38_912 },
|
||||
'qwen-turbo.*$': { min: 0, max: 38_912 },
|
||||
'qwen-flash.*$': { min: 0, max: 81_920 },
|
||||
'qwen3-.*$': { min: 1024, max: 38_912 },
|
||||
|
||||
// Claude models
|
||||
@@ -3300,10 +3239,5 @@ export const isGPT5SeriesModel = (model: Model) => {
|
||||
return modelId.includes('gpt-5')
|
||||
}
|
||||
|
||||
export const isOpenAIOpenWeightModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gpt-oss')
|
||||
}
|
||||
|
||||
// zhipu 视觉推理模型用这组 special token 标记推理结果
|
||||
export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import {
|
||||
BuiltinOcrProvider,
|
||||
BuiltinOcrProviderId,
|
||||
ImageOcrProvider,
|
||||
OcrProviderCapability,
|
||||
OcrTesseractProvider
|
||||
} from '@renderer/types'
|
||||
|
||||
const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider = {
|
||||
id: 'tesseract',
|
||||
name: 'Tesseract',
|
||||
capabilities: {
|
||||
image: true
|
||||
},
|
||||
config: {
|
||||
langs: {
|
||||
chi_sim: true,
|
||||
chi_tra: true,
|
||||
eng: true
|
||||
}
|
||||
}
|
||||
} as const satisfies OcrTesseractProvider
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS_MAP = {
|
||||
tesseract
|
||||
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
|
||||
|
||||
export const DEFAULT_OCR_PROVIDER = {
|
||||
image: tesseract
|
||||
} as const satisfies Record<OcrProviderCapability, BuiltinOcrProvider>
|
||||
12
src/renderer/src/config/ocrProviders.ts
Normal file
12
src/renderer/src/config/ocrProviders.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import MacOSLogo from '@renderer/assets/images/providers/macos.svg'
|
||||
|
||||
export function getOcrProviderLogo(providerId: string) {
|
||||
switch (providerId) {
|
||||
case 'system':
|
||||
return MacOSLogo
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const OCR_PROVIDER_CONFIG = {}
|
||||
@@ -166,7 +166,7 @@ export const SEARCH_SUMMARY_PROMPT = `
|
||||
</knowledge>
|
||||
\`
|
||||
|
||||
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
Rephrased question: \`
|
||||
<websearch>
|
||||
<question>
|
||||
@@ -279,7 +279,7 @@ export const SEARCH_SUMMARY_PROMPT_WEB_ONLY = `
|
||||
</websearch>
|
||||
\`
|
||||
|
||||
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
Rephrased question: \`
|
||||
<websearch>
|
||||
<question>
|
||||
@@ -374,7 +374,7 @@ export const SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY = `
|
||||
</knowledge>
|
||||
\`
|
||||
|
||||
7. Follow up question: Based on knowledge, Formula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
7. Follow up question: Based on knowledge, Fomula of Scaled Dot-Product Attention and Multi-Head Attention?
|
||||
Rephrased question: \`
|
||||
<knowledge>
|
||||
<rewrite>
|
||||
|
||||
@@ -38,6 +38,7 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
|
||||
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
|
||||
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
|
||||
import Ph8ProviderLogo from '@renderer/assets/images/providers/ph8.png'
|
||||
import PoeProviderLogo from '@renderer/assets/images/providers/poe.svg'
|
||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||
@@ -593,7 +594,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
|
||||
export const SYSTEM_PROVIDERS: SystemProvider[] = Object.values(SYSTEM_PROVIDERS_CONFIG)
|
||||
|
||||
export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
ph8: Ph8ProviderLogo,
|
||||
'302ai': Ai302ProviderLogo,
|
||||
openai: OpenAiProviderLogo,
|
||||
@@ -648,7 +649,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
vertexai: VertexAIProviderLogo,
|
||||
'new-api': NewAPIProviderLogo,
|
||||
'aws-bedrock': AwsProviderLogo,
|
||||
poe: 'svg' // use svg icon component
|
||||
poe: PoeProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -1276,11 +1277,7 @@ export const isSupportStreamOptionsProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = [
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
'nvidia'
|
||||
] as const satisfies SystemProviderId[]
|
||||
const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = ['ollama', 'lmstudio'] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
* 判断提供商是否支持使用 enable_thinking 参数来控制 Qwen3 等模型的思考。 Only for OpenAI Chat Completions API.
|
||||
|
||||
@@ -66,7 +66,7 @@ db.version(6).stores({
|
||||
// --- NEW VERSION 7 ---
|
||||
db.version(7)
|
||||
.stores({
|
||||
// Redeclare all tables for the new version
|
||||
// Re-declare all tables for the new version
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id', // Correct index for topics
|
||||
settings: '&id, value',
|
||||
@@ -79,7 +79,7 @@ db.version(7)
|
||||
|
||||
db.version(8)
|
||||
.stores({
|
||||
// Redeclare all tables for the new version
|
||||
// Re-declare all tables for the new version
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id', // Correct index for topics
|
||||
settings: '&id, value',
|
||||
@@ -91,7 +91,7 @@ db.version(8)
|
||||
.upgrade((tx) => upgradeToV8(tx))
|
||||
|
||||
db.version(9).stores({
|
||||
// Redeclare all tables for the new version
|
||||
// Re-declare all tables for the new version
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id', // Correct index for topics
|
||||
settings: '&id, value',
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
getThinkModelType,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
MODEL_SUPPORTED_OPTIONS,
|
||||
MODEL_SUPPORTED_REASONING_EFFORT
|
||||
MODEL_SUPPORTED_OPTIONS
|
||||
} from '@renderer/config/models'
|
||||
import { db } from '@renderer/databases'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
@@ -25,9 +24,9 @@ import {
|
||||
updateTopics
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopicManager } from './useTopic'
|
||||
@@ -85,12 +84,6 @@ export function useAssistant(id: string) {
|
||||
|
||||
const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model])
|
||||
|
||||
const settingsRef = useRef(assistant?.settings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = assistant.settings
|
||||
}, [assistant?.settings])
|
||||
|
||||
const updateAssistantSettings = useCallback(
|
||||
(settings: Partial<AssistantSettings>) => {
|
||||
assistant?.id && dispatch(_updateAssistantSettings({ assistantId: assistant.id, settings }))
|
||||
@@ -100,46 +93,28 @@ export function useAssistant(id: string) {
|
||||
|
||||
// 当model变化时,同步reasoning effort为模型支持的合法值
|
||||
useEffect(() => {
|
||||
const settings = settingsRef.current
|
||||
if (settings) {
|
||||
const currentReasoningEffort = settings.reasoning_effort
|
||||
if (assistant?.settings) {
|
||||
if (isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) {
|
||||
const modelType = getThinkModelType(model)
|
||||
const supportedOptions = MODEL_SUPPORTED_OPTIONS[modelType]
|
||||
if (supportedOptions.every((option) => option !== currentReasoningEffort)) {
|
||||
const cache = settings.reasoning_effort_cache
|
||||
let fallbackOption: ThinkingOption
|
||||
|
||||
// 选项不支持时,首先尝试恢复到上次使用的值
|
||||
if (cache && supportedOptions.includes(cache)) {
|
||||
fallbackOption = cache
|
||||
} else {
|
||||
// 灵活回退到支持的值
|
||||
// 注意:这里假设可用的options不会为空
|
||||
const enableThinking = currentReasoningEffort !== undefined
|
||||
fallbackOption = enableThinking
|
||||
? MODEL_SUPPORTED_REASONING_EFFORT[modelType][0]
|
||||
: MODEL_SUPPORTED_OPTIONS[modelType][0]
|
||||
}
|
||||
const currentReasoningEffort = assistant?.settings?.reasoning_effort
|
||||
const supportedOptions = MODEL_SUPPORTED_OPTIONS[getThinkModelType(model)]
|
||||
if (currentReasoningEffort && !supportedOptions.includes(currentReasoningEffort)) {
|
||||
// 选项不支持时,回退到第一个支持的值
|
||||
// 注意:这里假设可用的options不会为空
|
||||
const fallbackOption = supportedOptions[0]
|
||||
|
||||
updateAssistantSettings({
|
||||
reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption,
|
||||
reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption,
|
||||
qwenThinkMode: fallbackOption === 'off' ? undefined : true
|
||||
qwenThinkMode: fallbackOption === 'off'
|
||||
})
|
||||
} else {
|
||||
// 对于支持的选项, 不再更新 cache.
|
||||
}
|
||||
} else {
|
||||
// 切换到非思考模型时保留cache
|
||||
updateAssistantSettings({
|
||||
reasoning_effort: undefined,
|
||||
reasoning_effort_cache: currentReasoningEffort,
|
||||
qwenThinkMode: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [model, updateAssistantSettings])
|
||||
}, [assistant?.settings, model, updateAssistantSettings])
|
||||
|
||||
return {
|
||||
assistant: assistantWithModel,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
// const logger = loggerService.withContext('useDrag')
|
||||
|
||||
export const useDrag = <T extends HTMLElement>(onDrop?: (e: React.DragEvent<T>) => Promise<void> | void) => {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 确保是离开当前元素,而不是进入子元素
|
||||
// logger.debug('drag leave', { currentTarget: e.currentTarget, relatedTarget: e.relatedTarget })
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
await onDrop?.(e)
|
||||
},
|
||||
[onDrop]
|
||||
)
|
||||
|
||||
return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop }
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { filterSupportedFiles } from '@renderer/utils'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
/** 支持选择的扩展名 */
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
export const useFiles = (props?: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [files, setFiles] = useState<FileMetadata[]>([])
|
||||
const [selecting, setSelecting] = useState<boolean>(false)
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (props?.extensions) {
|
||||
return props.extensions
|
||||
} else {
|
||||
return ['*']
|
||||
}
|
||||
}, [props?.extensions])
|
||||
|
||||
/**
|
||||
* 选择文件的回调函数
|
||||
* @param multipleSelections - 是否允许多选文件,默认为 true
|
||||
* @returns 返回选中的文件元数据数组
|
||||
* @description
|
||||
* 1. 打开系统文件选择对话框
|
||||
* 2. 根据扩展名过滤文件
|
||||
* 3. 更新内部文件状态
|
||||
* 4. 当选择了不支持的文件类型时,会显示提示信息
|
||||
*/
|
||||
const onSelectFile = useCallback(
|
||||
async ({ multipleSelections = true }: { multipleSelections?: boolean }): Promise<FileMetadata[]> => {
|
||||
if (selecting) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selectProps: Electron.OpenDialogOptions['properties'] = multipleSelections
|
||||
? ['openFile', 'multiSelections']
|
||||
: ['openFile']
|
||||
|
||||
// when the number of extensions is greater than 20, use *.* to avoid selecting window lag
|
||||
const useAllFiles = extensions.length > 20
|
||||
|
||||
setSelecting(true)
|
||||
const _files = await window.api.file.select({
|
||||
properties: selectProps,
|
||||
filters: [
|
||||
{
|
||||
name: 'Files',
|
||||
extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', ''))
|
||||
}
|
||||
]
|
||||
})
|
||||
setSelecting(false)
|
||||
|
||||
if (_files) {
|
||||
if (!useAllFiles) {
|
||||
setFiles([...files, ..._files])
|
||||
return _files
|
||||
}
|
||||
const supportedFiles = await filterSupportedFiles(_files, extensions)
|
||||
if (supportedFiles.length > 0) {
|
||||
setFiles([...files, ...supportedFiles])
|
||||
}
|
||||
|
||||
if (supportedFiles.length !== _files.length) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported_count', {
|
||||
count: _files.length - supportedFiles.length
|
||||
})
|
||||
})
|
||||
}
|
||||
return supportedFiles
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
[extensions, files, selecting, t]
|
||||
)
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
files,
|
||||
selecting,
|
||||
setFiles,
|
||||
onSelectFile,
|
||||
clearFiles
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { loggerService } from '@logger'
|
||||
import * as OcrService from '@renderer/services/ocr/OcrService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { ImageFileMetadata, isImageFile, SupportedOcrFile } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('useOcr')
|
||||
|
||||
export const useOcr = () => {
|
||||
const { t } = useTranslation()
|
||||
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
|
||||
|
||||
/**
|
||||
* 对图片文件进行OCR识别
|
||||
* @param image 图片文件元数据
|
||||
* @returns OCR识别结果的Promise
|
||||
* @throws OCR失败时抛出错误
|
||||
*/
|
||||
const ocrImage = async (image: ImageFileMetadata) => {
|
||||
return OcrService.ocr(image, imageProvider)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对支持的文件进行OCR识别.
|
||||
* @param file 支持OCR的文件
|
||||
* @returns OCR识别结果的Promise
|
||||
* @throws 当文件类型不支持或OCR失败时抛出错误
|
||||
*/
|
||||
const ocr = async (file: SupportedOcrFile) => {
|
||||
const key = uuid()
|
||||
window.message.loading({ content: t('ocr.processing'), key, duration: 0 })
|
||||
// await to keep show loading message
|
||||
try {
|
||||
if (isImageFile(file)) {
|
||||
return await ocrImage(file)
|
||||
} else {
|
||||
// @ts-expect-error all types should be covered
|
||||
throw new Error(t('ocr.file.not_supported', { type: file.type }))
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to ocr.', e as Error)
|
||||
window.message.error(t('ocr.error.unknown') + ': ' + formatErrorMessage(e))
|
||||
throw e
|
||||
} finally {
|
||||
window.message.destroy(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ocr
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { BUILTIN_OCR_PROVIDERS_MAP } from '@renderer/config/ocr'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { addOcrProvider, removeOcrProvider, updateOcrProviderConfig } from '@renderer/store/ocr'
|
||||
import { isBuiltinOcrProviderId, OcrProvider, OcrProviderConfig } from '@renderer/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
const logger = loggerService.withContext('useOcrProvider')
|
||||
|
||||
export const useOcrProviders = () => {
|
||||
const providers = useAppSelector((state) => state.ocr.providers)
|
||||
const dispatch = useDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* 添加一个新的OCR服务提供者
|
||||
* @param provider - OCR提供者对象,包含id和其他配置信息
|
||||
* @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误
|
||||
*/
|
||||
const addProvider = (provider: OcrProvider) => {
|
||||
if (providers.some((p) => p.id === provider.id)) {
|
||||
const msg = `Provider with id ${provider.id} already exists`
|
||||
logger.error(msg)
|
||||
window.message.error(t('ocr.error.provider.existing'))
|
||||
throw new Error(msg)
|
||||
}
|
||||
dispatch(addOcrProvider(provider))
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个OCR服务提供者
|
||||
* @param id - 要移除的OCR提供者ID
|
||||
* @throws {Error} 当尝试移除一个内置提供商时抛出错误
|
||||
*/
|
||||
const removeProvider = (id: string) => {
|
||||
if (isBuiltinOcrProviderId(id)) {
|
||||
const msg = `Cannot remove builtin provider ${id}`
|
||||
logger.error(msg)
|
||||
window.message.error(t('ocr.error.provider.cannot_remove_builtin'))
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
dispatch(removeOcrProvider(id))
|
||||
}
|
||||
|
||||
return { providers, addProvider, removeProvider }
|
||||
}
|
||||
|
||||
export const useOcrProvider = (id: string) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useDispatch()
|
||||
const { providers, addProvider } = useOcrProviders()
|
||||
let provider = providers.find((p) => p.id === id)
|
||||
|
||||
// safely fallback
|
||||
if (!provider) {
|
||||
logger.error(`Ocr Provider ${id} not found`)
|
||||
window.message.error(t('ocr.error.provider.not_found'))
|
||||
if (isBuiltinOcrProviderId(id)) {
|
||||
try {
|
||||
addProvider(BUILTIN_OCR_PROVIDERS_MAP[id])
|
||||
} catch (e) {
|
||||
logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`)
|
||||
window.message.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name }))
|
||||
} finally {
|
||||
provider = BUILTIN_OCR_PROVIDERS_MAP[id]
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Fallback to tesseract`)
|
||||
window.message.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' }))
|
||||
provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = (update: Partial<OcrProviderConfig>) => {
|
||||
dispatch(updateOcrProviderConfig({ id: provider.id, update }))
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
updateConfig
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "Expand",
|
||||
"file_error": "Error processing file",
|
||||
"file_not_supported": "Model does not support this file type",
|
||||
"file_not_supported_count": "{{count}} files are not supported",
|
||||
"generate_image": "Generate image",
|
||||
"generate_image_not_supported": "The model does not support generating images.",
|
||||
"knowledge_base": "Knowledge Base",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "Enter new name",
|
||||
"title": "Edit Name",
|
||||
"title_tip": "Tips: Double-click the topic name to rename it directly in place"
|
||||
"title": "Edit Name"
|
||||
},
|
||||
"export": {
|
||||
"image": "Export as image",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "Enabled",
|
||||
"error": "error",
|
||||
"expand": "Expand",
|
||||
"file": {
|
||||
"not_supported": "Unsupported file type {{type}}"
|
||||
},
|
||||
"footnote": "Reference content",
|
||||
"footnotes": "References",
|
||||
"fullscreen": "Entered fullscreen mode. Press F11 to exit",
|
||||
@@ -766,7 +761,6 @@
|
||||
"open": "Open",
|
||||
"paste": "Paste",
|
||||
"preview": "Preview",
|
||||
"proceed": "Proceed",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Provider",
|
||||
"reasoning_content": "Deep reasoning",
|
||||
@@ -791,7 +785,6 @@
|
||||
"success": "Success",
|
||||
"swap": "Swap",
|
||||
"topics": "Topics",
|
||||
"upload_files": "Upload file",
|
||||
"warning": "Warning",
|
||||
"you": "You"
|
||||
},
|
||||
@@ -810,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "Backup file format error"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "Open debug panel",
|
||||
"message": "It seems that something went wrong...",
|
||||
"reload": "Reload"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Returned an invalid data format"
|
||||
@@ -897,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Continue Chatting",
|
||||
"error": {
|
||||
"topic_not_found": "Topic not found"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Locate the message"
|
||||
},
|
||||
@@ -1546,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "Currently, the reranker model does not support this provider ({{provider}})",
|
||||
"rerank_model_support_provider": "Currently, the reranker model only supports some providers ({{provider}})",
|
||||
"rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add.",
|
||||
"search": {
|
||||
"placeholder": "Search models...",
|
||||
"tooltip": "Search models"
|
||||
},
|
||||
"search": "Search models...",
|
||||
"stream_output": "Stream output",
|
||||
"type": {
|
||||
"embedding": "Embedding",
|
||||
@@ -1576,26 +1556,6 @@
|
||||
},
|
||||
"tip": "If the response is successful, then only messages exceeding 30 seconds will trigger a reminder"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Cannot delete built-in provider",
|
||||
"existing": "The provider already exists",
|
||||
"not_found": "OCR provider does not exist",
|
||||
"update_failed": "Failed to update configuration"
|
||||
},
|
||||
"unknown": "An error occurred during the OCR process"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "Unsupported file type {{type}}"
|
||||
},
|
||||
"processing": "OCR processing...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "Reverted to {{name}}, which may cause issues"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||
@@ -2717,8 +2677,7 @@
|
||||
"title": "Auto Update"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "Builtin avatar",
|
||||
"reset": "Reset avatar"
|
||||
"reset": "Reset Avatar"
|
||||
},
|
||||
"backup": {
|
||||
"button": "Backup",
|
||||
@@ -2937,10 +2896,6 @@
|
||||
"text": "Text",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search MCP servers...",
|
||||
"tooltip": "Search MCP servers"
|
||||
},
|
||||
"searchNpx": "Search MCP",
|
||||
"serverPlural": "servers",
|
||||
"serverSingular": "server",
|
||||
@@ -3343,36 +3298,6 @@
|
||||
"check": "Check",
|
||||
"check_all_keys": "Check All Keys",
|
||||
"check_multiple_keys": "Check Multiple API Keys",
|
||||
"cleanup": {
|
||||
"button": {
|
||||
"tooltip": "Clean up duplicate and missing providers"
|
||||
},
|
||||
"confirm": {
|
||||
"content": "This will clean up duplicate providers and add missing system providers. Do you want to continue?",
|
||||
"title": "Confirm Provider Cleanup"
|
||||
},
|
||||
"conflict": {
|
||||
"apply_resolution": "Apply Selection",
|
||||
"both_have_apikey": "{{provider}} has multiple providers with API keys configured",
|
||||
"both_have_apikey_desc": "Multiple providers with API keys detected, please select which configuration to keep",
|
||||
"description": "The following conflicts were detected and need manual handling:",
|
||||
"different_apihost": "{{provider}} has providers with different API hosts",
|
||||
"different_apihost_desc": "Providers with different API hosts detected, please select which configuration to keep",
|
||||
"multiple_enabled": "{{provider}} has multiple enabled providers",
|
||||
"multiple_enabled_desc": "Multiple enabled providers detected, please select which configuration to keep",
|
||||
"proceed_question": "One configuration has been automatically selected to keep. Do you want to continue?",
|
||||
"provider_conflict": "{{provider}} Configuration Conflict",
|
||||
"resolution_desc": "Please select which configuration to keep for each conflicting provider:",
|
||||
"resolution_title": "Resolve Provider Configuration Conflicts",
|
||||
"title": "Provider Configuration Conflicts",
|
||||
"unknown": "{{provider}} has unknown configuration conflicts",
|
||||
"unknown_desc": "Unknown configuration conflicts detected"
|
||||
},
|
||||
"no_changes": "Provider configuration does not need cleanup",
|
||||
"success": "Provider cleanup completed",
|
||||
"success_with_conflicts": "Provider cleanup completed (conflicts automatically handled)",
|
||||
"success_with_user_resolution": "Provider cleanup completed (conflicts resolved)"
|
||||
},
|
||||
"copilot": {
|
||||
"auth_failed": "Github Copilot authentication failed.",
|
||||
"auth_success": "GitHub Copilot authentication successful.",
|
||||
@@ -3550,20 +3475,6 @@
|
||||
},
|
||||
"title": "Settings",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "The provider does not exist"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Supported languages",
|
||||
"temp_tooltip": "Currently only Chinese and English are supported"
|
||||
},
|
||||
"title": "Image"
|
||||
},
|
||||
"image_provider": "OCR service provider",
|
||||
"title": "OCR service"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "Document Processing Provider",
|
||||
"provider_placeholder": "Choose a document processing provider",
|
||||
@@ -3804,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "Swap the source and target languages"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "Drop here",
|
||||
"error": {
|
||||
"multiple": "Multiple file uploads are not allowed",
|
||||
"too_large": "File too large",
|
||||
"unknown": "Failed to read file content"
|
||||
},
|
||||
"reading": "Reading file content..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "Clear History",
|
||||
"clear_description": "Clear history will delete all translation history, continue?",
|
||||
"delete": "Delete translation history",
|
||||
"delete": "Delete",
|
||||
"empty": "No translation history",
|
||||
"error": {
|
||||
"delete": "Deletion failed",
|
||||
"save": "Failed to save translation history"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "展開",
|
||||
"file_error": "ファイル処理エラー",
|
||||
"file_not_supported": "モデルはこのファイルタイプをサポートしません",
|
||||
"file_not_supported_count": "{{count}} 個のファイルはサポートされていません",
|
||||
"generate_image": "画像を生成する",
|
||||
"generate_image_not_supported": "モデルは画像の生成をサポートしていません。",
|
||||
"knowledge_base": "ナレッジベース",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "新しい名前を入力",
|
||||
"title": "名前を編集",
|
||||
"title_tip": "ヒント: トピック名をダブルクリックすると、直接その場で名前を変更できます"
|
||||
"title": "名前を編集"
|
||||
},
|
||||
"export": {
|
||||
"image": "画像としてエクスポート",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "有効",
|
||||
"error": "エラー",
|
||||
"expand": "展開",
|
||||
"file": {
|
||||
"not_supported": "サポートされていないファイルタイプ {{type}}"
|
||||
},
|
||||
"footnote": "引用内容",
|
||||
"footnotes": "脚注",
|
||||
"fullscreen": "全画面モードに入りました。F11キーで終了します",
|
||||
@@ -790,7 +785,6 @@
|
||||
"success": "成功",
|
||||
"swap": "交換",
|
||||
"topics": "トピック",
|
||||
"upload_files": "ファイルをアップロードする",
|
||||
"warning": "警告",
|
||||
"you": "あなた"
|
||||
},
|
||||
@@ -809,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "バックアップファイルの形式エラー"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "デバッグパネルを開く",
|
||||
"message": "何か問題が発生したようです...",
|
||||
"reload": "再読み込み"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "無効なデータ形式が返されました"
|
||||
@@ -896,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "チャットを続ける",
|
||||
"error": {
|
||||
"topic_not_found": "トピックが見つかりません"
|
||||
},
|
||||
"locate": {
|
||||
"message": "メッセージを探す"
|
||||
},
|
||||
@@ -1545,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。",
|
||||
"rerank_model_support_provider": "現在の再順序付けモデルは、{{provider}} のみサポートしています",
|
||||
"rerank_model_tooltip": "設定->モデルサービスに移動し、管理ボタンをクリックして追加します。",
|
||||
"search": {
|
||||
"placeholder": "モデルを検索...",
|
||||
"tooltip": "モデルを検索"
|
||||
},
|
||||
"search": "モデルを検索...",
|
||||
"stream_output": "ストリーム出力",
|
||||
"type": {
|
||||
"embedding": "埋め込み",
|
||||
@@ -1575,26 +1556,6 @@
|
||||
},
|
||||
"tip": "応答が成功した場合、30秒を超えるメッセージのみに通知を行います"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
|
||||
"existing": "プロバイダーはすでに存在します",
|
||||
"not_found": "OCRプロバイダーが存在しません",
|
||||
"update_failed": "更新構成に失敗しました"
|
||||
},
|
||||
"unknown": "OCR処理中にエラーが発生しました"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "サポートされていないファイルタイプ {{type}}"
|
||||
},
|
||||
"processing": "OCR処理中...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "{{name}} に戻されました。これにより問題が発生する可能性があります。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
@@ -2716,7 +2677,6 @@
|
||||
"title": "自動更新"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "内蔵アバター",
|
||||
"reset": "アバターをリセット"
|
||||
},
|
||||
"backup": {
|
||||
@@ -2936,10 +2896,6 @@
|
||||
"text": "テキスト",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "MCP サーバーを検索...",
|
||||
"tooltip": "MCP サーバーを検索"
|
||||
},
|
||||
"searchNpx": "MCP を検索",
|
||||
"serverPlural": "サーバー",
|
||||
"serverSingular": "サーバー",
|
||||
@@ -3519,20 +3475,6 @@
|
||||
},
|
||||
"title": "設定",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "該提供者は存在しません"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "サポートされている言語",
|
||||
"temp_tooltip": "現在のところ、中国語と英語のみをサポートしています"
|
||||
},
|
||||
"title": "画像"
|
||||
},
|
||||
"image_provider": "OCRサービスプロバイダー",
|
||||
"title": "OCRサービス"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "プレプロセスプロバイダー",
|
||||
"provider_placeholder": "前処理プロバイダーを選択してください",
|
||||
@@ -3773,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "入力言語と出力言語を入れ替える"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "ここにドラッグ&ドロップしてください",
|
||||
"error": {
|
||||
"multiple": "複数のファイルのアップロードは許可されていません",
|
||||
"too_large": "ファイルが大きすぎます",
|
||||
"unknown": "ファイルの内容を読み取るのに失敗しました"
|
||||
},
|
||||
"reading": "ファイルの内容を読み込んでいます..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "履歴をクリア",
|
||||
"clear_description": "履歴をクリアすると、すべての翻訳履歴が削除されます。続行しますか?",
|
||||
"delete": "翻訳履歴を削除する",
|
||||
"delete": "削除",
|
||||
"empty": "翻訳履歴がありません",
|
||||
"error": {
|
||||
"delete": "削除に失敗しました",
|
||||
"save": "保存翻訳履歴に失敗しました"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "Развернуть",
|
||||
"file_error": "Ошибка обработки файла",
|
||||
"file_not_supported": "Модель не поддерживает этот тип файла",
|
||||
"file_not_supported_count": "{{count}} файлов не поддерживаются",
|
||||
"generate_image": "Сгенерировать изображение",
|
||||
"generate_image_not_supported": "Модель не поддерживает генерацию изображений.",
|
||||
"knowledge_base": "База знаний",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "Введите новый заголовок",
|
||||
"title": "Редактировать заголовок",
|
||||
"title_tip": "Совет: двойной щелчок по названию темы позволяет переименовать её на месте"
|
||||
"title": "Редактировать заголовок"
|
||||
},
|
||||
"export": {
|
||||
"image": "Экспорт как изображение",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "Включено",
|
||||
"error": "ошибка",
|
||||
"expand": "Развернуть",
|
||||
"file": {
|
||||
"not_supported": "Неподдерживаемый тип файла {{type}}"
|
||||
},
|
||||
"footnote": "Цитируемый контент",
|
||||
"footnotes": "Сноски",
|
||||
"fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода",
|
||||
@@ -790,7 +785,6 @@
|
||||
"success": "Успешно",
|
||||
"swap": "Поменять местами",
|
||||
"topics": "Топики",
|
||||
"upload_files": "Загрузить файл",
|
||||
"warning": "Предупреждение",
|
||||
"you": "Вы"
|
||||
},
|
||||
@@ -809,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "Ошибка формата файла резервной копии"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "Открыть панель отладки",
|
||||
"message": "Похоже, возникла какая-то проблема...",
|
||||
"reload": "Перезагрузить"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Вернулся недопустимый формат данных"
|
||||
@@ -896,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Продолжить чат",
|
||||
"error": {
|
||||
"topic_not_found": "Топик не найден"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Найти сообщение"
|
||||
},
|
||||
@@ -1545,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})",
|
||||
"rerank_model_support_provider": "Текущая модель переупорядочивания поддерживается только некоторыми поставщиками ({{provider}})",
|
||||
"rerank_model_tooltip": "В настройках -> Служба модели нажмите кнопку \"Управление\", чтобы добавить.",
|
||||
"search": {
|
||||
"placeholder": "Поиск моделей...",
|
||||
"tooltip": "Поиск моделей"
|
||||
},
|
||||
"search": "Поиск моделей...",
|
||||
"stream_output": "Потоковый вывод",
|
||||
"type": {
|
||||
"embedding": "Встраиваемые",
|
||||
@@ -1575,26 +1556,6 @@
|
||||
},
|
||||
"tip": "Если ответ успешен, уведомление выдается только по сообщениям, превышающим 30 секунд"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
|
||||
"existing": "Поставщик уже существует",
|
||||
"not_found": "Поставщик OCR отсутствует",
|
||||
"update_failed": "Обновление конфигурации не удалось"
|
||||
},
|
||||
"unknown": "Произошла ошибка в процессе распознавания текста"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "Неподдерживаемый тип файла {{type}}"
|
||||
},
|
||||
"processing": "Обработка OCR...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "Возвращено к {{name}}, это может вызвать проблемы"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
@@ -2716,7 +2677,6 @@
|
||||
"title": "Автоматическое обновление"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "Встроенный аватар",
|
||||
"reset": "Сброс аватара"
|
||||
},
|
||||
"backup": {
|
||||
@@ -2936,10 +2896,6 @@
|
||||
"text": "Текст",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Найти MCP серверы...",
|
||||
"tooltip": "Найти MCP серверы"
|
||||
},
|
||||
"searchNpx": "Найти MCP",
|
||||
"serverPlural": "серверы",
|
||||
"serverSingular": "сервер",
|
||||
@@ -3519,20 +3475,6 @@
|
||||
},
|
||||
"title": "Настройки",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "Поставщик не существует"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Поддерживаемые языки",
|
||||
"temp_tooltip": "На данный момент поддерживаются только китайский и английский языки"
|
||||
},
|
||||
"title": "Изображение"
|
||||
},
|
||||
"image_provider": "Поставщик услуг OCR",
|
||||
"title": "OCR-сервис"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "Поставщик обработки документов",
|
||||
"provider_placeholder": "Выберите поставщика услуг обработки документов",
|
||||
@@ -3773,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "Поменяйте исходный и целевой языки местами"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "Перетащите сюда",
|
||||
"error": {
|
||||
"multiple": "Не разрешается загружать несколько файлов",
|
||||
"too_large": "Файл слишком большой",
|
||||
"unknown": "Ошибка при чтении содержимого файла"
|
||||
},
|
||||
"reading": "Чтение содержимого файла..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "Очистить историю",
|
||||
"clear_description": "Очистка истории удалит все записи переводов. Продолжить?",
|
||||
"delete": "Удалить историю переводов",
|
||||
"delete": "Удалить",
|
||||
"empty": "История переводов отсутствует",
|
||||
"error": {
|
||||
"delete": "Удаление не удалось",
|
||||
"save": "Не удалось сохранить историю переводов"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "展开",
|
||||
"file_error": "文件处理出错",
|
||||
"file_not_supported": "模型不支持此文件类型",
|
||||
"file_not_supported_count": "{{count}} 个文件不被支持",
|
||||
"generate_image": "生成图片",
|
||||
"generate_image_not_supported": "模型不支持生成图片",
|
||||
"knowledge_base": "知识库",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "输入新名称",
|
||||
"title": "编辑话题名",
|
||||
"title_tip": "提示: 双击话题名可以直接就地重命名"
|
||||
"title": "编辑话题名"
|
||||
},
|
||||
"export": {
|
||||
"image": "导出为图片",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "已启用",
|
||||
"error": "错误",
|
||||
"expand": "展开",
|
||||
"file": {
|
||||
"not_supported": "不支持的文件类型 {{type}}"
|
||||
},
|
||||
"footnote": "引用内容",
|
||||
"footnotes": "引用内容",
|
||||
"fullscreen": "已进入全屏模式,按 F11 退出",
|
||||
@@ -766,7 +761,6 @@
|
||||
"open": "打开",
|
||||
"paste": "粘贴",
|
||||
"preview": "预览",
|
||||
"proceed": "继续",
|
||||
"prompt": "提示词",
|
||||
"provider": "提供商",
|
||||
"reasoning_content": "已深度思考",
|
||||
@@ -791,7 +785,6 @@
|
||||
"success": "成功",
|
||||
"swap": "交换",
|
||||
"topics": "话题",
|
||||
"upload_files": "上传文件",
|
||||
"warning": "警告",
|
||||
"you": "用户"
|
||||
},
|
||||
@@ -810,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "备份文件格式错误"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "打开调试面板",
|
||||
"message": "似乎出现了一些问题...",
|
||||
"reload": "重新加载"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "返回了无效的数据格式"
|
||||
@@ -897,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "继续聊天",
|
||||
"error": {
|
||||
"topic_not_found": "话题不存在"
|
||||
},
|
||||
"locate": {
|
||||
"message": "定位到消息"
|
||||
},
|
||||
@@ -1546,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})",
|
||||
"rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})",
|
||||
"rerank_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加",
|
||||
"search": {
|
||||
"placeholder": "搜索模型...",
|
||||
"tooltip": "搜索模型"
|
||||
},
|
||||
"search": "搜索模型...",
|
||||
"stream_output": "流式输出",
|
||||
"type": {
|
||||
"embedding": "嵌入",
|
||||
@@ -1576,26 +1556,6 @@
|
||||
},
|
||||
"tip": "如果响应成功,则只针对超过30秒的消息进行提醒"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "不能删除内置提供商",
|
||||
"existing": "提供商已存在",
|
||||
"not_found": "OCR 提供商不存在",
|
||||
"update_failed": "更新配置失败"
|
||||
},
|
||||
"unknown": "OCR 过程发生错误"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "不支持的文件类型 {{type}}"
|
||||
},
|
||||
"processing": "OCR 处理中...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "已回退到 {{name}},这可能导致问题"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "对话后模型在内存中保持的时间(默认:5 分钟)",
|
||||
@@ -2717,7 +2677,6 @@
|
||||
"title": "自动更新"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "内置头像",
|
||||
"reset": "重置头像"
|
||||
},
|
||||
"backup": {
|
||||
@@ -2937,10 +2896,6 @@
|
||||
"text": "文本",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索 MCP 服务器...",
|
||||
"tooltip": "搜索 MCP 服务器"
|
||||
},
|
||||
"searchNpx": "搜索 MCP",
|
||||
"serverPlural": "服务器",
|
||||
"serverSingular": "服务器",
|
||||
@@ -3343,36 +3298,6 @@
|
||||
"check": "检测",
|
||||
"check_all_keys": "检测所有密钥",
|
||||
"check_multiple_keys": "检测多个 API 密钥",
|
||||
"cleanup": {
|
||||
"button": {
|
||||
"tooltip": "清理重复和缺失的提供商"
|
||||
},
|
||||
"confirm": {
|
||||
"content": "这将清理重复的提供商并添加缺失的系统提供商,是否继续?",
|
||||
"title": "确认清理提供商"
|
||||
},
|
||||
"conflict": {
|
||||
"apply_resolution": "应用选择",
|
||||
"both_have_apikey": "{{provider}} 存在多个配置了 API 密钥的提供商",
|
||||
"both_have_apikey_desc": "检测到多个配置了 API 密钥的提供商,请选择要保留的配置",
|
||||
"description": "检测到以下冲突需要手动处理:",
|
||||
"different_apihost": "{{provider}} 存在不同 API 地址的提供商配置",
|
||||
"different_apihost_desc": "检测到不同 API 地址的提供商配置,请选择要保留的配置",
|
||||
"multiple_enabled": "{{provider}} 存在多个已启用的提供商",
|
||||
"multiple_enabled_desc": "检测到多个已启用的提供商,请选择要保留的配置",
|
||||
"proceed_question": "已自动选择一个配置保留,是否继续?",
|
||||
"provider_conflict": "{{provider}} 配置冲突",
|
||||
"resolution_desc": "请为每个冲突的提供商选择要保留的配置:",
|
||||
"resolution_title": "解决提供商配置冲突",
|
||||
"title": "提供商配置冲突",
|
||||
"unknown": "{{provider}} 存在未知配置冲突",
|
||||
"unknown_desc": "检测到未知配置冲突"
|
||||
},
|
||||
"no_changes": "提供商配置无需清理",
|
||||
"success": "提供商清理完成",
|
||||
"success_with_conflicts": "提供商清理完成(存在冲突已自动处理)",
|
||||
"success_with_user_resolution": "提供商清理完成(冲突已解决)"
|
||||
},
|
||||
"copilot": {
|
||||
"auth_failed": "Github Copilot 认证失败",
|
||||
"auth_success": "Github Copilot 认证成功",
|
||||
@@ -3550,20 +3475,6 @@
|
||||
},
|
||||
"title": "设置",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "该提供商不存在"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "支持的语言",
|
||||
"temp_tooltip": "目前暂时只支持中文和英文"
|
||||
},
|
||||
"title": "图片"
|
||||
},
|
||||
"image_provider": "OCR 服务提供商",
|
||||
"title": "OCR 服务"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "文档处理服务商",
|
||||
"provider_placeholder": "选择一个文档处理服务商",
|
||||
@@ -3804,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "交换源语言与目标语言"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "拖放到此处",
|
||||
"error": {
|
||||
"multiple": "不允许上传多个文件",
|
||||
"too_large": "文件过大",
|
||||
"unknown": "读取文件内容失败"
|
||||
},
|
||||
"reading": "读取文件内容中..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "清空历史",
|
||||
"clear_description": "清空历史将删除所有翻译历史记录,是否继续?",
|
||||
"delete": "删除翻译历史",
|
||||
"delete": "删除",
|
||||
"empty": "暂无翻译历史",
|
||||
"error": {
|
||||
"delete": "删除失败",
|
||||
"save": "保存翻译历史失败"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "展開",
|
||||
"file_error": "檔案處理錯誤",
|
||||
"file_not_supported": "模型不支援此檔案類型",
|
||||
"file_not_supported_count": "{{count}} 個檔案不被支援",
|
||||
"generate_image": "生成圖片",
|
||||
"generate_image_not_supported": "模型不支援生成圖片",
|
||||
"knowledge_base": "知識庫",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "輸入新名稱",
|
||||
"title": "編輯名稱",
|
||||
"title_tip": "提示:雙擊話題名可以直接就地重新命名"
|
||||
"title": "編輯名稱"
|
||||
},
|
||||
"export": {
|
||||
"image": "匯出為圖片",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "已啟用",
|
||||
"error": "錯誤",
|
||||
"expand": "展開",
|
||||
"file": {
|
||||
"not_supported": "不支持的文件類型 {{type}}"
|
||||
},
|
||||
"footnote": "引用內容",
|
||||
"footnotes": "引用",
|
||||
"fullscreen": "已進入全螢幕模式,按 F11 結束",
|
||||
@@ -790,7 +785,6 @@
|
||||
"success": "成功",
|
||||
"swap": "交換",
|
||||
"topics": "話題",
|
||||
"upload_files": "上傳檔案",
|
||||
"warning": "警告",
|
||||
"you": "您"
|
||||
},
|
||||
@@ -809,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "備份檔案格式錯誤"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "打開除錯面板",
|
||||
"message": "似乎出現了一些問題...",
|
||||
"reload": "重新載入"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "返回了無效的資料格式"
|
||||
@@ -896,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "繼續聊天",
|
||||
"error": {
|
||||
"topic_not_found": "話題不存在"
|
||||
},
|
||||
"locate": {
|
||||
"message": "定位到訊息"
|
||||
},
|
||||
@@ -1545,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})",
|
||||
"rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})",
|
||||
"rerank_model_tooltip": "在設定 -> 模型服務中點擊管理按鈕添加",
|
||||
"search": {
|
||||
"placeholder": "搜尋模型...",
|
||||
"tooltip": "搜尋模型"
|
||||
},
|
||||
"search": "搜尋模型...",
|
||||
"stream_output": "串流輸出",
|
||||
"type": {
|
||||
"embedding": "嵌入",
|
||||
@@ -1575,26 +1556,6 @@
|
||||
},
|
||||
"tip": "如果回應成功,則只針對超過30秒的訊息發出提醒"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "不能刪除內建提供者",
|
||||
"existing": "提供商已存在",
|
||||
"not_found": "OCR 提供商不存在",
|
||||
"update_failed": "更新配置失敗"
|
||||
},
|
||||
"unknown": "OCR過程發生錯誤"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "不支持的文件類型 {{type}}"
|
||||
},
|
||||
"processing": "OCR 處理中...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "已回退到 {{name}},這可能導致問題"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||
@@ -2716,7 +2677,6 @@
|
||||
"title": "自動更新"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "內置頭像",
|
||||
"reset": "重設頭像"
|
||||
},
|
||||
"backup": {
|
||||
@@ -2936,10 +2896,6 @@
|
||||
"text": "文字",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索 MCP 伺服器...",
|
||||
"tooltip": "搜索 MCP 伺服器"
|
||||
},
|
||||
"searchNpx": "搜索 MCP",
|
||||
"serverPlural": "伺服器",
|
||||
"serverSingular": "伺服器",
|
||||
@@ -3519,20 +3475,6 @@
|
||||
},
|
||||
"title": "設定",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "該提供商不存在"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "支援的語言",
|
||||
"temp_tooltip": "目前暫時只支援中文和英文"
|
||||
},
|
||||
"title": "圖片"
|
||||
},
|
||||
"image_provider": "OCR 服務提供商",
|
||||
"title": "OCR 服務"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "文件處理供應商",
|
||||
"provider_placeholder": "選擇一個文件處理供應商",
|
||||
@@ -3773,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "交換源語言與目標語言"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "拖放到此处",
|
||||
"error": {
|
||||
"multiple": "不允许上传多个文件",
|
||||
"too_large": "文件過大",
|
||||
"unknown": "读取文件内容失败"
|
||||
},
|
||||
"reading": "讀取檔案內容中..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "清空歷史",
|
||||
"clear_description": "清空歷史將刪除所有翻譯歷史記錄,是否繼續?",
|
||||
"delete": "刪除翻譯歷史",
|
||||
"delete": "刪除",
|
||||
"empty": "翻譯歷史為空",
|
||||
"error": {
|
||||
"delete": "删除失败",
|
||||
"save": "保存翻譯歷史失敗"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "Επεκτάση",
|
||||
"file_error": "Σφάλμα κατά την επεξεργασία του αρχείου",
|
||||
"file_not_supported": "Το μοντέλο δεν υποστηρίζει αυτό το είδος αρχείων",
|
||||
"file_not_supported_count": "{{count}} αρχεία δεν υποστηρίζονται",
|
||||
"generate_image": "Δημιουργία εικόνας",
|
||||
"generate_image_not_supported": "Το μοντέλο δεν υποστηρίζει τη δημιουργία εικόνων",
|
||||
"knowledge_base": "Βάση γνώσεων",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "Εισαγάγετε το νέο όνομα",
|
||||
"title": "Επεξεργασία ονόματος θέματος",
|
||||
"title_tip": "Συμβουλές: Διπλό κλικ στο όνομα του θέματος για να το μετονομάσετε απευθείας"
|
||||
"title": "Επεξεργασία ονόματος θέματος"
|
||||
},
|
||||
"export": {
|
||||
"image": "Εξαγωγή ως εικόνα",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"error": "σφάλμα",
|
||||
"expand": "Επεκτάση",
|
||||
"file": {
|
||||
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
|
||||
},
|
||||
"footnote": "Παραπομπή",
|
||||
"footnotes": "Παραπομπές",
|
||||
"fullscreen": "Εισήχθη σε πλήρη οθόνη, πατήστε F11 για να έξω",
|
||||
@@ -790,7 +785,6 @@
|
||||
"success": "Επιτυχία",
|
||||
"swap": "Εναλλαγή",
|
||||
"topics": "Θέματα",
|
||||
"upload_files": "Ανέβασμα αρχείου",
|
||||
"warning": "Προσοχή",
|
||||
"you": "Εσείς"
|
||||
},
|
||||
@@ -809,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "Λάθος μορφή αρχείου που επιστρέφεται"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
|
||||
"message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...",
|
||||
"reload": "Επαναφόρτωση"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Επέστρεψε μη έγκυρη μορφή δεδομένων"
|
||||
@@ -896,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Συνεχίστε το συνομιλημένο",
|
||||
"error": {
|
||||
"topic_not_found": "Το θέμα δεν υπάρχει"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Εφαρμογή στο μήνυμα"
|
||||
},
|
||||
@@ -1545,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "Ο επαναξιολογητικός μοντέλος δεν υποστηρίζει αυτόν τον πάροχο ({{provider}})",
|
||||
"rerank_model_support_provider": "Σημειώστε ότι το μοντέλο αναδιάταξης υποστηρίζεται από μερικούς παρόχους ({{provider}})",
|
||||
"rerank_model_tooltip": "Κάντε κλικ στο κουμπί Διαχείριση στο παράθυρο Ρυθμίσεις -> Υπηρεσία Μοντέλων",
|
||||
"search": {
|
||||
"placeholder": "Αναζήτηση μοντέλου...",
|
||||
"tooltip": "Αναζήτηση μοντέλου"
|
||||
},
|
||||
"search": "Αναζήτηση μοντέλου...",
|
||||
"stream_output": "Διαρκής Εξόδος",
|
||||
"type": {
|
||||
"embedding": "ενσωμάτωση",
|
||||
@@ -1575,26 +1556,6 @@
|
||||
},
|
||||
"tip": "Εάν η απάντηση είναι επιτυχής, η ειδοποίηση εμφανίζεται μόνο για μηνύματα που υπερβαίνουν τα 30 δευτερόλεπτα"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
|
||||
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
|
||||
"not_found": "Ο πάροχος OCR δεν υπάρχει",
|
||||
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
|
||||
},
|
||||
"unknown": "Η διαδικασία OCR εμφάνισε σφάλμα"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
|
||||
},
|
||||
"processing": "Η επεξεργασία OCR βρίσκεται σε εξέλιξη...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "Επαναφέρθηκε στο {{name}}, το οποίο μπορεί να προκαλέσει προβλήματα"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "Χρόνος που ο μοντέλος διατηρείται στη μνήμη μετά τη συζήτηση (προεπιλογή: 5 λεπτά)",
|
||||
@@ -2935,10 +2896,6 @@
|
||||
"text": "Κείμενο",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Αναζήτηση MCP διακομιστών...",
|
||||
"tooltip": "Αναζήτηση MCP διακομιστών"
|
||||
},
|
||||
"searchNpx": "Αναζήτηση MCP",
|
||||
"serverPlural": "Διακομιστές",
|
||||
"serverSingular": "Διακομιστής",
|
||||
@@ -3518,20 +3475,6 @@
|
||||
},
|
||||
"title": "Ρυθμίσεις",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "Ο πάροχος δεν υπάρχει"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Υποστηριζόμενες γλώσσες",
|
||||
"temp_tooltip": "Προς το παρόν υποστηρίζονται μόνο η κινεζική και η αγγλική γλώσσα"
|
||||
},
|
||||
"title": "Εικόνα"
|
||||
},
|
||||
"image_provider": "Πάροχοι υπηρεσιών OCR",
|
||||
"title": "Υπηρεσία OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "πάροχος υπηρεσιών προεπεξεργασίας εγγράφων",
|
||||
"provider_placeholder": "Επιλέξτε έναν πάροχο υπηρεσιών προεπεξεργασίας εγγράφων",
|
||||
@@ -3772,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "Ανταλλαγή γλώσσας πηγής και γλώσσας προορισμού"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "Σύρετε και αφήστε εδώ",
|
||||
"error": {
|
||||
"multiple": "Δεν επιτρέπεται η μεταφόρτωση πολλαπλών αρχείων",
|
||||
"too_large": "Το αρχείο είναι πολύ μεγάλο",
|
||||
"unknown": "Αποτυχία ανάγνωσης του περιεχομένου του αρχείου"
|
||||
},
|
||||
"reading": "Διαβάζοντας το περιεχόμενο του αρχείου..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "Καθαρισμός ιστορικού",
|
||||
"clear_description": "Η διαγραφή του ιστορικού θα διαγράψει όλα τα απομνημονεύματα μετάφρασης. Θέλετε να συνεχίσετε;",
|
||||
"delete": "Διαγραφή του ιστορικού μετάφρασης",
|
||||
"delete": "Διαγραφή",
|
||||
"empty": "δεν υπάρχουν απομνημονεύματα μετάφρασης",
|
||||
"error": {
|
||||
"delete": "Αποτυχία διαγραφής",
|
||||
"save": "Αποτυχία αποθήκευσης του ιστορικού μεταφράσεων"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "Expandir",
|
||||
"file_error": "Error al procesar el archivo",
|
||||
"file_not_supported": "El modelo no admite este tipo de archivo",
|
||||
"file_not_supported_count": "{{count}} archivos no soportados",
|
||||
"generate_image": "Generar imagen",
|
||||
"generate_image_not_supported": "El modelo no soporta la generación de imágenes",
|
||||
"knowledge_base": "Base de conocimientos",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "Introduce nuevo nombre",
|
||||
"title": "Editar nombre del tema",
|
||||
"title_tip": "Consejos: hacer doble clic en el nombre del tema permite cambiar el nombre directamente en el lugar"
|
||||
"title": "Editar nombre del tema"
|
||||
},
|
||||
"export": {
|
||||
"image": "Exportar como imagen",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "Activado",
|
||||
"error": "error",
|
||||
"expand": "Expandir",
|
||||
"file": {
|
||||
"not_supported": "Tipo de archivo no compatible {{type}}"
|
||||
},
|
||||
"footnote": "Nota al pie",
|
||||
"footnotes": "Notas al pie",
|
||||
"fullscreen": "En modo pantalla completa, presione F11 para salir",
|
||||
@@ -790,7 +785,6 @@
|
||||
"success": "Éxito",
|
||||
"swap": "Intercambiar",
|
||||
"topics": "Temas",
|
||||
"upload_files": "Subir archivo",
|
||||
"warning": "Advertencia",
|
||||
"you": "Usuario"
|
||||
},
|
||||
@@ -809,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "Formato de archivo de copia de seguridad incorrecto"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "Abrir el panel de depuración",
|
||||
"message": "Parece que ha surgido un problema...",
|
||||
"reload": "Recargar"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Devuelve un formato de datos no válido"
|
||||
@@ -896,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Continuar chat",
|
||||
"error": {
|
||||
"topic_not_found": "El tema no existe"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Localizar mensaje"
|
||||
},
|
||||
@@ -1545,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "Actualmente, el modelo de reordenamiento no admite este proveedor ({{provider}})",
|
||||
"rerank_model_support_provider": "Actualmente, el modelo de reordenamiento solo es compatible con algunos proveedores ({{provider}})",
|
||||
"rerank_model_tooltip": "Haga clic en el botón Administrar en Configuración->Servicio de modelos para agregar",
|
||||
"search": {
|
||||
"placeholder": "Buscar modelo...",
|
||||
"tooltip": "Buscar modelo"
|
||||
},
|
||||
"search": "Buscar modelo...",
|
||||
"stream_output": "Salida en flujo",
|
||||
"type": {
|
||||
"embedding": "Incrustación",
|
||||
@@ -1575,26 +1556,6 @@
|
||||
},
|
||||
"tip": "Si la respuesta es exitosa, solo se enviará un recordatorio para mensajes que excedan los 30 segundos"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
|
||||
"existing": "El proveedor ya existe",
|
||||
"not_found": "El proveedor de OCR no existe",
|
||||
"update_failed": "Actualización de la configuración fallida"
|
||||
},
|
||||
"unknown": "El proceso OCR ha fallado"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "Tipo de archivo no compatible {{type}}"
|
||||
},
|
||||
"processing": "Procesando OCR...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "Se ha revertido a {{name}}, lo que podría causar problemas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "Tiempo que el modelo permanece en memoria después de la conversación (por defecto: 5 minutos)",
|
||||
@@ -2935,10 +2896,6 @@
|
||||
"text": "Texto",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar servidores MCP...",
|
||||
"tooltip": "Buscar servidores MCP"
|
||||
},
|
||||
"searchNpx": "Buscar MCP",
|
||||
"serverPlural": "Servidores",
|
||||
"serverSingular": "Servidor",
|
||||
@@ -3518,20 +3475,6 @@
|
||||
},
|
||||
"title": "Configuración",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "El proveedor no existe"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Idiomas compatibles",
|
||||
"temp_tooltip": "Actualmente solo se admiten chino e inglés."
|
||||
},
|
||||
"title": "Imagen"
|
||||
},
|
||||
"image_provider": "Proveedor de servicios OCR",
|
||||
"title": "Servicio OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "Proveedor de servicios de preprocesamiento de documentos",
|
||||
"provider_placeholder": "Seleccionar un proveedor de servicios de preprocesamiento de documentos",
|
||||
@@ -3772,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "Intercambiar el idioma de origen y el idioma de destino"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "Arrastrar y soltar aquí",
|
||||
"error": {
|
||||
"multiple": "No se permite cargar varios archivos",
|
||||
"too_large": "El archivo es demasiado grande",
|
||||
"unknown": "Error al leer el contenido del archivo"
|
||||
},
|
||||
"reading": "Leyendo el contenido del archivo..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "Borrar historial",
|
||||
"clear_description": "Borrar el historial eliminará todos los registros de traducciones, ¿desea continuar?",
|
||||
"delete": "Eliminar historial de traducción",
|
||||
"delete": "Eliminar",
|
||||
"empty": "Sin historial de traducciones por el momento",
|
||||
"error": {
|
||||
"delete": "Eliminación fallida",
|
||||
"save": "Error al guardar el historial de traducciones"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "Développer",
|
||||
"file_error": "Erreur lors du traitement du fichier",
|
||||
"file_not_supported": "Le modèle ne prend pas en charge ce type de fichier",
|
||||
"file_not_supported_count": "{{count}} fichiers non pris en charge",
|
||||
"generate_image": "Générer une image",
|
||||
"generate_image_not_supported": "Le modèle ne supporte pas la génération d'images",
|
||||
"knowledge_base": "Base de connaissances",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "Entrez un nouveau nom",
|
||||
"title": "Modifier le nom du sujet",
|
||||
"title_tip": "Conseil : double-cliquez sur le nom du sujet pour le renommer directement sur place"
|
||||
"title": "Modifier le nom du sujet"
|
||||
},
|
||||
"export": {
|
||||
"image": "Exporter sous forme d'image",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "Activé",
|
||||
"error": "erreur",
|
||||
"expand": "Développer",
|
||||
"file": {
|
||||
"not_supported": "Type de fichier non pris en charge {{type}}"
|
||||
},
|
||||
"footnote": "Note de bas de page",
|
||||
"footnotes": "Notes de bas de page",
|
||||
"fullscreen": "Mode plein écran, appuyez sur F11 pour quitter",
|
||||
@@ -790,7 +785,6 @@
|
||||
"success": "Succès",
|
||||
"swap": "Échanger",
|
||||
"topics": "Sujets",
|
||||
"upload_files": "Uploader des fichiers",
|
||||
"warning": "Avertissement",
|
||||
"you": "Vous"
|
||||
},
|
||||
@@ -809,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "Le format du fichier de sauvegarde est incorrect"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "Ouvrir le panneau de débogage",
|
||||
"message": "Il semble que quelques problèmes soient survenus...",
|
||||
"reload": "Recharger"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "a renvoyé un format de données invalide"
|
||||
@@ -896,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Continuer la conversation",
|
||||
"error": {
|
||||
"topic_not_found": "Le sujet n'existe pas"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Localiser le message"
|
||||
},
|
||||
@@ -1545,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "Le modèle de réordonnancement ne prend pas en charge ce fournisseur ({{provider}}) pour le moment",
|
||||
"rerank_model_support_provider": "Le modèle de réordonnancement ne prend actuellement en charge que certains fournisseurs ({{provider}})",
|
||||
"rerank_model_tooltip": "Cliquez sur le bouton Gérer dans Paramètres -> Services de modèles pour ajouter",
|
||||
"search": {
|
||||
"placeholder": "Rechercher un modèle...",
|
||||
"tooltip": "Rechercher un modèle"
|
||||
},
|
||||
"search": "Rechercher un modèle...",
|
||||
"stream_output": "Sortie en flux",
|
||||
"type": {
|
||||
"embedding": "Incorporation",
|
||||
@@ -1575,26 +1556,6 @@
|
||||
},
|
||||
"tip": "Si la réponse est réussie, un rappel est envoyé uniquement pour les messages dépassant 30 secondes"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
|
||||
"existing": "Le fournisseur existe déjà",
|
||||
"not_found": "Le fournisseur OCR n'existe pas",
|
||||
"update_failed": "Échec de la mise à jour de la configuration"
|
||||
},
|
||||
"unknown": "Une erreur s'est produite lors du processus OCR"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "Type de fichier non pris en charge {{type}}"
|
||||
},
|
||||
"processing": "Traitement OCR en cours...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "Revenu à {{name}}, ce qui pourrait entraîner des problèmes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "Le temps pendant lequel le modèle reste en mémoire après la conversation (par défaut : 5 minutes)",
|
||||
@@ -2935,10 +2896,6 @@
|
||||
"text": "Текст",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher des serveurs MCP...",
|
||||
"tooltip": "Rechercher des serveurs MCP"
|
||||
},
|
||||
"searchNpx": "Поиск MCP",
|
||||
"serverPlural": "Serveurs",
|
||||
"serverSingular": "Serveur",
|
||||
@@ -3518,20 +3475,6 @@
|
||||
},
|
||||
"title": "Paramètres",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "Ce fournisseur n'existe pas"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Langues prises en charge",
|
||||
"temp_tooltip": "Pour le moment, seuls le chinois et l'anglais sont pris en charge."
|
||||
},
|
||||
"title": "Image"
|
||||
},
|
||||
"image_provider": "Fournisseur de service OCR",
|
||||
"title": "Service OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "fournisseur de services de prétraitement de documents",
|
||||
"provider_placeholder": "Choisissez un prestataire de traitement de documents",
|
||||
@@ -3772,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "Échanger la langue source et la langue cible"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "Glisser-déposer ici",
|
||||
"error": {
|
||||
"multiple": "Impossible de téléverser plusieurs fichiers",
|
||||
"too_large": "Fichier trop volumineux",
|
||||
"unknown": "Échec de la lecture du contenu du fichier"
|
||||
},
|
||||
"reading": "Lecture du contenu du fichier en cours..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "Effacer l'historique",
|
||||
"clear_description": "L'effacement de l'historique supprimera toutes les entrées d'historique de traduction, voulez-vous continuer ?",
|
||||
"delete": "Supprimer l'historique des traductions",
|
||||
"delete": "Supprimer",
|
||||
"empty": "Aucun historique de traduction pour le moment",
|
||||
"error": {
|
||||
"delete": "Échec de la suppression",
|
||||
"save": "Échec de la sauvegarde de l'historique des traductions"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -322,7 +322,6 @@
|
||||
"expand": "Expandir",
|
||||
"file_error": "Erro ao processar o arquivo",
|
||||
"file_not_supported": "O modelo não suporta este tipo de arquivo",
|
||||
"file_not_supported_count": "{{count}} arquivos não suportados",
|
||||
"generate_image": "Gerar imagem",
|
||||
"generate_image_not_supported": "Modelo não suporta geração de imagem",
|
||||
"knowledge_base": "Base de conhecimento",
|
||||
@@ -584,8 +583,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"placeholder": "Digite novo nome",
|
||||
"title": "Editar nome do tópico",
|
||||
"title_tip": "Dicas: Clique duas vezes no nome do tópico para renomeá-lo diretamente no local"
|
||||
"title": "Editar nome do tópico"
|
||||
},
|
||||
"export": {
|
||||
"image": "Exportar como imagem",
|
||||
@@ -747,9 +745,6 @@
|
||||
"enabled": "Ativado",
|
||||
"error": "错误",
|
||||
"expand": "Expandir",
|
||||
"file": {
|
||||
"not_supported": "Tipo de arquivo não suportado {{type}}"
|
||||
},
|
||||
"footnote": "Nota de rodapé",
|
||||
"footnotes": "Notas de rodapé",
|
||||
"fullscreen": "Entrou no modo de tela cheia, pressione F11 para sair",
|
||||
@@ -790,7 +785,6 @@
|
||||
"success": "Sucesso",
|
||||
"swap": "Trocar",
|
||||
"topics": "Tópicos",
|
||||
"upload_files": "Carregar arquivo",
|
||||
"warning": "Aviso",
|
||||
"you": "Você"
|
||||
},
|
||||
@@ -809,13 +803,6 @@
|
||||
"backup": {
|
||||
"file_format": "Formato do arquivo de backup está incorreto"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "Abrir o painel de depuração",
|
||||
"message": "Parece que ocorreu um problema...",
|
||||
"reload": "Recarregar"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Devolveu um formato de dados inválido"
|
||||
@@ -896,9 +883,6 @@
|
||||
},
|
||||
"history": {
|
||||
"continue_chat": "Continuar conversando",
|
||||
"error": {
|
||||
"topic_not_found": "Tópico inexistente"
|
||||
},
|
||||
"locate": {
|
||||
"message": "Localizar mensagem"
|
||||
},
|
||||
@@ -1545,10 +1529,7 @@
|
||||
"rerank_model_not_support_provider": "Atualmente o modelo de reclassificação não suporta este provedor ({{provider}})",
|
||||
"rerank_model_support_provider": "O modelo de reclassificação atualmente suporta apenas alguns provedores ({{provider}})",
|
||||
"rerank_model_tooltip": "Clique no botão Gerenciar em Configurações -> Serviço de modelos para adicionar",
|
||||
"search": {
|
||||
"placeholder": "Procurar modelo...",
|
||||
"tooltip": "Procurar modelo"
|
||||
},
|
||||
"search": "Procurar modelo...",
|
||||
"stream_output": "Saída em fluxo",
|
||||
"type": {
|
||||
"embedding": "inserção",
|
||||
@@ -1575,26 +1556,6 @@
|
||||
},
|
||||
"tip": "Se a resposta for bem-sucedida, lembrete apenas para mensagens que excedam 30 segundos"
|
||||
},
|
||||
"ocr": {
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
|
||||
"existing": "O provedor já existe",
|
||||
"not_found": "O provedor OCR não existe",
|
||||
"update_failed": "Falha ao atualizar a configuração"
|
||||
},
|
||||
"unknown": "O processo OCR apresentou um erro"
|
||||
},
|
||||
"file": {
|
||||
"not_supported": "Tipo de arquivo não suportado {{type}}"
|
||||
},
|
||||
"processing": "Processamento OCR em andamento...",
|
||||
"warning": {
|
||||
"provider": {
|
||||
"fallback": "Revertido para {{name}}, o que pode causar problemas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time": {
|
||||
"description": "Tempo que o modelo permanece na memória após a conversa (padrão: 5 minutos)",
|
||||
@@ -2935,10 +2896,6 @@
|
||||
"text": "Texto",
|
||||
"uri": "URI"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar servidores MCP...",
|
||||
"tooltip": "Buscar servidores MCP"
|
||||
},
|
||||
"searchNpx": "Buscar MCP",
|
||||
"serverPlural": "Servidores",
|
||||
"serverSingular": "Servidor",
|
||||
@@ -3518,20 +3475,6 @@
|
||||
},
|
||||
"title": "Configurações",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "O provedor não existe"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Idiomas suportados",
|
||||
"temp_tooltip": "No momento, apenas chinês e inglês são suportados."
|
||||
},
|
||||
"title": "Imagem"
|
||||
},
|
||||
"image_provider": "Provedor de serviços OCR",
|
||||
"title": "Serviço OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "prestador de serviços de pré-processamento de documentos",
|
||||
"provider_placeholder": "Escolha um fornecedor de pré-processamento de documentos",
|
||||
@@ -3772,22 +3715,12 @@
|
||||
"exchange": {
|
||||
"label": "Trocar idioma de origem e idioma de destino"
|
||||
},
|
||||
"files": {
|
||||
"drag_text": "Arraste e solte aqui",
|
||||
"error": {
|
||||
"multiple": "Não é permitido fazer upload de vários arquivos",
|
||||
"too_large": "Arquivo muito grande",
|
||||
"unknown": "Falha ao ler o conteúdo do arquivo"
|
||||
},
|
||||
"reading": "Lendo o conteúdo do arquivo..."
|
||||
},
|
||||
"history": {
|
||||
"clear": "Limpar Histórico",
|
||||
"clear_description": "Limpar histórico irá deletar todos os registros de tradução. Deseja continuar?",
|
||||
"delete": "Apagar histórico de traduções",
|
||||
"delete": "Excluir",
|
||||
"empty": "Nenhum histórico de tradução disponível",
|
||||
"error": {
|
||||
"delete": "Falha ao excluir",
|
||||
"save": "Falha ao guardar o histórico de traduções"
|
||||
},
|
||||
"search": {
|
||||
|
||||
@@ -346,14 +346,11 @@ const Container = styled.div`
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 0;
|
||||
`
|
||||
|
||||
const MainContent = styled.div`
|
||||
width: 600px;
|
||||
margin: auto;
|
||||
min-height: fit-content;
|
||||
`
|
||||
|
||||
const Title = styled.h1`
|
||||
|
||||
@@ -22,7 +22,7 @@ let _stack: Route[] = ['topics']
|
||||
let _topic: Topic | undefined
|
||||
let _message: Message | undefined
|
||||
|
||||
const HistoryPage: FC = () => {
|
||||
const TopicsPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState(_search)
|
||||
const [searchKeywords, setSearchKeywords] = useState(_search)
|
||||
@@ -52,12 +52,7 @@ const HistoryPage: FC = () => {
|
||||
setTopic(undefined)
|
||||
}
|
||||
|
||||
// topic 不包含 messages,用到的时候才会获取
|
||||
const onTopicClick = (topic: Topic | null | undefined) => {
|
||||
if (!topic) {
|
||||
window.message.error(t('history.error.topic_not_found'))
|
||||
return
|
||||
}
|
||||
const onTopicClick = (topic: Topic) => {
|
||||
setStack((prev) => [...prev, 'topic'])
|
||||
setTopic(topic)
|
||||
}
|
||||
@@ -91,7 +86,7 @@ const HistoryPage: FC = () => {
|
||||
</SearchIcon>
|
||||
)
|
||||
}
|
||||
suffix={search.length ? <CornerDownLeft size={16} /> : null}
|
||||
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
|
||||
ref={inputRef}
|
||||
placeholder={t('history.search.placeholder')}
|
||||
value={search}
|
||||
@@ -151,4 +146,4 @@ const SearchIcon = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default HistoryPage
|
||||
export default TopicsPage
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import db from '@renderer/databases'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { selectTopicsMap } from '@renderer/store/assistants'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { List, Spin, Typography } from 'antd'
|
||||
import { List, Typography } from 'antd'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { FC, memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { FC, memo, useCallback, useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
type SearchResult = {
|
||||
message: Message
|
||||
topic: Topic
|
||||
content: string
|
||||
}
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
keywords: string
|
||||
onMessageClick: (message: Message) => void
|
||||
@@ -26,7 +19,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => {
|
||||
const { handleScroll, containerRef } = useScrollPosition('SearchResults')
|
||||
const observerRef = useRef<MutationObserver | null>(null)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const [searchTerms, setSearchTerms] = useState<string[]>(
|
||||
keywords
|
||||
@@ -36,12 +29,9 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
)
|
||||
|
||||
const topics = useLiveQuery(() => db.topics.toArray(), [])
|
||||
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
|
||||
const storeTopicsMap = useSelector(selectTopicsMap)
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic; content: string }[]>([])
|
||||
const [searchStats, setSearchStats] = useState({ count: 0, time: 0 })
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const removeMarkdown = (text: string) => {
|
||||
return text
|
||||
@@ -56,40 +46,33 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
|
||||
const onSearch = useCallback(async () => {
|
||||
setSearchResults([])
|
||||
setIsLoading(true)
|
||||
|
||||
if (keywords.length === 0) {
|
||||
setSearchStats({ count: 0, time: 0 })
|
||||
setSearchTerms([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const results: { message: Message; topic: Topic; content: string }[] = []
|
||||
const newSearchTerms = keywords
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.filter((term) => term.length > 0)
|
||||
const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i'))
|
||||
|
||||
const blocks = (await db.message_blocks.toArray())
|
||||
const blocksArray = await db.message_blocks.toArray()
|
||||
const blocks = blocksArray
|
||||
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
.filter((block) => searchRegexes.some((regex) => regex.test(block.content)))
|
||||
.filter((block) => newSearchTerms.some((term) => block.content.toLowerCase().includes(term)))
|
||||
|
||||
const messages = topics?.flatMap((topic) => topic.messages)
|
||||
const messages = topics?.map((topic) => topic.messages).flat()
|
||||
|
||||
const results = await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
const message = messages?.find((message) => message.id === block.messageId)
|
||||
if (message) {
|
||||
const topic = storeTopicsMap.get(message.topicId)
|
||||
if (topic) {
|
||||
return { message, topic, content: block.content }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
).then((results) => results.filter(Boolean) as SearchResult[])
|
||||
for (const block of blocks) {
|
||||
const message = messages?.find((message) => message.id === block.messageId)
|
||||
if (message) {
|
||||
results.push({ message, topic: await getTopicById(message.topicId)!, content: block.content })
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now()
|
||||
setSearchResults(results)
|
||||
@@ -98,8 +81,7 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
time: (endTime - startTime) / 1000
|
||||
})
|
||||
setSearchTerms(newSearchTerms)
|
||||
setIsLoading(false)
|
||||
}, [keywords, storeTopicsMap, topics])
|
||||
}, [keywords, topics])
|
||||
|
||||
const highlightText = (text: string) => {
|
||||
let highlightedText = removeMarkdown(text)
|
||||
@@ -118,24 +100,9 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
onSearch()
|
||||
}, [onSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
observerRef.current = new MutationObserver(() => {
|
||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
})
|
||||
|
||||
observerRef.current.observe(containerRef.current, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
|
||||
return () => observerRef.current?.disconnect()
|
||||
}, [containerRef])
|
||||
|
||||
return (
|
||||
<Container ref={containerRef} {...props} onScroll={handleScroll}>
|
||||
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
|
||||
<ContainerWrapper>
|
||||
{searchResults.length > 0 && (
|
||||
<SearchStats>
|
||||
Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds
|
||||
@@ -146,15 +113,19 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
dataSource={searchResults}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
hideOnSinglePage: true
|
||||
onChange: () => {
|
||||
setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0)
|
||||
}
|
||||
}}
|
||||
style={{ opacity: isLoading ? 0 : 1 }}
|
||||
renderItem={({ message, topic, content }) => (
|
||||
<List.Item>
|
||||
<Title
|
||||
level={5}
|
||||
style={{ color: 'var(--color-primary)', cursor: 'pointer' }}
|
||||
onClick={() => onTopicClick(topic)}>
|
||||
onClick={async () => {
|
||||
const _topic = await getTopicById(topic.id)
|
||||
onTopicClick(_topic)
|
||||
}}>
|
||||
{topic.name}
|
||||
</Title>
|
||||
<div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}>
|
||||
@@ -167,17 +138,24 @@ const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...p
|
||||
)}
|
||||
/>
|
||||
<div style={{ minHeight: 30 }}></div>
|
||||
</Spin>
|
||||
</ContainerWrapper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px 36px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const ContainerWrapper = styled.div`
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
@@ -188,7 +166,6 @@ const SearchStats = styled.div`
|
||||
|
||||
const SearchResultTime = styled.div`
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
`
|
||||
|
||||
export default memo(SearchResults)
|
||||
|
||||
@@ -5,17 +5,18 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { classNames, runAsyncFunction } from '@renderer/utils'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Divider, Empty } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Forward } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { default as MessageItem } from '../../home/Messages/Message'
|
||||
@@ -24,22 +25,16 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
topic?: Topic
|
||||
}
|
||||
|
||||
const TopicMessages: FC<Props> = ({ topic: _topic, ...props }) => {
|
||||
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
|
||||
const dispatch = useAppDispatch()
|
||||
const { messageStyle } = useSettings()
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const [topic, setTopic] = useState<Topic | undefined>(_topic)
|
||||
|
||||
useEffect(() => {
|
||||
if (!_topic) return
|
||||
|
||||
runAsyncFunction(async () => {
|
||||
const topic = await getTopicById(_topic.id)
|
||||
setTopic(topic)
|
||||
})
|
||||
}, [_topic, topic])
|
||||
topic && dispatch(loadTopicMessagesThunk(topic.id))
|
||||
}, [dispatch, topic])
|
||||
|
||||
const isEmpty = (topic?.messages || []).length === 0
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { selectAllTopics } from '@renderer/store/assistants'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Button, Divider, Empty, Segmented } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { groupBy, isEmpty, orderBy } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
type SortType = 'createdAt' | 'updatedAt'
|
||||
@@ -20,18 +20,18 @@ type Props = {
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => {
|
||||
const { assistants } = useAssistants()
|
||||
const { t } = useTranslation()
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicsHistory')
|
||||
const [sortType, setSortType] = useState<SortType>('createdAt')
|
||||
|
||||
// FIXME: db 中没有 topic.name 等信息,只能从 store 获取
|
||||
const topics = useSelector(selectAllTopics)
|
||||
const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), sortType, 'desc')
|
||||
|
||||
const filteredTopics = topics.filter((topic) => {
|
||||
return topic.name.toLowerCase().includes(keywords.toLowerCase())
|
||||
})
|
||||
|
||||
const groupedTopics = groupBy(orderBy(filteredTopics, sortType, 'desc'), (topic) => {
|
||||
const groupedTopics = groupBy(filteredTopics, (topic) => {
|
||||
return dayjs(topic[sortType]).format('MM/DD')
|
||||
})
|
||||
|
||||
@@ -66,14 +66,19 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
|
||||
<Date>{date}</Date>
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
{items.map((topic) => (
|
||||
<TopicItem key={topic.id} onClick={() => onClick(topic)}>
|
||||
<TopicItem
|
||||
key={topic.id}
|
||||
onClick={async () => {
|
||||
const _topic = await getTopicById(topic.id)
|
||||
onClick(_topic)
|
||||
}}>
|
||||
<TopicName>{topic.name.substring(0, 50)}</TopicName>
|
||||
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
|
||||
</TopicItem>
|
||||
))}
|
||||
</ListItem>
|
||||
))}
|
||||
{keywords && (
|
||||
{keywords.length >= 2 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
|
||||
{t('history.search.messages')}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
||||
@@ -101,24 +100,20 @@ const HomePage: FC = () => {
|
||||
)}
|
||||
<ContentContainer id={isLeftNavbar ? 'content-container' : undefined}>
|
||||
{showAssistants && (
|
||||
<ErrorBoundary>
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position="left"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary>
|
||||
<Chat
|
||||
assistant={activeAssistant}
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position="left"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<Chat
|
||||
assistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
/>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { FileType } from '@renderer/types'
|
||||
import { filterSupportedFiles } from '@renderer/utils/file'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { FC, useCallback, useImperativeHandle, useState } from 'react'
|
||||
import { FC, useCallback, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
@@ -29,47 +28,22 @@ const AttachmentButton: FC<Props> = ({
|
||||
disabled
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [selecting, setSelecting] = useState<boolean>(false)
|
||||
|
||||
const onSelectFile = useCallback(async () => {
|
||||
if (selecting) {
|
||||
return
|
||||
}
|
||||
// when the number of extensions is greater than 20, use *.* to avoid selecting window lag
|
||||
const useAllFiles = extensions.length > 20
|
||||
|
||||
setSelecting(true)
|
||||
const _files = await window.api.file.select({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{
|
||||
name: 'Files',
|
||||
extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', ''))
|
||||
extensions: extensions.map((i) => i.replace('.', ''))
|
||||
}
|
||||
]
|
||||
})
|
||||
setSelecting(false)
|
||||
|
||||
if (_files) {
|
||||
if (!useAllFiles) {
|
||||
setFiles([...files, ..._files])
|
||||
return
|
||||
}
|
||||
const supportedFiles = await filterSupportedFiles(_files, extensions)
|
||||
if (supportedFiles.length > 0) {
|
||||
setFiles([...files, ...supportedFiles])
|
||||
}
|
||||
|
||||
if (supportedFiles.length !== _files.length) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported_count', {
|
||||
count: _files.length - supportedFiles.length
|
||||
})
|
||||
})
|
||||
}
|
||||
setFiles([...files, ..._files])
|
||||
}
|
||||
}, [extensions, files, selecting, setFiles, t])
|
||||
}, [extensions, files, setFiles])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
onSelectFile()
|
||||
|
||||
@@ -83,7 +83,7 @@ export const getFileIcon = (type?: string) => {
|
||||
export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => {
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const isImage = (ext: string) => {
|
||||
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext.toLocaleLowerCase())
|
||||
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
|
||||
}
|
||||
|
||||
const fullName = FileManager.formatFileName(file)
|
||||
|
||||
@@ -5,7 +5,6 @@ import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import {
|
||||
isGenerateImageModel,
|
||||
isGenerateImageModels,
|
||||
isMandatoryWebSearchModel,
|
||||
isSupportedDisableGenerationModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
@@ -38,7 +37,7 @@ import { setSearching } from '@renderer/store/runtime'
|
||||
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, filterSupportedFiles, formatFileSize } from '@renderer/utils'
|
||||
import { classNames, delay, formatFileSize } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import {
|
||||
getFilesFromDropEvent,
|
||||
@@ -111,6 +110,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
const startDragY = useRef<number>(0)
|
||||
const startHeight = useRef<number>(0)
|
||||
const currentMessageId = useRef<string>('')
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
|
||||
const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
|
||||
@@ -241,12 +241,17 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
baseUserMessage.mentions = mentionedModels
|
||||
}
|
||||
|
||||
const assistantWithTopicPrompt = topic.prompt
|
||||
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
|
||||
: assistant
|
||||
|
||||
baseUserMessage.usage = await estimateUserPromptUsage(baseUserMessage)
|
||||
|
||||
const { message, blocks } = getUserMessage(baseUserMessage)
|
||||
message.traceId = parent?.spanContext().traceId
|
||||
|
||||
dispatch(_sendMessage(message, blocks, assistant, topic.id))
|
||||
currentMessageId.current = message.id
|
||||
dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id))
|
||||
|
||||
// Clear input
|
||||
setText('')
|
||||
@@ -506,42 +511,30 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const cursorPosition = textArea?.selectionStart ?? 0
|
||||
const lastSymbol = newText[cursorPosition - 1]
|
||||
|
||||
// 触发符号为 '/':若当前未打开或符号不同,则切换/打开
|
||||
if (enableQuickPanelTriggers && lastSymbol === '/') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== '/') {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== '/') {
|
||||
const quickPanelMenu =
|
||||
inputbarToolsRef.current?.getQuickPanelMenu({
|
||||
t,
|
||||
files,
|
||||
couldAddImageFile,
|
||||
text: newText,
|
||||
openSelectFileMenu,
|
||||
translate
|
||||
}) || []
|
||||
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
|
||||
const quickPanelMenu =
|
||||
inputbarToolsRef.current?.getQuickPanelMenu({
|
||||
t,
|
||||
files,
|
||||
couldAddImageFile,
|
||||
text: newText,
|
||||
openSelectFileMenu,
|
||||
translate
|
||||
}) || []
|
||||
|
||||
quickPanel.open({
|
||||
title: t('settings.quickPanel.title'),
|
||||
list: quickPanelMenu,
|
||||
symbol: '/'
|
||||
})
|
||||
}
|
||||
quickPanel.open({
|
||||
title: t('settings.quickPanel.title'),
|
||||
list: quickPanelMenu,
|
||||
symbol: '/'
|
||||
})
|
||||
}
|
||||
|
||||
// 触发符号为 '@':若当前未打开或符号不同,则切换/打开
|
||||
if (enableQuickPanelTriggers && lastSymbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== '@') {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== '@') {
|
||||
inputbarToolsRef.current?.openMentionModelsPanel({
|
||||
type: 'input',
|
||||
position: cursorPosition - 1,
|
||||
originalText: newText
|
||||
})
|
||||
}
|
||||
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
|
||||
inputbarToolsRef.current?.openMentionModelsPanel({
|
||||
type: 'input',
|
||||
position: cursorPosition - 1,
|
||||
originalText: newText
|
||||
})
|
||||
}
|
||||
},
|
||||
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]
|
||||
@@ -592,20 +585,26 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
|
||||
setText(text + data)
|
||||
|
||||
const droppedFiles = await getFilesFromDropEvent(e).catch((err) => {
|
||||
const files = await getFilesFromDropEvent(e).catch((err) => {
|
||||
logger.error('handleDrop:', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (droppedFiles) {
|
||||
const supportedFiles = await filterSupportedFiles(droppedFiles, supportedExts)
|
||||
supportedFiles.length > 0 && setFiles((prevFiles) => [...prevFiles, ...supportedFiles])
|
||||
if (droppedFiles.length > 0 && supportedFiles.length !== droppedFiles.length) {
|
||||
if (files) {
|
||||
let supportedFiles = 0
|
||||
|
||||
files.forEach((file) => {
|
||||
if (supportedExts.includes(file.ext)) {
|
||||
setFiles((prevFiles) => [...prevFiles, file])
|
||||
supportedFiles++
|
||||
}
|
||||
})
|
||||
|
||||
// 如果有文件,但都不支持
|
||||
if (files.length > 0 && supportedFiles === 0) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported_count', {
|
||||
count: droppedFiles.length - supportedFiles.length
|
||||
})
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -774,10 +773,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
if (!isWebSearchModel(model) && assistant.enableWebSearch) {
|
||||
updateAssistant({ ...assistant, enableWebSearch: false })
|
||||
}
|
||||
if (
|
||||
assistant.webSearchProviderId &&
|
||||
(!WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId) || isMandatoryWebSearchModel(model))
|
||||
) {
|
||||
if (assistant.webSearchProviderId && !WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId)) {
|
||||
updateAssistant({ ...assistant, webSearchProviderId: undefined })
|
||||
}
|
||||
if (!isGenerateImageModel(model) && assistant.enableGenerateImage) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
|
||||
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import { isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models'
|
||||
import { isGenerateImageModel } from '@renderer/config/models'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
|
||||
@@ -340,8 +340,7 @@ const InputbarTools = ({
|
||||
{
|
||||
key: 'web_search',
|
||||
label: t('chat.input.web_search.label'),
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
|
||||
condition: !isMandatoryWebSearchModel(model)
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />
|
||||
},
|
||||
{
|
||||
key: 'url_context',
|
||||
|
||||
@@ -48,66 +48,6 @@ const MentionModelsButton: FC<Props> = ({
|
||||
|
||||
// 记录是否有模型被选择的动作发生
|
||||
const hasModelActionRef = useRef<boolean>(false)
|
||||
// 记录触发信息,用于清除操作
|
||||
const triggerInfoRef = useRef<{ type: 'input' | 'button'; position?: number; originalText?: string } | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
// 基于光标 + 搜索词定位并删除最近一次触发的 @ 及搜索文本
|
||||
const removeAtSymbolAndText = useCallback(
|
||||
(currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => {
|
||||
const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length))
|
||||
|
||||
// ESC/精确删除:优先按 pattern = "@" + searchText 从光标向左最近匹配
|
||||
if (searchText !== undefined) {
|
||||
const pattern = '@' + searchText
|
||||
const fromIndex = Math.max(0, safeCaret - 1)
|
||||
const start = currentText.lastIndexOf(pattern, fromIndex)
|
||||
if (start !== -1) {
|
||||
const end = start + pattern.length
|
||||
return currentText.slice(0, start) + currentText.slice(end)
|
||||
}
|
||||
|
||||
// 兜底:使用打开时的 position 做校验后再删
|
||||
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
||||
const expected = pattern
|
||||
const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length)
|
||||
if (actual === expected) {
|
||||
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length)
|
||||
}
|
||||
// 如果不完全匹配,安全起见仅删除单个 '@'
|
||||
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1)
|
||||
}
|
||||
|
||||
// 未找到匹配则不改动
|
||||
return currentText
|
||||
}
|
||||
|
||||
// 清除按钮:未知搜索词,删除离光标最近的 '@' 及后续连续非空白(到空格/换行/结尾)
|
||||
{
|
||||
const fromIndex = Math.max(0, safeCaret - 1)
|
||||
const start = currentText.lastIndexOf('@', fromIndex)
|
||||
if (start === -1) {
|
||||
// 兜底:使用打开时的 position(若存在),按空白边界删除
|
||||
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
||||
let endPos = fallbackPosition + 1
|
||||
while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') {
|
||||
endPos++
|
||||
}
|
||||
return currentText.slice(0, fallbackPosition) + currentText.slice(endPos)
|
||||
}
|
||||
return currentText
|
||||
}
|
||||
|
||||
let endPos = start + 1
|
||||
while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') {
|
||||
endPos++
|
||||
}
|
||||
return currentText.slice(0, start) + currentText.slice(endPos)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const pinnedModels = useLiveQuery(
|
||||
async () => {
|
||||
@@ -200,20 +140,9 @@ const MentionModelsButton: FC<Props> = ({
|
||||
label: t('settings.input.clear.all'),
|
||||
description: t('settings.input.clear.models'),
|
||||
icon: <CircleX />,
|
||||
alwaysVisible: true,
|
||||
isSelected: false,
|
||||
action: () => {
|
||||
onClearMentionModels()
|
||||
|
||||
// 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除)
|
||||
if (triggerInfoRef.current?.type === 'input') {
|
||||
setText((currentText) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||
return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position)
|
||||
})
|
||||
}
|
||||
|
||||
quickPanel.close()
|
||||
}
|
||||
})
|
||||
@@ -228,17 +157,13 @@ const MentionModelsButton: FC<Props> = ({
|
||||
onMentionModel,
|
||||
navigate,
|
||||
quickPanel,
|
||||
onClearMentionModels,
|
||||
setText,
|
||||
removeAtSymbolAndText
|
||||
onClearMentionModels
|
||||
])
|
||||
|
||||
const openQuickPanel = useCallback(
|
||||
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
|
||||
// 重置模型动作标记
|
||||
hasModelActionRef.current = false
|
||||
// 保存触发信息
|
||||
triggerInfoRef.current = triggerInfo
|
||||
|
||||
quickPanel.open({
|
||||
title: t('agents.edit.model.select.title'),
|
||||
@@ -258,11 +183,28 @@ const MentionModelsButton: FC<Props> = ({
|
||||
closeTriggerInfo?.type === 'input' &&
|
||||
closeTriggerInfo?.position !== undefined
|
||||
) {
|
||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
||||
// 使用React的setText来更新状态
|
||||
setText((currentText) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', closeTriggerInfo.position!)
|
||||
const position = closeTriggerInfo.position!
|
||||
// 验证位置的字符是否仍是 @
|
||||
if (currentText[position] !== '@') {
|
||||
return currentText
|
||||
}
|
||||
|
||||
// 计算删除范围:@ + searchText
|
||||
const deleteLength = 1 + (searchText?.length || 0)
|
||||
|
||||
// 验证要删除的内容是否匹配预期
|
||||
const expectedText = '@' + (searchText || '')
|
||||
const actualText = currentText.slice(position, position + deleteLength)
|
||||
|
||||
if (actualText !== expectedText) {
|
||||
// 如果实际文本不匹配,只删除 @ 字符
|
||||
return currentText.slice(0, position) + currentText.slice(position + 1)
|
||||
}
|
||||
|
||||
// 删除 @ 和搜索文本
|
||||
return currentText.slice(0, position) + currentText.slice(position + deleteLength)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -271,7 +213,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
}
|
||||
})
|
||||
},
|
||||
[modelItems, quickPanel, t, setText, removeAtSymbolAndText]
|
||||
[modelItems, quickPanel, t, setText]
|
||||
)
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
|
||||
@@ -77,14 +77,12 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
if (!isEnabled) {
|
||||
updateAssistantSettings({
|
||||
reasoning_effort: undefined,
|
||||
reasoning_effort_cache: undefined,
|
||||
qwenThinkMode: false
|
||||
})
|
||||
return
|
||||
}
|
||||
updateAssistantSettings({
|
||||
reasoning_effort: option,
|
||||
reasoning_effort_cache: option,
|
||||
qwenThinkMode: true
|
||||
})
|
||||
return
|
||||
|
||||
@@ -28,62 +28,48 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
const { providers } = useWebSearchProviders()
|
||||
const { updateAssistant } = useAssistant(assistant.id)
|
||||
|
||||
// 注意:assistant.enableWebSearch 有不同的语义
|
||||
/** 表示是否启用网络搜索 */
|
||||
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
|
||||
|
||||
const WebSearchIcon = useCallback(
|
||||
({ pid, size = 18, color }: { pid?: WebSearchProviderId; size?: number; color?: string }) => {
|
||||
({ pid, size = 18 }: { pid?: WebSearchProviderId; size?: number }) => {
|
||||
const iconColor = enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
|
||||
switch (pid) {
|
||||
case 'bocha':
|
||||
return <BochaLogo width={size} height={size} color={color} />
|
||||
return <BochaLogo width={size} height={size} color={iconColor} />
|
||||
case 'exa':
|
||||
// size微调,视觉上和其他图标平衡一些
|
||||
return <ExaLogo width={size - 2} height={size} color={color} />
|
||||
return <ExaLogo width={size - 2} height={size} color={iconColor} />
|
||||
case 'tavily':
|
||||
return <TavilyLogo width={size} height={size} color={color} />
|
||||
return <TavilyLogo width={size} height={size} color={iconColor} />
|
||||
case 'searxng':
|
||||
return <SearXNGLogo width={size} height={size} color={color} />
|
||||
return <SearXNGLogo width={size} height={size} color={iconColor} />
|
||||
case 'local-baidu':
|
||||
return <BaiduOutlined size={size} style={{ color, fontSize: size }} />
|
||||
return <BaiduOutlined size={size} style={{ color: iconColor, fontSize: size }} />
|
||||
case 'local-bing':
|
||||
return <BingLogo width={size} height={size} color={color} />
|
||||
return <BingLogo width={size} height={size} color={iconColor} />
|
||||
case 'local-google':
|
||||
return <GoogleOutlined size={size} style={{ color, fontSize: size }} />
|
||||
return <GoogleOutlined size={size} style={{ color: iconColor, fontSize: size }} />
|
||||
default:
|
||||
return <Globe size={size} style={{ color, fontSize: size }} />
|
||||
return <Globe size={size} style={{ color: iconColor, fontSize: size }} />
|
||||
}
|
||||
},
|
||||
[enableWebSearch]
|
||||
)
|
||||
|
||||
const updateWebSearchProvider = useCallback(
|
||||
const updateSelectedWebSearchProvider = useCallback(
|
||||
async (providerId?: WebSearchProvider['id']) => {
|
||||
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
|
||||
const currentWebSearchProviderId = assistant.webSearchProviderId
|
||||
const newWebSearchProviderId = currentWebSearchProviderId === providerId ? undefined : providerId
|
||||
startTransition(() => {
|
||||
updateAssistant({
|
||||
...assistant,
|
||||
webSearchProviderId: providerId,
|
||||
enableWebSearch: false
|
||||
})
|
||||
updateAssistant({ ...assistant, webSearchProviderId: newWebSearchProviderId, enableWebSearch: false })
|
||||
})
|
||||
},
|
||||
[assistant, updateAssistant]
|
||||
)
|
||||
|
||||
const updateQuickPanelItem = useCallback(
|
||||
async (providerId?: WebSearchProvider['id']) => {
|
||||
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
|
||||
if (providerId === assistant.webSearchProviderId) {
|
||||
updateWebSearchProvider(undefined)
|
||||
} else {
|
||||
updateWebSearchProvider(providerId)
|
||||
}
|
||||
},
|
||||
[assistant.webSearchProviderId, updateWebSearchProvider]
|
||||
)
|
||||
|
||||
const updateToModelBuiltinWebSearch = useCallback(async () => {
|
||||
const updateSelectedWebSearchBuiltin = useCallback(async () => {
|
||||
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
|
||||
startTransition(() => {
|
||||
updateAssistant({ ...assistant, webSearchProviderId: undefined, enableWebSearch: !assistant.enableWebSearch })
|
||||
@@ -104,7 +90,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
icon: <WebSearchIcon size={13} pid={p.id} />,
|
||||
isSelected: p.id === assistant?.webSearchProviderId,
|
||||
disabled: !WebSearchService.isWebSearchEnabled(p.id),
|
||||
action: () => updateQuickPanelItem(p.id)
|
||||
action: () => updateSelectedWebSearchProvider(p.id)
|
||||
}))
|
||||
.filter((o) => !o.disabled)
|
||||
|
||||
@@ -117,7 +103,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
icon: <Globe />,
|
||||
isSelected: assistant.enableWebSearch,
|
||||
disabled: !isWebSearchModelEnabled,
|
||||
action: () => updateToModelBuiltinWebSearch()
|
||||
action: () => updateSelectedWebSearchBuiltin()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,18 +115,36 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
assistant?.webSearchProviderId,
|
||||
providers,
|
||||
t,
|
||||
updateQuickPanelItem,
|
||||
updateToModelBuiltinWebSearch
|
||||
updateSelectedWebSearchBuiltin,
|
||||
updateSelectedWebSearchProvider
|
||||
])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
if (assistant.webSearchProviderId) {
|
||||
updateSelectedWebSearchProvider(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (assistant.enableWebSearch) {
|
||||
updateSelectedWebSearchBuiltin()
|
||||
return
|
||||
}
|
||||
|
||||
quickPanel.open({
|
||||
title: t('chat.input.web_search.label'),
|
||||
list: providerItems,
|
||||
symbol: '?',
|
||||
pageSize: 9
|
||||
})
|
||||
}, [quickPanel, t, providerItems])
|
||||
}, [
|
||||
assistant.webSearchProviderId,
|
||||
assistant.enableWebSearch,
|
||||
quickPanel,
|
||||
t,
|
||||
providerItems,
|
||||
updateSelectedWebSearchProvider,
|
||||
updateSelectedWebSearchBuiltin
|
||||
])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '?') {
|
||||
@@ -150,28 +154,18 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (enableWebSearch) {
|
||||
updateWebSearchProvider(undefined)
|
||||
} else {
|
||||
handleOpenQuickPanel()
|
||||
}
|
||||
}, [enableWebSearch, handleOpenQuickPanel, updateWebSearchProvider])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
const color = enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onClick}>
|
||||
<WebSearchIcon color={color} pid={assistant.webSearchProviderId} />
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<WebSearchIcon pid={assistant.webSearchProviderId} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import store from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { getCodeBlockId, isOpenFenceBlock } from '@renderer/utils/markdown'
|
||||
import { getCodeBlockId } from '@renderer/utils/markdown'
|
||||
import type { Node } from 'mdast'
|
||||
import React, { memo, useCallback, useMemo } from 'react'
|
||||
|
||||
@@ -16,9 +16,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
|
||||
const languageMatch = /language-([\w-+]+)/.exec(className || '')
|
||||
const isMultiline = children?.includes('\n')
|
||||
const language = languageMatch?.[1] ?? (isMultiline ? 'text' : null)
|
||||
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n')
|
||||
const language = match?.[1] ?? 'text'
|
||||
|
||||
// 代码块 id
|
||||
const id = useMemo(() => getCodeBlockId(node?.position?.start), [node?.position?.start])
|
||||
@@ -40,11 +39,11 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
|
||||
[blockId, id]
|
||||
)
|
||||
|
||||
if (language !== null) {
|
||||
if (match) {
|
||||
// HTML 代码块特殊处理
|
||||
// FIXME: 感觉没有必要用 isHtmlCode 判断
|
||||
if (language === 'html') {
|
||||
const isOpenFence = isOpenFenceBlock(children?.length, languageMatch?.[1]?.length, node?.position)
|
||||
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming && isOpenFence} />
|
||||
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmpty, omit } from 'lodash'
|
||||
import { omit } from 'lodash'
|
||||
import React from 'react'
|
||||
import type { Node } from 'unist'
|
||||
|
||||
@@ -33,7 +33,6 @@ const Link: React.FC<LinkProps> = (props) => {
|
||||
<CitationTooltip citation={props.citationData}>
|
||||
<a
|
||||
{...omit(props, ['node', 'citationData'])}
|
||||
href={isEmpty(props.href) ? undefined : props.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -10,7 +10,6 @@ const mocks = vi.hoisted(() => ({
|
||||
emit: vi.fn()
|
||||
},
|
||||
getCodeBlockId: vi.fn(),
|
||||
isOpenFenceBlock: vi.fn(),
|
||||
selectById: vi.fn(),
|
||||
CodeBlockView: vi.fn(({ onSave, children }) => (
|
||||
<div>
|
||||
@@ -37,8 +36,7 @@ vi.mock('@renderer/services/EventService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/markdown', () => ({
|
||||
getCodeBlockId: mocks.getCodeBlockId,
|
||||
isOpenFenceBlock: mocks.isOpenFenceBlock
|
||||
getCodeBlockId: mocks.getCodeBlockId
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
@@ -76,7 +74,6 @@ describe('CodeBlock', () => {
|
||||
vi.clearAllMocks()
|
||||
// Default mock return values
|
||||
mocks.getCodeBlockId.mockReturnValue('test-code-block-id')
|
||||
mocks.isOpenFenceBlock.mockReturnValue(false)
|
||||
mocks.selectById.mockReturnValue({
|
||||
id: 'test-msg-block-id',
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
|
||||
@@ -100,8 +100,11 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
const handleEditResend = useCallback(
|
||||
async (blocks: MessageBlock[]) => {
|
||||
const assistantWithTopicPrompt = topic.prompt
|
||||
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
|
||||
: assistant
|
||||
try {
|
||||
await resendUserMessageWithEdit(message, blocks, assistant)
|
||||
await resendUserMessageWithEdit(message, blocks, assistantWithTopicPrompt)
|
||||
stopEditing()
|
||||
} catch (error) {
|
||||
logger.error('Failed to resend message:', error as Error)
|
||||
|
||||
@@ -150,7 +150,10 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const handleResendUserMessage = useCallback(
|
||||
async (messageUpdate?: Message) => {
|
||||
if (!loading) {
|
||||
await resendMessage(messageUpdate ?? message, assistant)
|
||||
const assistantWithTopicPrompt = topic.prompt
|
||||
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
|
||||
: assistant
|
||||
await resendMessage(messageUpdate ?? message, assistantWithTopicPrompt)
|
||||
}
|
||||
},
|
||||
[assistant, loading, message, resendMessage, topic.prompt]
|
||||
@@ -376,8 +379,12 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
// const _message = resetAssistantMessage(message, selectedModel)
|
||||
// editMessage(message.id, { ..._message }) // REMOVED
|
||||
|
||||
const assistantWithTopicPrompt = topic.prompt
|
||||
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
|
||||
: assistant
|
||||
|
||||
// Call the function from the hook
|
||||
regenerateAssistantMessage(message, assistant)
|
||||
regenerateAssistantMessage(message, assistantWithTopicPrompt)
|
||||
}
|
||||
|
||||
// 按条件筛选能够提及的模型,该函数仅在isAssistantMessage时会用到
|
||||
|
||||
@@ -72,7 +72,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
|
||||
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
||||
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
|
||||
const { translateLanguages } = useTranslate()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -222,19 +222,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
key: 'rename',
|
||||
icon: <EditIcon size={14} />,
|
||||
disabled: isRenaming(topic.id),
|
||||
async onClick() {
|
||||
const name = await PromptPopup.show({
|
||||
title: t('chat.topics.edit.title'),
|
||||
message: '',
|
||||
defaultValue: topic?.name || '',
|
||||
extraNode: (
|
||||
<div style={{ color: 'var(--color-text-3)', marginTop: 8 }}>{t('chat.topics.edit.title_tip')}</div>
|
||||
)
|
||||
})
|
||||
if (name && topic?.name !== name) {
|
||||
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
|
||||
updateTopic(updatedTopic)
|
||||
}
|
||||
onClick() {
|
||||
setEditingTopicId(topic.id)
|
||||
topicEdit.startEdit(topic.name)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -458,6 +448,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
assistants,
|
||||
assistant,
|
||||
updateTopic,
|
||||
topicEdit,
|
||||
activeTopic.id,
|
||||
setActiveTopic,
|
||||
onPinTopic,
|
||||
@@ -528,13 +519,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<TopicName
|
||||
className={getTopicNameClassName()}
|
||||
title={topicName}
|
||||
onDoubleClick={() => {
|
||||
setEditingTopicId(topic.id)
|
||||
topicEdit.startEdit(topic.name)
|
||||
}}>
|
||||
<TopicName className={getTopicNameClassName()} title={topicName}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
)}
|
||||
@@ -542,10 +527,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
mouseEnterDelay={0.7}
|
||||
mouseLeaveDelay={0}
|
||||
title={
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
||||
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
||||
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<MenuButton
|
||||
@@ -560,9 +546,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
}
|
||||
}}>
|
||||
{deletingTopicId === topic.id ? (
|
||||
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
|
||||
<DeleteIcon size={14} color="var(--color-error)" />
|
||||
) : (
|
||||
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
|
||||
<XIcon size={14} color="var(--color-text-3)" />
|
||||
)}
|
||||
</MenuButton>
|
||||
</Tooltip>
|
||||
@@ -636,7 +622,6 @@ const TopicNameContainer = styled.div`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 20px;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
@@ -690,14 +675,19 @@ const TopicName = styled.div`
|
||||
|
||||
const TopicEditInput = styled.input`
|
||||
background: var(--color-background);
|
||||
border: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-1);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
padding: 2px 6px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
`
|
||||
|
||||
const PendingIndicator = styled.div.attrs({
|
||||
|
||||
@@ -137,10 +137,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['contextMenu']}
|
||||
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
|
||||
<AssistantNameRow className="name" title={fullAssistantName}>
|
||||
{assistantIconType === 'model' ? (
|
||||
@@ -389,6 +386,7 @@ const Container = styled.div`
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--color-list-item-hover);
|
||||
}
|
||||
|
||||
@@ -158,13 +158,9 @@ const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
||||
<a href={`http://file/${item.file.name}`} target="_blank" rel="noreferrer">
|
||||
{item.file.origin_name}
|
||||
</a>
|
||||
) : item.metadata.type !== 'LocalPathLoader' ? (
|
||||
<a href={item.metadata.source} target="_blank" rel="noreferrer">
|
||||
{item.metadata.source}
|
||||
</a>
|
||||
) : (
|
||||
// 处理预处理后的文件source
|
||||
<a href={`file://${item.metadata.source}`} target="_blank" rel="noreferrer">
|
||||
// item.metadata.source
|
||||
<a href={`http://file/${item.metadata.source}`} target="_blank" rel="noreferrer">
|
||||
{item.metadata.source.split('/').pop() || item.metadata.source}
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -286,7 +286,6 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('prompt', prompt)
|
||||
formData.append('model', painting.model)
|
||||
if (painting.background && painting.background !== 'auto') {
|
||||
formData.append('background', painting.background)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
|
||||
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
|
||||
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
|
||||
const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt')
|
||||
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
|
||||
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { setImageOcrProvider } from '@renderer/store/ocr'
|
||||
import { isImageOcrProvider, OcrProvider } from '@renderer/types'
|
||||
import { Select } from 'antd'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
import { SettingRow, SettingRowTitle } from '..'
|
||||
|
||||
const logger = loggerService.withContext('OcrImageSettings')
|
||||
|
||||
type Props = {
|
||||
setProvider: (provider: OcrProvider) => void
|
||||
}
|
||||
|
||||
const OcrImageSettings = ({ setProvider }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const providers = useAppSelector((state) => state.ocr.providers)
|
||||
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
|
||||
const imageProviders = providers.filter((p) => isImageOcrProvider(p))
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// 挂载时更新外部状态
|
||||
useEffect(() => {
|
||||
setProvider(imageProvider)
|
||||
}, [imageProvider, setProvider])
|
||||
|
||||
const updateImageProvider = (id: string) => {
|
||||
const provider = imageProviders.find((p) => p.id === id)
|
||||
if (!provider) {
|
||||
logger.error(`Failed to find image provider by id: ${id}`)
|
||||
window.message.error(t('settings.tool.ocr.image.error.provider_not_found'))
|
||||
return
|
||||
}
|
||||
|
||||
setProvider(provider)
|
||||
dispatch(setImageOcrProvider(provider))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Select
|
||||
value={imageProvider.id}
|
||||
style={{ width: '200px' }}
|
||||
onChange={(id: string) => updateImageProvider(id)}
|
||||
options={imageProviders.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OcrImageSettings
|
||||
@@ -1,52 +0,0 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { isBuiltinOcrProvider, OcrProvider } from '@renderer/types'
|
||||
import { getOcrProviderLogo } from '@renderer/utils/ocr'
|
||||
import { Avatar, Divider, Flex } from 'antd'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingTitle } from '..'
|
||||
import { OcrTesseractSettings } from './OcrTesseractSettings'
|
||||
|
||||
// const logger = loggerService.withContext('OcrTesseractSettings')
|
||||
|
||||
type Props = {
|
||||
provider: OcrProvider
|
||||
}
|
||||
|
||||
const OcrProviderSettings = ({ provider }: Props) => {
|
||||
// const { t } = useTranslation()
|
||||
const getProviderSettings = () => {
|
||||
if (isBuiltinOcrProvider(provider)) {
|
||||
switch (provider.id) {
|
||||
case 'tesseract':
|
||||
return <OcrTesseractSettings />
|
||||
}
|
||||
} else {
|
||||
throw new Error('Not supported OCR provider')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTitle>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderLogo shape="square" src={getOcrProviderLogo(provider.id)} size={16} />
|
||||
<ProviderName> {provider.name}</ProviderName>
|
||||
</Flex>
|
||||
</SettingTitle>
|
||||
<Divider style={{ width: '100%', margin: '10px 0' }} />
|
||||
<ErrorBoundary>{getProviderSettings()}</ErrorBoundary>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
export default OcrProviderSettings
|
||||
@@ -1,42 +0,0 @@
|
||||
import { PictureOutlined } from '@ant-design/icons'
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { OcrProvider } from '@renderer/types'
|
||||
import { Tabs, TabsProps } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||
import OcrImageSettings from './OcrImageSettings'
|
||||
import OcrProviderSettings from './OcrProviderSettings'
|
||||
|
||||
const OcrSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme: themeMode } = useTheme()
|
||||
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
|
||||
const [provider, setProvider] = useState<OcrProvider>(imageProvider) // since default to image provider
|
||||
|
||||
const tabs: TabsProps['items'] = [
|
||||
{
|
||||
key: 'image',
|
||||
label: t('settings.tool.ocr.image.title'),
|
||||
icon: <PictureOutlined />,
|
||||
children: <OcrImageSettings setProvider={setProvider} />
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SettingGroup theme={themeMode}>
|
||||
<SettingTitle>{t('settings.tool.ocr.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<Tabs defaultActiveKey="image" items={tabs} />
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={themeMode}>
|
||||
<OcrProviderSettings provider={provider} />
|
||||
</SettingGroup>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
export default OcrSettings
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user