diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..f530d6e3bf
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,86 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ open-pull-requests-limit: 7
+ target-branch: "main"
+ commit-message:
+ prefix: "chore"
+ include: "scope"
+ groups:
+ # 核心框架
+ core-framework:
+ patterns:
+ - "react"
+ - "react-dom"
+ - "electron"
+ - "typescript"
+ - "@types/react*"
+ - "@types/node"
+ update-types:
+ - "minor"
+ - "patch"
+
+ # Electron 生态和构建工具
+ electron-build:
+ patterns:
+ - "electron-*"
+ - "@electron*"
+ - "vite"
+ - "@vitejs/*"
+ - "dotenv-cli"
+ - "rollup-plugin-*"
+ - "@swc/*"
+ update-types:
+ - "minor"
+ - "patch"
+
+ # 测试工具
+ testing-tools:
+ patterns:
+ - "vitest"
+ - "@vitest/*"
+ - "playwright"
+ - "@playwright/*"
+ - "eslint*"
+ - "@eslint*"
+ - "prettier"
+ - "husky"
+ - "lint-staged"
+ update-types:
+ - "minor"
+ - "patch"
+
+ # CherryStudio 自定义包
+ cherrystudio-packages:
+ patterns:
+ - "@cherrystudio/*"
+ update-types:
+ - "minor"
+ - "patch"
+
+ # 兜底其他 dependencies
+ other-dependencies:
+ dependency-type: "production"
+
+ # 兜底其他 devDependencies
+ other-dev-dependencies:
+ dependency-type: "development"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 3
+ commit-message:
+ prefix: "ci"
+ include: "scope"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
+ update-types:
+ - "minor"
+ - "patch"
diff --git a/.github/workflows/issue-management.yml b/.github/workflows/issue-management.yml
index f79b31fd92..59faedc04e 100644
--- a/.github/workflows/issue-management.yml
+++ b/.github/workflows/issue-management.yml
@@ -54,5 +54,5 @@ jobs:
days-before-pr-close: -1 # Completely disable closing for PRs
# Temporary to reduce the huge issues number
- operations-per-run: 100
+ operations-per-run: 1000
debug-only: false
diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml
index 0a993f7963..72153a74c2 100644
--- a/.github/workflows/nightly-build.yml
+++ b/.github/workflows/nightly-build.yml
@@ -52,6 +52,8 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
+ with:
+ ref: main
- name: Install Node.js
uses: actions/setup-node@v4
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index df32ff7133..3200140f77 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -5,6 +5,7 @@ on:
pull_request:
branches:
- main
+ - develop
jobs:
build:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b8b1731d05..c3ab3d803d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,6 +26,8 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
+ with:
+ ref: main
- name: Get release tag
id: get-tag
@@ -111,5 +113,5 @@ jobs:
allowUpdates: true
makeLatest: false
tag: ${{ steps.get-tag.outputs.tag }}
- artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
+ artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 459dc6201c..f0986c32b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,9 +45,15 @@ stats.html
local
.aider*
.cursorrules
-.cursor/rules
+.cursor/*
-# test
+# vitest
coverage
.vitest-cache
vitest.config.*.timestamp-*
+
+# playwright
+playwright-report
+test-results
+
+YOUR_MEMORY_FILE_PATH
diff --git a/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch b/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch
new file mode 100644
index 0000000000..e1258fcb35
--- /dev/null
+++ b/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch
@@ -0,0 +1,71 @@
+diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs
+index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644
+--- a/dist/utils/tiktoken.cjs
++++ b/dist/utils/tiktoken.cjs
+@@ -1,25 +1,14 @@
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.encodingForModel = exports.getEncoding = void 0;
+-const lite_1 = require("js-tiktoken/lite");
+ const async_caller_js_1 = require("./async_caller.cjs");
+ const cache = {};
+ const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({});
+ async function getEncoding(encoding) {
+- if (!(encoding in cache)) {
+- cache[encoding] = caller
+- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
+- .then((res) => res.json())
+- .then((data) => new lite_1.Tiktoken(data))
+- .catch((e) => {
+- delete cache[encoding];
+- throw e;
+- });
+- }
+- return await cache[encoding];
++ throw new Error("TikToken Not implemented");
+ }
+ exports.getEncoding = getEncoding;
+ async function encodingForModel(model) {
+- return getEncoding((0, lite_1.getEncodingNameForModel)(model));
++ throw new Error("TikToken Not implemented");
+ }
+ exports.encodingForModel = encodingForModel;
+diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js
+index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644
+--- a/dist/utils/tiktoken.js
++++ b/dist/utils/tiktoken.js
+@@ -1,20 +1,9 @@
+-import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite";
+ import { AsyncCaller } from "./async_caller.js";
+ const cache = {};
+ const caller = /* #__PURE__ */ new AsyncCaller({});
+ export async function getEncoding(encoding) {
+- if (!(encoding in cache)) {
+- cache[encoding] = caller
+- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
+- .then((res) => res.json())
+- .then((data) => new Tiktoken(data))
+- .catch((e) => {
+- delete cache[encoding];
+- throw e;
+- });
+- }
+- return await cache[encoding];
++ throw new Error("TikToken Not implemented");
+ }
+ export async function encodingForModel(model) {
+- return getEncoding(getEncodingNameForModel(model));
++ throw new Error("TikToken Not implemented");
+ }
+diff --git a/package.json b/package.json
+index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644
+--- a/package.json
++++ b/package.json
+@@ -37,7 +37,6 @@
+ "ansi-styles": "^5.0.0",
+ "camelcase": "6",
+ "decamelize": "1.2.0",
+- "js-tiktoken": "^1.0.12",
+ "langsmith": ">=0.2.8 <0.4.0",
+ "mustache": "^4.2.0",
+ "p-queue": "^6.6.2",
diff --git a/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch b/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch
deleted file mode 100644
index 81b3ab5f78..0000000000
--- a/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch
+++ /dev/null
@@ -1,92 +0,0 @@
-diff --git a/out/electron/ElectronFramework.js b/out/electron/ElectronFramework.js
-index 5a4b4546870ee9e770d5a50d79790d39baabd268..3f0ac05dfd6bbaeaf5f834341a823718bd10f55c 100644
---- a/out/electron/ElectronFramework.js
-+++ b/out/electron/ElectronFramework.js
-@@ -55,26 +55,27 @@ async function removeUnusedLanguagesIfNeeded(options) {
- if (!wantedLanguages.length) {
- return;
- }
-- const { dir, langFileExt } = getLocalesConfig(options);
-+ const { dirs, langFileExt } = getLocalesConfig(options);
- // noinspection SpellCheckingInspection
-- await (0, tiny_async_pool_1.default)(builder_util_1.MAX_FILE_REQUESTS, await (0, fs_extra_1.readdir)(dir), async (file) => {
-- if (!file.endsWith(langFileExt)) {
-+ const deletedFiles = async (dir) => {
-+ await (0, tiny_async_pool_1.default)(builder_util_1.MAX_FILE_REQUESTS, await (0, fs_extra_1.readdir)(dir), async (file) => {
-+ if (!file.endsWith(langFileExt)) {
-+ return;
-+ }
-+ const language = file.substring(0, file.length - langFileExt.length);
-+ if (!wantedLanguages.includes(language)) {
-+ return fs.rm(path.join(dir, file), { recursive: true, force: true });
-+ }
- return;
-- }
-- const language = file.substring(0, file.length - langFileExt.length);
-- if (!wantedLanguages.includes(language)) {
-- return fs.rm(path.join(dir, file), { recursive: true, force: true });
-- }
-- return;
-- });
-+ });
-+ };
-+ await Promise.all(dirs.map(deletedFiles));
- function getLocalesConfig(options) {
- const { appOutDir, packager } = options;
- if (packager.platform === index_1.Platform.MAC) {
-- return { dir: packager.getResourcesDir(appOutDir), langFileExt: ".lproj" };
-- }
-- else {
-- return { dir: path.join(packager.getResourcesDir(appOutDir), "..", "locales"), langFileExt: ".pak" };
-+ return { dirs: [packager.getResourcesDir(appOutDir), packager.getMacOsElectronFrameworkResourcesDir(appOutDir)], langFileExt: ".lproj" };
- }
-+ return { dirs: [path.join(packager.getResourcesDir(appOutDir), "..", "locales")], langFileExt: ".pak" };
- }
- }
- class ElectronFramework {
-diff --git a/out/node-module-collector/index.d.ts b/out/node-module-collector/index.d.ts
-index 8e808be0fa0d5971b9f9605c8eb88f71630e34b7..1b97dccd8a150a67c4312d2ba4757960e624045b 100644
---- a/out/node-module-collector/index.d.ts
-+++ b/out/node-module-collector/index.d.ts
-@@ -2,6 +2,6 @@ import { NpmNodeModulesCollector } from "./npmNodeModulesCollector";
- import { PnpmNodeModulesCollector } from "./pnpmNodeModulesCollector";
- import { detect, PM, getPackageManagerVersion } from "./packageManager";
- import { NodeModuleInfo } from "./types";
--export declare function getCollectorByPackageManager(rootDir: string): Promise;
-+export declare function getCollectorByPackageManager(rootDir: string): Promise;
- export declare function getNodeModules(rootDir: string): Promise;
- export { detect, getPackageManagerVersion, PM };
-diff --git a/out/platformPackager.d.ts b/out/platformPackager.d.ts
-index 2df1ba2725c54c7b0e8fed67ab52e94f0cdb17bc..c7ff756564cfd216d2c7d8f72f367527010c06f9 100644
---- a/out/platformPackager.d.ts
-+++ b/out/platformPackager.d.ts
-@@ -67,6 +67,7 @@ export declare abstract class PlatformPackager !isArm64(file));
- }
-- const zipFileInfo = (0, Provider_1.findFile)(files, "zip", ["pkg", "dmg"]);
-+ const zipFileInfo = (0, Provider_1.findFile)(files, "zip", ["pkg", "dmg"], false /*has been filtered by myself*/);
- if (zipFileInfo == null) {
- throw (0, builder_util_runtime_1.newError)(`ZIP file not provided: ${(0, builder_util_runtime_1.safeStringifyJson)(files)}`, "ERR_UPDATER_ZIP_FILE_NOT_FOUND");
- }
-diff --git a/out/providers/Provider.js b/out/providers/Provider.js
-index 9829dff7e95aa9baa0bfdf29f52e6f761c9b7243..6ecaade9e294c87c03bb42e77ff5463f2782cb3c 100644
---- a/out/providers/Provider.js
-+++ b/out/providers/Provider.js
-@@ -61,11 +61,18 @@ class Provider {
- }
- }
- exports.Provider = Provider;
--function findFile(files, extension, not) {
-+function findFile(files, extension, not, filterByArch = true) {
- if (files.length === 0) {
- throw (0, builder_util_runtime_1.newError)("No files provided", "ERR_UPDATER_NO_FILES_PROVIDED");
- }
-- const result = files.find(it => it.url.pathname.toLowerCase().endsWith(`.${extension.toLowerCase()}`));
-+ const result = files
-+ .filter(file => {
-+ if (!filterByArch) {
-+ return true;
-+ }
-+ return (process.arch == "arm64") === (file.url.pathname.includes("arm64") || file.info.url.includes("arm64"));
-+ })
-+ .find(it => it.url.pathname.toLowerCase().endsWith(`.${extension.toLowerCase()}`));
- if (result != null) {
- return result;
- }
-diff --git a/out/differentialDownloader/multipleRangeDownloader.js b/out/differentialDownloader/multipleRangeDownloader.js
-index bf7d3a2982c62b94054fed4ef60455b20b26d622..3a924eddc946ec446654a112a33be4e2cea311d2 100644
---- a/out/differentialDownloader/multipleRangeDownloader.js
-+++ b/out/differentialDownloader/multipleRangeDownloader.js
-@@ -75,7 +75,7 @@ function doExecuteTasks(differentialDownloader, options, out, resolve, reject) {
- return;
- }
- const contentType = (0, builder_util_runtime_1.safeGetHeader)(response, "content-type");
-- const m = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i.exec(contentType);
-+ const m = /^multipart\/.+?\s*;\s*boundary=(?:"([^"]+)"|([^\s";]+))\s*$/i.exec(contentType);
- if (m == null) {
- reject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`));
- return;
diff --git a/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch b/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch
deleted file mode 100644
index 9970972523..0000000000
--- a/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch
+++ /dev/null
@@ -1,39 +0,0 @@
-diff --git a/core.js b/core.js
-index ebb071d31cd5a14792b62814df072c5971e83300..31e1062d4a7f2422ffec79cf96a35dbb69fe89cb 100644
---- a/core.js
-+++ b/core.js
-@@ -157,7 +157,7 @@ class APIClient {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'User-Agent': this.getUserAgent(),
-- ...getPlatformHeaders(),
-+ // ...getPlatformHeaders(),
- ...this.authHeaders(opts),
- };
- }
-diff --git a/core.mjs b/core.mjs
-index 9c1a0264dcd73a85de1cf81df4efab9ce9ee2ab7..33f9f1f237f2eb2667a05dae1a7e3dc916f6bfff 100644
---- a/core.mjs
-+++ b/core.mjs
-@@ -150,7 +150,7 @@ export class APIClient {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'User-Agent': this.getUserAgent(),
-- ...getPlatformHeaders(),
-+ // ...getPlatformHeaders(),
- ...this.authHeaders(opts),
- };
- }
-diff --git a/error.mjs b/error.mjs
-index 7d19f5578040afa004bc887aab1725e8703d2bac..59ec725b6142299a62798ac4bdedb63ba7d9932c 100644
---- a/error.mjs
-+++ b/error.mjs
-@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
- if (!status || !headers) {
- return new APIConnectionError({ message, cause: castToError(errorResponse) });
- }
-- const error = errorResponse?.['error'];
-+ const error = errorResponse?.['error'] || errorResponse;
- if (status === 400) {
- return new BadRequestError(status, error, message, headers);
- }
diff --git a/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch b/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
new file mode 100644
index 0000000000..dbf07cb475
--- /dev/null
+++ b/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
@@ -0,0 +1,279 @@
+diff --git a/client.js b/client.js
+index 33b4ff6309d5f29187dab4e285d07dac20340bab..8f568637ee9e4677585931fb0284c8165a933f69 100644
+--- a/client.js
++++ b/client.js
+@@ -433,7 +433,7 @@ class OpenAI {
+ 'User-Agent': this.getUserAgent(),
+ 'X-Stainless-Retry-Count': String(retryCount),
+ ...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
+- ...(0, detect_platform_1.getPlatformHeaders)(),
++ // ...(0, detect_platform_1.getPlatformHeaders)(),
+ 'OpenAI-Organization': this.organization,
+ 'OpenAI-Project': this.project,
+ },
+diff --git a/client.mjs b/client.mjs
+index c34c18213073540ebb296ea540b1d1ad39527906..1ce1a98256d7e90e26ca963582f235b23e996e73 100644
+--- a/client.mjs
++++ b/client.mjs
+@@ -430,7 +430,7 @@ export class OpenAI {
+ 'User-Agent': this.getUserAgent(),
+ 'X-Stainless-Retry-Count': String(retryCount),
+ ...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
+- ...getPlatformHeaders(),
++ // ...getPlatformHeaders(),
+ 'OpenAI-Organization': this.organization,
+ 'OpenAI-Project': this.project,
+ },
+diff --git a/core/error.js b/core/error.js
+index a12d9d9ccd242050161adeb0f82e1b98d9e78e20..fe3a5462480558bc426deea147f864f12b36f9bd 100644
+--- a/core/error.js
++++ b/core/error.js
+@@ -40,7 +40,7 @@ class APIError extends OpenAIError {
+ if (!status || !headers) {
+ return new APIConnectionError({ message, cause: (0, errors_1.castToError)(errorResponse) });
+ }
+- const error = errorResponse?.['error'];
++ const error = errorResponse?.['error'] || errorResponse;
+ if (status === 400) {
+ return new BadRequestError(status, error, message, headers);
+ }
+diff --git a/core/error.mjs b/core/error.mjs
+index 83cefbaffeb8c657536347322d8de9516af479a2..63334b7972ec04882aa4a0800c1ead5982345045 100644
+--- a/core/error.mjs
++++ b/core/error.mjs
+@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
+ if (!status || !headers) {
+ return new APIConnectionError({ message, cause: castToError(errorResponse) });
+ }
+- const error = errorResponse?.['error'];
++ const error = errorResponse?.['error'] || errorResponse;
+ if (status === 400) {
+ return new BadRequestError(status, error, message, headers);
+ }
+diff --git a/resources/embeddings.js b/resources/embeddings.js
+index 2404264d4ba0204322548945ebb7eab3bea82173..8f1bc45cc45e0797d50989d96b51147b90ae6790 100644
+--- a/resources/embeddings.js
++++ b/resources/embeddings.js
+@@ -5,52 +5,64 @@ exports.Embeddings = void 0;
+ const resource_1 = require("../core/resource.js");
+ const utils_1 = require("../internal/utils.js");
+ class Embeddings extends resource_1.APIResource {
+- /**
+- * Creates an embedding vector representing the input text.
+- *
+- * @example
+- * ```ts
+- * const createEmbeddingResponse =
+- * await client.embeddings.create({
+- * input: 'The quick brown fox jumped over the lazy dog',
+- * model: 'text-embedding-3-small',
+- * });
+- * ```
+- */
+- create(body, options) {
+- const hasUserProvidedEncodingFormat = !!body.encoding_format;
+- // No encoding_format specified, defaulting to base64 for performance reasons
+- // See https://github.com/openai/openai-node/pull/1312
+- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
+- if (hasUserProvidedEncodingFormat) {
+- (0, utils_1.loggerFor)(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
+- }
+- const response = this._client.post('/embeddings', {
+- body: {
+- ...body,
+- encoding_format: encoding_format,
+- },
+- ...options,
+- });
+- // if the user specified an encoding_format, return the response as-is
+- if (hasUserProvidedEncodingFormat) {
+- return response;
+- }
+- // in this stage, we are sure the user did not specify an encoding_format
+- // and we defaulted to base64 for performance reasons
+- // we are sure then that the response is base64 encoded, let's decode it
+- // the returned result will be a float32 array since this is OpenAI API's default encoding
+- (0, utils_1.loggerFor)(this._client).debug('embeddings/decoding base64 embeddings from base64');
+- return response._thenUnwrap((response) => {
+- if (response && response.data) {
+- response.data.forEach((embeddingBase64Obj) => {
+- const embeddingBase64Str = embeddingBase64Obj.embedding;
+- embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(embeddingBase64Str);
+- });
+- }
+- return response;
+- });
+- }
++ /**
++ * Creates an embedding vector representing the input text.
++ *
++ * @example
++ * ```ts
++ * const createEmbeddingResponse =
++ * await client.embeddings.create({
++ * input: 'The quick brown fox jumped over the lazy dog',
++ * model: 'text-embedding-3-small',
++ * });
++ * ```
++ */
++ create(body, options) {
++ const hasUserProvidedEncodingFormat = !!body.encoding_format;
++ // No encoding_format specified, defaulting to base64 for performance reasons
++ // See https://github.com/openai/openai-node/pull/1312
++ let encoding_format = hasUserProvidedEncodingFormat
++ ? body.encoding_format
++ : "base64";
++ if (body.model.includes("jina")) {
++ encoding_format = undefined;
++ }
++ if (hasUserProvidedEncodingFormat) {
++ (0, utils_1.loggerFor)(this._client).debug(
++ "embeddings/user defined encoding_format:",
++ body.encoding_format
++ );
++ }
++ const response = this._client.post("/embeddings", {
++ body: {
++ ...body,
++ encoding_format: encoding_format,
++ },
++ ...options,
++ });
++ // if the user specified an encoding_format, return the response as-is
++ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
++ return response;
++ }
++ // in this stage, we are sure the user did not specify an encoding_format
++ // and we defaulted to base64 for performance reasons
++ // we are sure then that the response is base64 encoded, let's decode it
++ // the returned result will be a float32 array since this is OpenAI API's default encoding
++ (0, utils_1.loggerFor)(this._client).debug(
++ "embeddings/decoding base64 embeddings from base64"
++ );
++ return response._thenUnwrap((response) => {
++ if (response && response.data) {
++ response.data.forEach((embeddingBase64Obj) => {
++ const embeddingBase64Str = embeddingBase64Obj.embedding;
++ embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(
++ embeddingBase64Str
++ );
++ });
++ }
++ return response;
++ });
++ }
+ }
+ exports.Embeddings = Embeddings;
+ //# sourceMappingURL=embeddings.js.map
+diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
+index 19dcaef578c194a89759c4360073cfd4f7dd2cbf..0284e9cc615c900eff508eb595f7360a74bd9200 100644
+--- a/resources/embeddings.mjs
++++ b/resources/embeddings.mjs
+@@ -2,51 +2,61 @@
+ import { APIResource } from "../core/resource.mjs";
+ import { loggerFor, toFloat32Array } from "../internal/utils.mjs";
+ export class Embeddings extends APIResource {
+- /**
+- * Creates an embedding vector representing the input text.
+- *
+- * @example
+- * ```ts
+- * const createEmbeddingResponse =
+- * await client.embeddings.create({
+- * input: 'The quick brown fox jumped over the lazy dog',
+- * model: 'text-embedding-3-small',
+- * });
+- * ```
+- */
+- create(body, options) {
+- const hasUserProvidedEncodingFormat = !!body.encoding_format;
+- // No encoding_format specified, defaulting to base64 for performance reasons
+- // See https://github.com/openai/openai-node/pull/1312
+- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
+- if (hasUserProvidedEncodingFormat) {
+- loggerFor(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
+- }
+- const response = this._client.post('/embeddings', {
+- body: {
+- ...body,
+- encoding_format: encoding_format,
+- },
+- ...options,
+- });
+- // if the user specified an encoding_format, return the response as-is
+- if (hasUserProvidedEncodingFormat) {
+- return response;
+- }
+- // in this stage, we are sure the user did not specify an encoding_format
+- // and we defaulted to base64 for performance reasons
+- // we are sure then that the response is base64 encoded, let's decode it
+- // the returned result will be a float32 array since this is OpenAI API's default encoding
+- loggerFor(this._client).debug('embeddings/decoding base64 embeddings from base64');
+- return response._thenUnwrap((response) => {
+- if (response && response.data) {
+- response.data.forEach((embeddingBase64Obj) => {
+- const embeddingBase64Str = embeddingBase64Obj.embedding;
+- embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
+- });
+- }
+- return response;
+- });
+- }
++ /**
++ * Creates an embedding vector representing the input text.
++ *
++ * @example
++ * ```ts
++ * const createEmbeddingResponse =
++ * await client.embeddings.create({
++ * input: 'The quick brown fox jumped over the lazy dog',
++ * model: 'text-embedding-3-small',
++ * });
++ * ```
++ */
++ create(body, options) {
++ const hasUserProvidedEncodingFormat = !!body.encoding_format;
++ // No encoding_format specified, defaulting to base64 for performance reasons
++ // See https://github.com/openai/openai-node/pull/1312
++ let encoding_format = hasUserProvidedEncodingFormat
++ ? body.encoding_format
++ : "base64";
++ if (body.model.includes("jina")) {
++ encoding_format = undefined;
++ }
++ if (hasUserProvidedEncodingFormat) {
++ loggerFor(this._client).debug(
++ "embeddings/user defined encoding_format:",
++ body.encoding_format
++ );
++ }
++ const response = this._client.post("/embeddings", {
++ body: {
++ ...body,
++ encoding_format: encoding_format,
++ },
++ ...options,
++ });
++ // if the user specified an encoding_format, return the response as-is
++ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
++ return response;
++ }
++ // in this stage, we are sure the user did not specify an encoding_format
++ // and we defaulted to base64 for performance reasons
++ // we are sure then that the response is base64 encoded, let's decode it
++ // the returned result will be a float32 array since this is OpenAI API's default encoding
++ loggerFor(this._client).debug(
++ "embeddings/decoding base64 embeddings from base64"
++ );
++ return response._thenUnwrap((response) => {
++ if (response && response.data) {
++ response.data.forEach((embeddingBase64Obj) => {
++ const embeddingBase64Str = embeddingBase64Obj.embedding;
++ embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
++ });
++ }
++ return response;
++ });
++ }
+ }
+ //# sourceMappingURL=embeddings.mjs.map
diff --git a/.yarn/releases/yarn-4.6.0.cjs b/.yarn/releases/yarn-4.6.0.cjs
deleted file mode 100755
index 3e7773b1ed..0000000000
Binary files a/.yarn/releases/yarn-4.6.0.cjs and /dev/null differ
diff --git a/.yarn/releases/yarn-4.9.1.cjs b/.yarn/releases/yarn-4.9.1.cjs
new file mode 100755
index 0000000000..657026d5c6
Binary files /dev/null and b/.yarn/releases/yarn-4.9.1.cjs differ
diff --git a/.yarnrc.yml b/.yarnrc.yml
index ff35b50cbe..e1e4cf05ca 100644
--- a/.yarnrc.yml
+++ b/.yarnrc.yml
@@ -4,4 +4,4 @@ httpTimeout: 300000
nodeLinker: node-modules
-yarnPath: .yarn/releases/yarn-4.6.0.cjs
+yarnPath: .yarn/releases/yarn-4.9.1.cjs
diff --git a/README.md b/README.md
index b5d7c2528d..3cf67d8368 100644
--- a/README.md
+++ b/README.md
@@ -19,13 +19,15 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
# 📖 Guide
-https://docs.cherry-ai.com
+
# 🌠 Screenshot
-
-
-
+
+
+
+
+
# 🌟 Key Features
@@ -65,28 +67,50 @@ https://docs.cherry-ai.com
- 📝 Complete Markdown Rendering
- 🤲 Easy Content Sharing
-# 📝 TODO
+# 📝 Roadmap
-- [x] Quick popup (read clipboard, quick question, explain, translate, summarize)
-- [x] Comparison of multi-model answers
-- [x] Support login using SSO provided by service providers
-- [x] All models support networking
-- [x] Launch of the first official version
-- [x] Bug fixes and improvements (In progress...)
-- [ ] Plugin functionality (JavaScript)
-- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base)
-- [ ] iOS & Android client
-- [ ] AI notes
-- [ ] Voice input and output (AI call)
-- [ ] Data backup supports custom backup content
+We're actively working on the following features and improvements:
+
+1. 🎯 **Core Features**
+
+- Selection Assistant - Smart content selection enhancement
+- Deep Research - Advanced research capabilities
+- Memory System - Global context awareness
+- Document Preprocessing - Improved document handling
+- MCP Marketplace - Model Context Protocol ecosystem
+
+2. 🗂 **Knowledge Management**
+
+- Notes and Collections
+- Dynamic Canvas visualization
+- OCR capabilities
+- TTS (Text-to-Speech) support
+
+3. 📱 **Platform Support**
+
+- HarmonyOS Edition (PC)
+- Android App (Phase 1)
+- iOS App (Phase 1)
+- Multi-Window support
+- Window Pinning functionality
+
+4. 🔌 **Advanced Features**
+
+- Plugin System
+- ASR (Automatic Speech Recognition)
+- Assistant and Topic Interaction Refactoring
+
+Track our progress and contribute on our [project board](https://github.com/orgs/CherryHQ/projects/7).
+
+Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/CherryHQ/cherry-studio/discussions) to share your ideas and feedback!
# 🌈 Theme
-- Theme Gallery: https://cherrycss.com
-- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
-- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
-- Claude dynamic-style: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
-- Maple Neon Theme: https://github.com/BoningtonChen/CherryStudio_themes
+- Theme Gallery:
+- Aero Theme:
+- PaperMaterial Theme:
+- Claude dynamic-style:
+- Maple Neon Theme:
Welcome PR for more themes
@@ -94,6 +118,10 @@ Welcome PR for more themes
Refer to the [development documentation](docs/dev.md)
+Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio)
+
+Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
+
# 🤝 Contributing
We welcome contributions to Cherry Studio! Here are some ways you can contribute:
@@ -117,7 +145,7 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB
Thank you for your support and contributions!
-## Related Projects
+# 🔗 Related Projects
- [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution.
@@ -126,7 +154,7 @@ Thank you for your support and contributions!
# 🚀 Contributors
-
+
@@ -144,7 +172,7 @@ Thank you for your support and contributions!
# ✉️ Contact
-yinsenho@cherry-ai.com
+
# ⭐️ Star History
diff --git a/docs/README.ja.md b/docs/README.ja.md
index 02983db685..2a88cf8e5b 100644
--- a/docs/README.ja.md
+++ b/docs/README.ja.md
@@ -6,17 +6,19 @@
English | 中文 | 日本語
+
+
# 🍒 Cherry Studio
Cherry Studio は、複数の LLM プロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linux で利用可能です。
👏 [Telegram](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
-❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
+❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!
# 📖 ガイド
@@ -24,9 +26,11 @@ https://docs.cherry-ai.com
# 🌠 スクリーンショット
-
-
-
+
+
+
+
+
# 🌟 主な機能
@@ -56,7 +60,7 @@ https://docs.cherry-ai.com
- 🔤 AI による翻訳機能
- 🎯 ドラッグ&ドロップによる整理
- 🔌 ミニプログラム対応
-- ⚙️ MCP(モデルコンテキストプロトコル) サービス
+- ⚙️ MCP(モデルコンテキストプロトコル)サービス
5. **優れたユーザー体験**:
@@ -66,75 +70,104 @@ https://docs.cherry-ai.com
- 📝 完全な Markdown レンダリング
- 🤲 簡単な共有機能
-# 📝 TODO
+# 📝 開発計画
-- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約)
-- [x] 複数モデルの回答の比較
-- [x] サービスプロバイダーが提供する SSO を使用したログインをサポート
-- [x] すべてのモデルがネットワークをサポート
-- [x] 最初の公式バージョンのリリース
-- [ ] 錯誤修復と改善 (開発中...)
-- [ ] プラグイン機能(JavaScript)
-- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加)
-- [ ] iOS & Android クライアント
-- [ ] AIノート
-- [ ] 音声入出力(AI コール)
-- [ ] データバックアップはカスタムバックアップコンテンツをサポート
+以下の機能と改善に積極的に取り組んでいます:
+
+1. 🎯 **コア機能**
+
+- 選択アシスタント - スマートな内容選択の強化
+- ディープリサーチ - 高度な研究能力
+- メモリーシステム - グローバルコンテキスト認識
+- ドキュメント前処理 - 文書処理の改善
+- MCP マーケットプレイス - モデルコンテキストプロトコルエコシステム
+
+2. 🗂 **ナレッジ管理**
+
+- ノートとコレクション
+- ダイナミックキャンバス可視化
+- OCR 機能
+- TTS(テキスト読み上げ)サポート
+
+3. 📱 **プラットフォーム対応**
+
+- HarmonyOS エディション
+- Android アプリ(フェーズ1)
+- iOS アプリ(フェーズ1)
+- マルチウィンドウ対応
+- ウィンドウピン留め機能
+
+4. 🔌 **高度な機能**
+
+- プラグインシステム
+- ASR(音声認識)
+- アシスタントとトピックの対話機能リファクタリング
+
+[プロジェクトボード](https://github.com/orgs/CherryHQ/projects/7)で進捗を確認し、貢献することができます。
+
+開発計画に影響を与えたいですか?[GitHub ディスカッション](https://github.com/CherryHQ/cherry-studio/discussions)に参加して、アイデアやフィードバックを共有してください!
# 🌈 テーマ
-- テーマギャラリー: https://cherrycss.com
-- Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
-- PaperMaterial テーマ: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
-- Claude テーマ: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
-- メープルネオンテーマ: https://github.com/BoningtonChen/CherryStudio_themes
+- テーマギャラリー:https://cherrycss.com
+- Aero テーマ:https://github.com/hakadao/CherryStudio-Aero
+- PaperMaterial テーマ:https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
+- Claude テーマ:https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
+- メープルネオンテーマ:https://github.com/BoningtonChen/CherryStudio_themes
-より多くのテーマのPRを歓迎します
+より多くのテーマの PR を歓迎します
# 🖥️ 開発
-参考[開発ドキュメント](dev.md)
+[開発ドキュメント](dev.md)を参照してください
+
+[アーキテクチャ概要ドキュメント](https://deepwiki.com/CherryHQ/cherry-studio)を参照してください
+
+[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
# 🤝 貢献
Cherry Studio への貢献を歓迎します!以下の方法で貢献できます:
-1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します。
-2. **バグの修正**:見つけたバグを修正します。
-3. **問題の管理**:GitHub の問題を管理するのを手伝います。
-4. **製品デザイン**:デザインの議論に参加します。
-5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します。
-6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します。
-7. **使用の促進**:Cherry Studio を広めます。
+1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します
+2. **バグの修正**:見つけたバグを修正します
+3. **問題の管理**:GitHub の問題を管理するのを手伝います
+4. **製品デザイン**:デザインの議論に参加します
+5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します
+6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します
+7. **使用の促進**:Cherry Studio を広めます
## 始め方
-1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします。
-2. **ブランチを作成**:変更のためのブランチを作成します。
-3. **変更を提出**:変更をコミットしてプッシュします。
-4. **プルリクエストを開く**:変更内容と理由を説明します。
+1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします
+2. **ブランチを作成**:変更のためのブランチを作成します
+3. **変更を提出**:変更をコミットしてプッシュします
+4. **プルリクエストを開く**:変更内容と理由を説明します
詳細なガイドラインについては、[貢献ガイド](../CONTRIBUTING.md)をご覧ください。
ご支援と貢献に感謝します!
-## 関連頁版
+# 🔗 関連プロジェクト
- [one-api](https://github.com/songquanpeng/one-api):LLM API の管理・配信システム。OpenAI、Azure、Anthropic などの主要モデルに対応し、統一 API インターフェースを提供。API キー管理と再配布に利用可能。
+- [ublacklist](https://github.com/iorate/ublacklist):Google 検索結果から特定のサイトを非表示にします
+
# 🚀 コントリビューター
-
+
+
-# コミュニティ
+# 🌐 コミュニティ
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
-# スポンサー
+# ☕ スポンサー
-[Buy Me a Coffee](sponsor.md)
+[開発者を支援する](sponsor.md)
# 📃 ライセンス
diff --git a/docs/README.zh.md b/docs/README.zh.md
index 2dd938e386..f4a8feda66 100644
--- a/docs/README.zh.md
+++ b/docs/README.zh.md
@@ -4,7 +4,8 @@
- English | 中文 | 日本語
+ English | 中文 | 日本語
+
@@ -18,15 +19,25 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
+# GitCode✖️Cherry Studio【新源力】贡献挑战赛
+
+
+
+
+
+
+
# 📖 使用教程
https://docs.cherry-ai.com
# 🌠 界面
-
-
-
+
+
+
+
+
# 🌟 主要特性
@@ -66,28 +77,50 @@ https://docs.cherry-ai.com
- 📝 完整的 Markdown 渲染
- 🤲 便捷的内容分享功能
-# 📝 待辦事項
+# 📝 开发计划
-- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结)
-- [x] 多模型回答对比
-- [x] 支持使用服务供应商提供的 SSO 进行登入
-- [x] 全部模型支持连网(开发中...)
-- [x] 推出第一个正式版
-- [x] 错误修复和改进(开发中...)
-- [ ] 插件功能(JavaScript)
-- [ ] 浏览器插件(划词翻译、总结、新增至知识库)
-- [ ] iOS & Android 客户端
-- [ ] AI 笔记
-- [ ] 语音输入输出(AI 通话)
-- [ ] 数据备份支持自定义备份内容
+我们正在积极开发以下功能和改进:
+
+1. 🎯 **核心功能**
+
+- 选择助手 - 智能内容选择增强
+- 深度研究 - 高级研究能力
+- 全局记忆 - 全局上下文感知
+- 文档预处理 - 改进文档处理能力
+- MCP 市场 - 模型上下文协议生态系统
+
+2. 🗂 **知识管理**
+
+- 笔记与收藏功能
+- 动态画布可视化
+- OCR 光学字符识别
+- TTS 文本转语音支持
+
+3. 📱 **平台支持**
+
+- 鸿蒙版本 (PC)
+- Android 应用(第一期)
+- iOS 应用(第一期)
+- 多窗口支持
+- 窗口置顶功能
+
+4. 🔌 **高级特性**
+
+- 插件系统
+- ASR 语音识别
+- 助手与话题交互重构
+
+在我们的[项目面板](https://github.com/orgs/CherryHQ/projects/7)上跟踪进展并参与贡献。
+
+想要影响开发计划?欢迎加入我们的 [GitHub 讨论区](https://github.com/CherryHQ/cherry-studio/discussions) 分享您的想法和反馈!
# 🌈 主题
- 主题库:https://cherrycss.com
- Aero 主题:https://github.com/hakadao/CherryStudio-Aero
-- PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
-- 仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
-- 霓虹枫叶字体主题: https://github.com/BoningtonChen/CherryStudio_themes
+- PaperMaterial 主题:https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
+- 仿 Claude 主题:https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
+- 霓虹枫叶主题:https://github.com/BoningtonChen/CherryStudio_themes
欢迎 PR 更多主题
@@ -95,37 +128,43 @@ https://docs.cherry-ai.com
参考[开发文档](dev.md)
+参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio)
+
+参考[分支策略](branching-strategy-zh.md)了解贡献指南
+
# 🤝 贡献
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
-1. **贡献代码**:开发新功能或优化现有代码。
-2. **修复错误**:提交您发现的错误修复。
-3. **维护问题**:帮助管理 GitHub 问题。
-4. **产品设计**:参与设计讨论。
-5. **撰写文档**:改进用户手册和指南。
-6. **社区参与**:加入讨论并帮助用户。
-7. **推广使用**:宣传 Cherry Studio。
+1. **贡献代码**:开发新功能或优化现有代码
+2. **修复错误**:提交您发现的错误修复
+3. **维护问题**:帮助管理 GitHub 问题
+4. **产品设计**:参与设计讨论
+5. **撰写文档**:改进用户手册和指南
+6. **社区参与**:加入讨论并帮助用户
+7. **推广使用**:宣传 Cherry Studio
## 入门
-1. **Fork 仓库**:Fork 并克隆到您的本地机器。
-2. **创建分支**:为您的更改创建分支。
-3. **提交更改**:提交并推送您的更改。
-4. **打开 Pull Request**:描述您的更改和原因。
+1. **Fork 仓库**:Fork 并克隆到您的本地机器
+2. **创建分支**:为您的更改创建分支
+3. **提交更改**:提交并推送您的更改
+4. **打开 Pull Request**:描述您的更改和原因
-有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)。
+有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)
感谢您的支持和贡献!
-## 相关项目
+# 🔗 相关项目
- [one-api](https://github.com/songquanpeng/one-api):LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。
+- [ublacklist](https://github.com/iorate/ublacklist):屏蔽特定网站在 Google 搜索结果中显示
+
# 🚀 贡献者
-
+
@@ -135,7 +174,7 @@ https://docs.cherry-ai.com
# ☕ 赞助
-[微信赞赏码](sponsor.md)
+[赞助开发者](sponsor.md)
# 📃 许可证
diff --git a/docs/branching-strategy-en.md b/docs/branching-strategy-en.md
new file mode 100644
index 0000000000..f3b7ddf508
--- /dev/null
+++ b/docs/branching-strategy-en.md
@@ -0,0 +1,71 @@
+# 🌿 Branching Strategy
+
+Cherry Studio implements a structured branching strategy to maintain code quality and streamline the development process.
+
+## Main Branches
+
+- `main`: Main development branch
+
+ - Contains the latest development code
+ - Direct commits are not allowed - changes must come through pull requests
+ - Code may contain features in development and might not be fully stable
+
+- `release/*`: Release branches
+ - Created from `main` branch
+ - Contains stable code ready for release
+ - Only accepts documentation updates and bug fixes
+ - Thoroughly tested before production deployment
+
+## Contributing Branches
+
+When contributing to Cherry Studio, please follow these guidelines:
+
+1. **Feature Branches:**
+
+ - Create from `main` branch
+ - Naming format: `feature/issue-number-brief-description`
+ - Submit PR back to `main`
+
+2. **Bug Fix Branches:**
+
+ - Create from `main` branch
+ - Naming format: `fix/issue-number-brief-description`
+ - Submit PR back to `main`
+
+3. **Documentation Branches:**
+
+ - Create from `main` branch
+ - Naming format: `docs/brief-description`
+ - Submit PR back to `main`
+
+4. **Hotfix Branches:**
+
+ - Create from `main` branch
+ - Naming format: `hotfix/issue-number-brief-description`
+ - Submit PR to both `main` and relevant `release` branches
+
+5. **Release Branches:**
+ - Create from `main` branch
+ - Naming format: `release/version-number`
+ - Used for final preparation work before version release
+ - Only accepts bug fixes and documentation updates
+ - After testing and preparation, merge back to `main` and tag with version
+
+## Workflow Diagram
+
+
+
+## Pull Request Guidelines
+
+- All PRs should be submitted to the `main` branch unless fixing a critical production issue
+- Ensure your branch is up to date with the latest `main` changes before submitting
+- Include relevant issue numbers in your PR description
+- Make sure all tests pass and code meets our quality standards
+- Add before/after screenshots if you add a new feature or modify a UI component
+
+## Version Tag Management
+
+- Major releases: v1.0.0, v2.0.0, etc.
+- Feature releases: v1.1.0, v1.2.0, etc.
+- Patch releases: v1.0.1, v1.0.2, etc.
+- Hotfix releases: v1.0.1-hotfix, etc.
diff --git a/docs/branching-strategy-zh.md b/docs/branching-strategy-zh.md
new file mode 100644
index 0000000000..b1379537a5
--- /dev/null
+++ b/docs/branching-strategy-zh.md
@@ -0,0 +1,71 @@
+# 🌿 分支策略
+
+Cherry Studio 采用结构化的分支策略来维护代码质量并简化开发流程。
+
+## 主要分支
+
+- `main`:主开发分支
+
+ - 包含最新的开发代码
+ - 禁止直接提交 - 所有更改必须通过拉取请求(Pull Request)
+ - 此分支上的代码可能包含正在开发的功能,不一定完全稳定
+
+- `release/*`:发布分支
+ - 从 `main` 分支创建
+ - 包含准备发布的稳定代码
+ - 只接受文档更新和 bug 修复
+ - 经过完整测试后可以发布到生产环境
+
+## 贡献分支
+
+在为 Cherry Studio 贡献代码时,请遵循以下准则:
+
+1. **功能开发分支:**
+
+ - 从 `main` 分支创建
+ - 命名格式:`feature/issue-number-brief-description`
+ - 完成后提交 PR 到 `main` 分支
+
+2. **Bug 修复分支:**
+
+ - 从 `main` 分支创建
+ - 命名格式:`fix/issue-number-brief-description`
+ - 完成后提交 PR 到 `main` 分支
+
+3. **文档更新分支:**
+
+ - 从 `main` 分支创建
+ - 命名格式:`docs/brief-description`
+ - 完成后提交 PR 到 `main` 分支
+
+4. **紧急修复分支:**
+
+ - 从 `main` 分支创建
+ - 命名格式:`hotfix/issue-number-brief-description`
+ - 完成后需要同时合并到 `main` 和相关的 `release` 分支
+
+5. **发布分支:**
+ - 从 `main` 分支创建
+ - 命名格式:`release/version-number`
+ - 用于版本发布前的最终准备工作
+ - 只允许合并 bug 修复和文档更新
+ - 完成测试和准备工作后,将代码合并回 `main` 分支并打上版本标签
+
+## 工作流程
+
+
+
+## 拉取请求(PR)指南
+
+- 除非是修复生产环境的关键问题,否则所有 PR 都应该提交到 `main` 分支
+- 提交 PR 前确保你的分支已经同步了最新的 `main` 分支内容
+- 在 PR 描述中包含相关的 issue 编号
+- 确保所有测试通过,且代码符合我们的质量标准
+- 如果你添加了新功能或修改了 UI 组件,请附上更改前后的截图
+
+## 版本标签管理
+
+- 主要版本发布:v1.0.0、v2.0.0 等
+- 功能更新发布:v1.1.0、v1.2.0 等
+- 补丁修复发布:v1.0.1、v1.0.2 等
+- 紧急修复发布:v1.0.1-hotfix 等
diff --git a/docs/dev.md b/docs/dev.md
index 22a0eb9086..9a781314a9 100644
--- a/docs/dev.md
+++ b/docs/dev.md
@@ -37,6 +37,14 @@ yarn install
yarn dev
```
+### Debug
+
+```bash
+yarn debug
+```
+
+Then input chrome://inspect in browser
+
### Test
```bash
diff --git a/docs/technical/Message.md b/docs/technical/Message.md
new file mode 100644
index 0000000000..673b1cce7b
--- /dev/null
+++ b/docs/technical/Message.md
@@ -0,0 +1,3 @@
+# 消息的生命周期
+
+
diff --git a/docs/technical/how-to-use-messageBlock.md b/docs/technical/how-to-use-messageBlock.md
new file mode 100644
index 0000000000..f60c2851ce
--- /dev/null
+++ b/docs/technical/how-to-use-messageBlock.md
@@ -0,0 +1,127 @@
+# messageBlock.ts 使用指南
+
+该文件定义了用于管理应用程序中所有 `MessageBlock` 实体的 Redux Slice。它使用 Redux Toolkit 的 `createSlice` 和 `createEntityAdapter` 来高效地处理规范化的状态,并提供了一系列 actions 和 selectors 用于与消息块数据交互。
+
+## 核心目标
+
+- **状态管理**: 集中管理所有 `MessageBlock` 的状态。`MessageBlock` 代表消息中的不同内容单元(如文本、代码、图片、引用等)。
+- **规范化**: 使用 `createEntityAdapter` 将 `MessageBlock` 数据存储在规范化的结构中(`{ ids: [], entities: {} }`),这有助于提高性能和简化更新逻辑。
+- **可预测性**: 提供明确的 actions 来修改状态,并通过 selectors 安全地访问状态。
+
+## 关键概念
+
+- **Slice (`createSlice`)**: Redux Toolkit 的核心 API,用于创建包含 reducer 逻辑、action creators 和初始状态的 Redux 模块。
+- **Entity Adapter (`createEntityAdapter`)**: Redux Toolkit 提供的工具,用于简化对规范化数据的 CRUD(创建、读取、更新、删除)操作。它会自动生成 reducer 函数和 selectors。
+- **Selectors**: 用于从 Redux store 中派生和计算数据的函数。Selectors 可以被记忆化(memoized),以提高性能。
+
+## State 结构
+
+`messageBlocks` slice 的状态结构由 `createEntityAdapter` 定义,大致如下:
+
+```typescript
+{
+ ids: string[]; // 存储所有 MessageBlock ID 的有序列表
+ entities: { [id: string]: MessageBlock }; // 按 ID 存储 MessageBlock 对象的字典
+ loadingState: 'idle' | 'loading' | 'succeeded' | 'failed'; // (可选) 其他状态,如加载状态
+ error: string | null; // (可选) 错误信息
+}
+```
+
+## Actions
+
+该 slice 导出以下 actions (由 `createSlice` 和 `createEntityAdapter` 自动生成或自定义):
+
+- **`upsertOneBlock(payload: MessageBlock)`**:
+
+ - 添加一个新的 `MessageBlock` 或更新一个已存在的 `MessageBlock`。如果 payload 中的 `id` 已存在,则执行更新;否则执行插入。
+
+- **`upsertManyBlocks(payload: MessageBlock[])`**:
+
+ - 添加或更新多个 `MessageBlock`。常用于批量加载数据(例如,加载一个 Topic 的所有消息块)。
+
+- **`removeOneBlock(payload: string)`**:
+
+ - 根据提供的 `id` (payload) 移除单个 `MessageBlock`。
+
+- **`removeManyBlocks(payload: string[])`**:
+
+ - 根据提供的 `id` 数组 (payload) 移除多个 `MessageBlock`。常用于删除消息或清空 Topic 时清理相关的块。
+
+- **`removeAllBlocks()`**:
+
+ - 移除 state 中的所有 `MessageBlock` 实体。
+
+- **`updateOneBlock(payload: { id: string; changes: Partial
})`**:
+
+ - 更新一个已存在的 `MessageBlock`。`payload` 需要包含块的 `id` 和一个包含要更改的字段的 `changes` 对象。
+
+- **`setMessageBlocksLoading(payload: 'idle' | 'loading')`**:
+
+ - (自定义) 设置 `loadingState` 属性。
+
+- **`setMessageBlocksError(payload: string)`**:
+ - (自定义) 设置 `loadingState` 为 `'failed'` 并记录错误信息。
+
+**使用示例 (在 Thunk 或其他 Dispatch 的地方):**
+
+```typescript
+import { upsertOneBlock, removeManyBlocks, updateOneBlock } from './messageBlock'
+import store from './store' // 假设这是你的 Redux store 实例
+
+// 添加或更新一个块
+const newBlock: MessageBlock = {
+ /* ... block data ... */
+}
+store.dispatch(upsertOneBlock(newBlock))
+
+// 更新一个块的内容
+store.dispatch(updateOneBlock({ id: blockId, changes: { content: 'New content' } }))
+
+// 删除多个块
+const blockIdsToRemove = ['id1', 'id2']
+store.dispatch(removeManyBlocks(blockIdsToRemove))
+```
+
+## Selectors
+
+该 slice 导出由 `createEntityAdapter` 生成的基础 selectors,并通过 `messageBlocksSelectors` 对象访问:
+
+- **`messageBlocksSelectors.selectIds(state: RootState): string[]`**: 返回包含所有块 ID 的数组。
+- **`messageBlocksSelectors.selectEntities(state: RootState): { [id: string]: MessageBlock }`**: 返回块 ID 到块对象的映射字典。
+- **`messageBlocksSelectors.selectAll(state: RootState): MessageBlock[]`**: 返回包含所有块对象的数组。
+- **`messageBlocksSelectors.selectTotal(state: RootState): number`**: 返回块的总数。
+- **`messageBlocksSelectors.selectById(state: RootState, id: string): MessageBlock | undefined`**: 根据 ID 返回单个块对象,如果找不到则返回 `undefined`。
+
+**此外,还提供了一个自定义的、记忆化的 selector:**
+
+- **`selectFormattedCitationsByBlockId(state: RootState, blockId: string | undefined): Citation[]`**:
+ - 接收一个 `blockId`。
+ - 如果该 ID 对应的块是 `CITATION` 类型,则提取并格式化其包含的引用信息(来自网页搜索、知识库等),进行去重和重新编号,最后返回一个 `Citation[]` 数组,用于在 UI 中显示。
+ - 如果块不存在或类型不匹配,返回空数组 `[]`。
+ - 这个 selector 封装了处理不同引用来源(Gemini, OpenAI, OpenRouter, Zhipu 等)的复杂逻辑。
+
+**使用示例 (在 React 组件或 `useSelector` 中):**
+
+```typescript
+import { useSelector } from 'react-redux'
+import { messageBlocksSelectors, selectFormattedCitationsByBlockId } from './messageBlock'
+import type { RootState } from './store'
+
+// 获取所有块
+const allBlocks = useSelector(messageBlocksSelectors.selectAll)
+
+// 获取特定 ID 的块
+const specificBlock = useSelector((state: RootState) => messageBlocksSelectors.selectById(state, someBlockId))
+
+// 获取特定引用块格式化后的引用列表
+const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, citationBlockId))
+
+// 在组件中使用引用数据
+// {formattedCitations.map(citation => ...)}
+```
+
+## 集成
+
+`messageBlock.ts` slice 通常与 `messageThunk.ts` 中的 Thunks 紧密协作。Thunks 负责处理异步逻辑(如 API 调用、数据库操作),并在需要时 dispatch `messageBlock` slice 的 actions 来更新状态。例如,当 `messageThunk` 接收到流式响应时,它会 dispatch `upsertOneBlock` 或 `updateOneBlock` 来实时更新对应的 `MessageBlock`。同样,删除消息的 Thunk 会 dispatch `removeManyBlocks`。
+
+理解 `messageBlock.ts` 的职责是管理**状态本身**,而 `messageThunk.ts` 负责**触发状态变更**的异步流程,这对于维护清晰的应用架构至关重要。
diff --git a/docs/technical/how-to-use-messageThunk.md b/docs/technical/how-to-use-messageThunk.md
new file mode 100644
index 0000000000..86952f99ad
--- /dev/null
+++ b/docs/technical/how-to-use-messageThunk.md
@@ -0,0 +1,105 @@
+# messageThunk.ts 使用指南
+
+该文件包含用于管理应用程序中消息流、处理助手交互以及同步 Redux 状态与 IndexedDB 数据库的核心 Thunk Action Creators。主要围绕 `Message` 和 `MessageBlock` 对象进行操作。
+
+## 核心功能
+
+1. **发送/接收消息**: 处理用户消息的发送,触发助手响应,并流式处理返回的数据,将其解析为不同的 `MessageBlock`。
+2. **状态管理**: 确保 Redux store 中的消息和消息块状态与 IndexedDB 中的持久化数据保持一致。
+3. **消息操作**: 提供删除、重发、重新生成、编辑后重发、追加响应、克隆等消息生命周期管理功能。
+4. **Block 处理**: 动态创建、更新和保存各种类型的 `MessageBlock`(文本、思考过程、工具调用、引用、图片、错误、翻译等)。
+
+## 主要 Thunks
+
+以下是一些关键的 Thunk 函数及其用途:
+
+1. **`sendMessage(userMessage, userMessageBlocks, assistant, topicId)`**
+
+ - **用途**: 发送一条新的用户消息。
+ - **流程**:
+ - 保存用户消息 (`userMessage`) 及其块 (`userMessageBlocks`) 到 Redux 和 DB。
+ - 检查 `@mentions` 以确定是单模型响应还是多模型响应。
+ - 创建助手消息(们)的存根 (Stub)。
+ - 将存根添加到 Redux 和 DB。
+ - 将核心处理逻辑 `fetchAndProcessAssistantResponseImpl` 添加到该 `topicId` 的队列中以获取实际响应。
+ - **Block 相关**: 主要处理用户消息的初始 `MessageBlock` 保存。
+
+2. **`fetchAndProcessAssistantResponseImpl(dispatch, getState, topicId, assistant, assistantMessage)`**
+
+ - **用途**: (内部函数) 获取并处理单个助手响应的核心逻辑,被 `sendMessage`, `resend...`, `regenerate...`, `append...` 等调用。
+ - **流程**:
+ - 设置 Topic 加载状态。
+ - 准备上下文消息。
+ - 调用 `fetchChatCompletion` API 服务。
+ - 使用 `createStreamProcessor` 处理流式响应。
+ - 通过各种回调 (`onTextChunk`, `onThinkingChunk`, `onToolCallComplete`, `onImageGenerated`, `onError`, `onComplete` 等) 处理不同类型的事件。
+ - **Block 相关**:
+ - 根据流事件创建初始 `UNKNOWN` 块。
+ - 实时创建和更新 `MAIN_TEXT` 和 `THINKING` 块,使用 `throttledBlockUpdate` 和 `throttledBlockDbUpdate` 进行节流更新。
+ - 创建 `TOOL`, `CITATION`, `IMAGE`, `ERROR` 等类型的块。
+ - 在事件完成时(如 `onTextComplete`, `onToolCallComplete`)将块状态标记为 `SUCCESS` 或 `ERROR`,并使用 `saveUpdatedBlockToDB` 保存最终状态。
+ - 使用 `handleBlockTransition` 管理非流式块(如 `TOOL`, `CITATION`)的添加和状态更新。
+
+3. **`loadTopicMessagesThunk(topicId, forceReload)`**
+
+ - **用途**: 从数据库加载指定主题的所有消息及其关联的 `MessageBlock`。
+ - **流程**:
+ - 从 DB 获取 `Topic` 及其 `messages` 列表。
+ - 根据消息 ID 列表从 DB 获取所有相关的 `MessageBlock`。
+ - 使用 `upsertManyBlocks` 将块更新到 Redux。
+ - 将消息更新到 Redux。
+ - **Block 相关**: 负责将持久化的 `MessageBlock` 加载到 Redux 状态。
+
+4. **删除 Thunks**
+
+ - `deleteSingleMessageThunk(topicId, messageId)`: 删除单个消息及其所有 `MessageBlock`。
+ - `deleteMessageGroupThunk(topicId, askId)`: 删除一个用户消息及其所有相关的助手响应消息和它们的所有 `MessageBlock`。
+ - `clearTopicMessagesThunk(topicId)`: 清空主题下的所有消息及其所有 `MessageBlock`。
+ - **Block 相关**: 从 Redux 和 DB 中移除指定的 `MessageBlock`。
+
+5. **重发/重新生成 Thunks**
+
+ - `resendMessageThunk(topicId, userMessageToResend, assistant)`: 重发用户消息。会重置(清空 Block 并标记为 PENDING)所有与该用户消息关联的助手响应,然后重新请求生成。
+ - `resendUserMessageWithEditThunk(topicId, originalMessage, mainTextBlockId, editedContent, assistant)`: 用户编辑消息内容后重发。先更新用户消息的 `MAIN_TEXT` 块内容,然后调用 `resendMessageThunk`。
+ - `regenerateAssistantResponseThunk(topicId, assistantMessageToRegenerate, assistant)`: 重新生成单个助手响应。重置该助手消息(清空 Block 并标记为 PENDING),然后重新请求生成。
+ - **Block 相关**: 删除旧的 `MessageBlock`,并在重新生成过程中创建新的 `MessageBlock`。
+
+6. **`appendAssistantResponseThunk(topicId, existingAssistantMessageId, newModel, assistant)`**
+
+ - **用途**: 在已有的对话上下文中,针对同一个用户问题,使用新选择的模型追加一个新的助手响应。
+ - **流程**:
+ - 找到现有助手消息以获取原始 `askId`。
+ - 创建使用 `newModel` 的新助手消息存根(使用相同的 `askId`)。
+ - 添加新存根到 Redux 和 DB。
+ - 将 `fetchAndProcessAssistantResponseImpl` 添加到队列以生成新响应。
+ - **Block 相关**: 为新的助手响应创建全新的 `MessageBlock`。
+
+7. **`cloneMessagesToNewTopicThunk(sourceTopicId, branchPointIndex, newTopic)`**
+
+ - **用途**: 将源主题的部分消息(及其 Block)克隆到一个**已存在**的新主题中。
+ - **流程**:
+ - 复制指定索引前的消息。
+ - 为所有克隆的消息和 Block 生成新的 UUID。
+ - 正确映射克隆消息之间的 `askId` 关系。
+ - 复制 `MessageBlock` 内容,更新其 `messageId` 指向新的消息 ID。
+ - 更新文件引用计数(如果 Block 是文件或图片)。
+ - 将克隆的消息和 Block 保存到新主题的 Redux 状态和 DB 中。
+ - **Block 相关**: 创建 `MessageBlock` 的副本,并更新其 ID 和 `messageId`。
+
+8. **`initiateTranslationThunk(messageId, topicId, targetLanguage, sourceBlockId?, sourceLanguage?)`**
+ - **用途**: 为指定消息启动翻译流程,创建一个初始的 `TRANSLATION` 类型的 `MessageBlock`。
+ - **流程**:
+ - 创建一个状态为 `STREAMING` 的 `TranslationMessageBlock`。
+ - 将其添加到 Redux 和 DB。
+ - 更新原消息的 `blocks` 列表以包含新的翻译块 ID。
+ - **Block 相关**: 创建并保存一个占位的 `TranslationMessageBlock`。实际翻译内容的获取和填充需要后续步骤。
+
+## 内部机制和注意事项
+
+- **数据库交互**: 通过 `saveMessageAndBlocksToDB`, `updateExistingMessageAndBlocksInDB`, `saveUpdatesToDB`, `saveUpdatedBlockToDB`, `throttledBlockDbUpdate` 等辅助函数与 IndexedDB (`db`) 交互,确保数据持久化。
+- **状态同步**: Thunks 负责协调 Redux Store 和 IndexedDB 之间的数据一致性。
+- **队列 (`getTopicQueue`)**: 使用 `AsyncQueue` 确保对同一主题的操作(尤其是 API 请求)按顺序执行,避免竞态条件。
+- **节流 (`throttle`)**: 对流式响应中频繁的 Block 更新(文本、思考)使用 `lodash.throttle` 优化性能,减少 Redux dispatch 和 DB 写入次数。
+- **错误处理**: `fetchAndProcessAssistantResponseImpl` 内的回调函数(特别是 `onError`)处理流处理和 API 调用中可能出现的错误,并创建 `ERROR` 类型的 `MessageBlock`。
+
+开发者在使用这些 Thunks 时,通常需要提供 `dispatch`, `getState` (由 Redux Thunk 中间件注入),以及如 `topicId`, `assistant` 配置对象, 相关的 `Message` 或 `MessageBlock` 对象/ID 等参数。理解每个 Thunk 的职责和它如何影响消息及块的状态至关重要。
diff --git a/docs/technical/how-to-use-useMessageOperations.md b/docs/technical/how-to-use-useMessageOperations.md
new file mode 100644
index 0000000000..df56ad5e5f
--- /dev/null
+++ b/docs/technical/how-to-use-useMessageOperations.md
@@ -0,0 +1,156 @@
+# useMessageOperations.ts 使用指南
+
+该文件定义了一个名为 `useMessageOperations` 的自定义 React Hook。这个 Hook 的主要目的是为 React 组件提供一个便捷的接口,用于执行与特定主题(Topic)相关的各种消息操作。它封装了调用 Redux Thunks (`messageThunk.ts`) 和 Actions (`newMessage.ts`, `messageBlock.ts`) 的逻辑,简化了组件与消息数据交互的代码。
+
+## 核心目标
+
+- **封装**: 将复杂的消息操作逻辑(如删除、重发、重新生成、编辑、翻译等)封装在易于使用的函数中。
+- **简化**: 让组件可以直接调用这些操作函数,而无需直接与 Redux `dispatch` 或 Thunks 交互。
+- **上下文关联**: 所有操作都与传入的 `topic` 对象相关联,确保操作作用于正确的主题。
+
+## 如何使用
+
+在你的 React 函数组件中,导入并调用 `useMessageOperations` Hook,并传入当前活动的 `Topic` 对象。
+
+```typescript
+import React from 'react';
+import { useMessageOperations } from '@renderer/hooks/useMessageOperations';
+import type { Topic, Message, Assistant, Model } from '@renderer/types';
+
+interface MyComponentProps {
+ currentTopic: Topic;
+ currentAssistant: Assistant;
+}
+
+function MyComponent({ currentTopic, currentAssistant }: MyComponentProps) {
+ const {
+ deleteMessage,
+ resendMessage,
+ regenerateAssistantMessage,
+ appendAssistantResponse,
+ getTranslationUpdater,
+ createTopicBranch,
+ // ... 其他操作函数
+ } = useMessageOperations(currentTopic);
+
+ const handleDelete = (messageId: string) => {
+ deleteMessage(messageId);
+ };
+
+ const handleResend = (message: Message) => {
+ resendMessage(message, currentAssistant);
+ };
+
+ const handleAppend = (existingMsg: Message, newModel: Model) => {
+ appendAssistantResponse(existingMsg, newModel, currentAssistant);
+ }
+
+ // ... 在组件中使用其他操作函数
+
+ return (
+
+ {/* Component UI */}
+ handleDelete('some-message-id')}>Delete Message
+ {/* ... */}
+
+ );
+}
+```
+
+## 返回值
+
+`useMessageOperations(topic)` Hook 返回一个包含以下函数和值的对象:
+
+- **`deleteMessage(id: string)`**:
+
+ - 删除指定 `id` 的单个消息。
+ - 内部调用 `deleteSingleMessageThunk`。
+
+- **`deleteGroupMessages(askId: string)`**:
+
+ - 删除与指定 `askId` 相关联的一组消息(通常是用户提问及其所有助手回答)。
+ - 内部调用 `deleteMessageGroupThunk`。
+
+- **`editMessage(messageId: string, updates: Partial)`**:
+
+ - 更新指定 `messageId` 的消息的部分属性。
+ - **注意**: 目前主要用于更新 Redux 状态
+ - 内部调用 `newMessagesActions.updateMessage`。
+
+- **`resendMessage(message: Message, assistant: Assistant)`**:
+
+ - 重新发送指定的用户消息 (`message`),这将触发其所有关联助手响应的重新生成。
+ - 内部调用 `resendMessageThunk`。
+
+- **`resendUserMessageWithEdit(message: Message, editedContent: string, assistant: Assistant)`**:
+
+ - 在用户消息的主要文本块被编辑后,重新发送该消息。
+ - 会先查找消息的 `MAIN_TEXT` 块 ID,然后调用 `resendUserMessageWithEditThunk`。
+
+- **`clearTopicMessages(_topicId?: string)`**:
+
+ - 清除当前主题(或可选的指定 `_topicId`)下的所有消息。
+ - 内部调用 `clearTopicMessagesThunk`。
+
+- **`createNewContext()`**:
+
+ - 发出一个全局事件 (`EVENT_NAMES.NEW_CONTEXT`),通常用于通知 UI 清空显示,准备新的上下文。不直接修改 Redux 状态。
+
+- **`displayCount`**:
+
+ - (非操作函数) 从 Redux store 中获取当前的 `displayCount` 值。
+
+- **`pauseMessages()`**:
+
+ - 尝试中止当前主题中正在进行的消息生成(状态为 `processing` 或 `pending`)。
+ - 通过查找相关的 `askId` 并调用 `abortCompletion` 来实现。
+ - 同时会 dispatch `setTopicLoading` action 将加载状态设为 `false`。
+
+- **`resumeMessage(message: Message, assistant: Assistant)`**:
+
+ - 恢复/重新发送一个用户消息。目前实现为直接调用 `resendMessage`。
+
+- **`regenerateAssistantMessage(message: Message, assistant: Assistant)`**:
+
+ - 重新生成指定的**助手**消息 (`message`) 的响应。
+ - 内部调用 `regenerateAssistantResponseThunk`。
+
+- **`appendAssistantResponse(existingAssistantMessage: Message, newModel: Model, assistant: Assistant)`**:
+
+ - 针对 `existingAssistantMessage` 所回复的**同一用户提问**,使用 `newModel` 追加一个新的助手响应。
+ - 内部调用 `appendAssistantResponseThunk`。
+
+- **`getTranslationUpdater(messageId: string, targetLanguage: string, sourceBlockId?: string, sourceLanguage?: string)`**:
+
+ - **用途**: 获取一个用于逐步更新翻译块内容的函数。
+ - **流程**:
+ 1. 内部调用 `initiateTranslationThunk` 来创建或获取一个 `TRANSLATION` 类型的 `MessageBlock`,并获取其 `blockId`。
+ 2. 返回一个**异步更新函数**。
+ - **返回的更新函数 `(accumulatedText: string, isComplete?: boolean) => void`**:
+ - 接收累积的翻译文本和完成状态。
+ - 调用 `updateOneBlock` 更新 Redux 中的翻译块内容和状态 (`STREAMING` 或 `SUCCESS`)。
+ - 调用 `throttledBlockDbUpdate` 将更新(节流地)保存到数据库。
+ - 如果初始化失败(Thunk 返回 `undefined`),则此函数返回 `null`。
+
+- **`createTopicBranch(sourceTopicId: string, branchPointIndex: number, newTopic: Topic)`**:
+ - 创建一个主题分支,将 `sourceTopicId` 主题中 `branchPointIndex` 索引之前的消息克隆到 `newTopic` 中。
+ - **注意**: `newTopic` 对象必须是调用此函数**之前**已经创建并添加到 Redux 和数据库中的。
+ - 内部调用 `cloneMessagesToNewTopicThunk`。
+
+## 依赖
+
+- **`topic: Topic`**: 必须传入当前操作上下文的主题对象。Hook 返回的操作函数将始终作用于这个主题的 `topic.id`。
+- **Redux `dispatch`**: Hook 内部使用 `useAppDispatch` 获取 `dispatch` 函数来调用 actions 和 thunks。
+
+## 相关 Hooks
+
+在同一文件中还定义了两个辅助 Hook:
+
+- **`useTopicMessages(topic: Topic)`**:
+
+ - 使用 `selectMessagesForTopic` selector 来获取并返回指定主题的消息列表。
+
+- **`useTopicLoading(topic: Topic)`**:
+ - 使用 `selectNewTopicLoading` selector 来获取并返回指定主题的加载状态。
+
+这些 Hook 可以与 `useMessageOperations` 结合使用,方便地在组件中获取消息数据、加载状态,并执行相关操作。
diff --git a/docs/technical/message-lifecycle.png b/docs/technical/message-lifecycle.png
new file mode 100644
index 0000000000..95d6c52d1f
Binary files /dev/null and b/docs/technical/message-lifecycle.png differ
diff --git a/electron-builder.yml b/electron-builder.yml
index 6d3aa732cb..831d19c7fa 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -97,9 +97,8 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
- 新增对 grok-2-image 和 gpt-4o-image 图像支持
- 支持 Windows 便携版使用 data 目录存储数据
- MCP 界面改版,新增描述信息显示
- Mermaid 渲染逻辑优化
- 支持关闭公示渲染
- 修复 OpenAI 类型渲染错误
+ 新增划词助手
+ 助手支持分组
+ 支持主题颜色切换
+ 划词助手支持应用过滤
+ 翻译模块功能改进
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index fd5d5ac664..3983f92ba4 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -10,8 +10,7 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
export default defineConfig({
main: {
- plugins: [
- externalizeDepsPlugin({
+ plugins: [externalizeDepsPlugin({
exclude: [
'@cherrystudio/embedjs',
'@cherrystudio/embedjs-openai',
@@ -26,9 +25,7 @@ export default defineConfig({
'p-queue',
'webdav'
]
- }),
- ...visualizerPlugin('main')
- ],
+ }), ...visualizerPlugin('main')],
resolve: {
alias: {
'@main': resolve('src/main'),
@@ -38,7 +35,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
- external: ['@libsql/client'],
+ external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
plugins: [
{
name: 'inject-windows7-polyfill',
@@ -68,6 +65,9 @@ export default defineConfig({
alias: {
'@shared': resolve('packages/shared')
}
+ },
+ build: {
+ sourcemap: process.env.NODE_ENV === 'development'
}
},
renderer: {
@@ -98,7 +98,20 @@ export default defineConfig({
}
},
optimizeDeps: {
- exclude: []
+ exclude: ['pyodide']
+ },
+ worker: {
+ format: 'es'
+ },
+ build: {
+ rollupOptions: {
+ input: {
+ index: resolve(__dirname, 'src/renderer/index.html'),
+ miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
+ selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
+ selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
+ }
+ }
}
}
})
diff --git a/package.json b/package.json
index a3c688c08f..8738fe5277 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
- "version": "1.2.10",
+ "version": "1.4.1",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -20,8 +20,9 @@
"scripts": {
"start": "electron-vite preview",
"dev": "electron-vite dev",
+ "debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"build": "npm run typecheck && electron-vite build",
- "build:check": "yarn test && yarn typecheck && yarn check:i18n",
+ "build:check": "yarn typecheck && yarn check:i18n && yarn test",
"build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
@@ -38,44 +39,47 @@
"publish": "yarn build:check && yarn release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
"generate:agents": "yarn workspace @cherry-studio/database agents",
- "generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "node scripts/check-i18n.js",
- "test": "yarn test:renderer",
- "test:coverage": "yarn test:renderer:coverage",
- "test:node": "npx -y tsx --test src/**/*.test.ts",
- "test:renderer": "vitest run",
- "test:renderer:ui": "vitest --ui",
- "test:renderer:coverage": "vitest run --coverage",
+ "test": "vitest run --silent",
+ "test:main": "vitest run --project main",
+ "test:renderer": "vitest run --project renderer",
+ "test:update": "yarn test:renderer --update",
+ "test:coverage": "vitest run --coverage --silent",
+ "test:ui": "vitest --ui",
+ "test:watch": "vitest",
+ "test:e2e": "yarn playwright test",
+ "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
- "postinstall": "electron-builder install-app-deps",
"prepare": "husky"
},
"dependencies": {
- "@cherrystudio/embedjs": "^0.1.28",
- "@cherrystudio/embedjs-libsql": "^0.1.28",
- "@cherrystudio/embedjs-loader-csv": "^0.1.28",
- "@cherrystudio/embedjs-loader-image": "^0.1.28",
- "@cherrystudio/embedjs-loader-markdown": "^0.1.28",
- "@cherrystudio/embedjs-loader-msoffice": "^0.1.28",
- "@cherrystudio/embedjs-loader-pdf": "^0.1.28",
- "@cherrystudio/embedjs-loader-sitemap": "^0.1.28",
- "@cherrystudio/embedjs-loader-web": "^0.1.28",
- "@cherrystudio/embedjs-loader-xml": "^0.1.28",
- "@cherrystudio/embedjs-openai": "^0.1.28",
+ "@cherrystudio/embedjs": "^0.1.31",
+ "@cherrystudio/embedjs-libsql": "^0.1.31",
+ "@cherrystudio/embedjs-loader-csv": "^0.1.31",
+ "@cherrystudio/embedjs-loader-image": "^0.1.31",
+ "@cherrystudio/embedjs-loader-markdown": "^0.1.31",
+ "@cherrystudio/embedjs-loader-msoffice": "^0.1.31",
+ "@cherrystudio/embedjs-loader-pdf": "^0.1.31",
+ "@cherrystudio/embedjs-loader-sitemap": "^0.1.31",
+ "@cherrystudio/embedjs-loader-web": "^0.1.31",
+ "@cherrystudio/embedjs-loader-xml": "^0.1.31",
+ "@cherrystudio/embedjs-openai": "^0.1.31",
"@electron-toolkit/utils": "^3.0.0",
- "@electron/notarize": "^2.5.0",
"@langchain/community": "^0.3.36",
"@libsql/client": "^0.15.2",
"@libsql/win32-x64-msvc": "^0.5.4",
+ "@mozilla/readability": "^0.6.0",
+ "@notionhq/client": "^2.2.15",
+ "@peculiar/webcrypto": "^1.5.0",
"@strongtz/win32-arm64-msvc": "^0.4.7",
+ "@tanstack/react-query": "^5.27.0",
"@types/react-infinite-scroll-component": "^5.0.0",
- "adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"blob-polyfill": "^9.0.20240710",
@@ -86,14 +90,12 @@
"domexception": "^4.0.0",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
- "electron-updater": "patch:electron-updater@npm%3A6.6.3#~/.yarn/patches/electron-updater-npm-6.6.3-9269dbaf84.patch",
+ "electron-updater": "6.6.2",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
- "extract-zip": "^2.0.1",
"fast-xml-parser": "^5.2.0",
- "fetch-socks": "^1.3.2",
+ "franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
- "got-scraping": "^4.1.1",
"jsdom": "^26.0.0",
"libsql": "^0.5.4",
"markdown-it": "^14.1.0",
@@ -102,13 +104,13 @@
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
+ "selection-hook": "^0.9.21",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.4.0",
"web-streams-polyfill": "^4.1.0",
"webdav": "^5.8.0",
- "ws": "^8.18.1",
"zipread": "^1.3.3"
},
"devDependencies": {
@@ -116,27 +118,29 @@
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
- "@anthropic-ai/sdk": "^0.38.0",
+ "@anthropic-ai/sdk": "^0.41.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
+ "@electron/notarize": "^2.5.0",
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
- "@google/genai": "^0.10.0",
+ "@google/genai": "^1.0.1",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
- "@modelcontextprotocol/sdk": "^1.10.2",
+ "@modelcontextprotocol/sdk": "^1.11.4",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@peculiar/webcrypto": "^1.5.0",
"@reduxjs/toolkit": "^2.2.5",
- "@shikijs/markdown-it": "^3.2.2",
- "@swc/plugin-styled-components": "^7.1.3",
- "@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
+ "@shikijs/markdown-it": "^3.4.2",
+ "@swc/plugin-styled-components": "^7.1.5",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
"@tryfabric/martian": "^1.2.4",
- "@types/adm-zip": "^0",
"@types/diff": "^7",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
@@ -147,17 +151,21 @@
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
+ "@types/react-window": "^1",
"@types/tinycolor2": "^1",
- "@types/ws": "^8",
+ "@uiw/codemirror-extensions-langs": "^4.23.12",
+ "@uiw/codemirror-themes-all": "^4.23.12",
+ "@uiw/react-codemirror": "^4.23.12",
"@vitejs/plugin-react-swc": "^3.9.0",
- "@vitest/coverage-v8": "^3.1.1",
- "@vitest/ui": "^3.1.1",
+ "@vitest/browser": "^3.1.4",
+ "@vitest/coverage-v8": "^3.1.4",
+ "@vitest/ui": "^3.1.4",
+ "@vitest/web-worker": "^3.1.4",
"@xyflow/react": "^12.4.4",
"antd": "^5.22.5",
- "applescript": "^1.0.0",
"axios": "^1.7.3",
- "babel-plugin-styled-components": "^2.1.4",
"browser-image-compression": "^2.0.2",
+ "color": "^5.0.0",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
@@ -165,27 +173,31 @@
"electron": "22.3.23",
"electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
- "electron-icon-builder": "^2.0.1",
- "electron-vite": "^2.3.0",
+ "electron-vite": "^3.1.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
+ "fast-diff": "^1.3.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"i18next": "^23.11.5",
+ "jest-styled-components": "^7.2.0",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
+ "mermaid": "^11.6.0",
"mime": "^4.0.4",
+ "motion": "^12.10.5",
"npx-scope-finder": "^1.2.0",
- "openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
+ "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"p-queue": "^8.1.0",
+ "playwright": "^1.52.0",
"prettier": "^3.5.3",
- "rc-virtual-list": "^3.18.5",
+ "rc-virtual-list": "^3.18.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.6.1",
@@ -196,6 +208,7 @@
"react-router": "6",
"react-router-dom": "6",
"react-spinners": "^0.14.1",
+ "react-window": "^1.8.11",
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
@@ -205,33 +218,33 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
- "sass": "^1.77.2",
- "shiki": "^3.2.2",
+ "sass": "^1.88.0",
+ "shiki": "^3.4.2",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tiny-pinyin": "^1.3.2",
- "tinycolor2": "^1.6.0",
"tokenx": "^0.4.1",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "6.2.6",
- "vitest": "^3.1.1"
+ "vitest": "^3.1.4"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
- "node-gyp": "^9.1.0",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
- "openai@npm:^4.77.0": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
+ "openai@npm:^4.77.0": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"@types/domexception": "^4",
"electron": "22.3.23",
"electron-builder": "^24.9.1",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
- "shiki": "3.2.2"
+ "openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
+ "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
+ "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
},
- "packageManager": "yarn@4.6.0",
+ "packageManager": "yarn@4.9.1",
"lint-staged": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"prettier --write",
diff --git a/packages/artifacts/README.md b/packages/artifacts/README.md
deleted file mode 100644
index 944e891607..0000000000
--- a/packages/artifacts/README.md
+++ /dev/null
@@ -1 +0,0 @@
-# Cherry Studio Artifacts
diff --git a/packages/artifacts/package.json b/packages/artifacts/package.json
deleted file mode 100644
index 9adacafa85..0000000000
--- a/packages/artifacts/package.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "name": "@cherry-studio/artifacts",
- "version": "0.1.0",
- "description": "Cherry Studio Artifacts",
- "main": "index.js",
- "homepage": "https://github.com/kangfenmao/cherry-studio/blob/main/npm/artifacts",
- "publishConfig": {
- "access": "public",
- "registry": "https://registry.npmjs.org/"
- },
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "keywords": [
- "artifacts"
- ],
- "author": "kangfenmao",
- "license": "ISC"
-}
diff --git a/packages/artifacts/statics/word-explanation-card.css b/packages/artifacts/statics/word-explanation-card.css
deleted file mode 100644
index fadf580005..0000000000
--- a/packages/artifacts/statics/word-explanation-card.css
+++ /dev/null
@@ -1,108 +0,0 @@
-:root {
- /* 莫兰迪色系:使用柔和、低饱和度的颜色 */
- --primary-color: #b6b5a7; /* 莫兰迪灰褐色,用于背景文字 */
- --secondary-color: #9a8f8f; /* 莫兰迪灰棕色,用于标题背景 */
- --accent-color: #c5b4a0; /* 莫兰迪淡棕色,用于强调元素 */
- --background-color: #e8e3de; /* 莫兰迪米色,用于页面背景 */
- --text-color: #5b5b5b; /* 莫兰迪深灰色,用于主要文字 */
- --light-text-color: #8c8c8c; /* 莫兰迪中灰色,用于次要文字 */
- --divider-color: #d1cbc3; /* 莫兰迪浅灰色,用于分隔线 */
-}
-body,
-html {
- margin: 0;
- padding: 0;
- height: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
- background-color: var(--background-color); /* 使用莫兰迪米色作为页面背景 */
- font-family: 'Noto Sans SC', sans-serif;
- color: var(--text-color); /* 使用莫兰迪深灰色作为主要文字颜色 */
-}
-.card {
- width: 300px;
- height: 500px;
- background-color: #f2ede9; /* 莫兰迪浅米色,用于卡片背景 */
- border-radius: 20px;
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
- overflow: hidden;
- position: relative;
- display: flex;
- flex-direction: column;
-}
-.header {
- background-color: var(--secondary-color); /* 使用莫兰迪灰棕色作为标题背景 */
- color: #f2ede9; /* 浅色文字与深色背景形成对比 */
- padding: 20px;
- text-align: left;
- position: relative;
- z-index: 1;
-}
-h1 {
- font-family: 'Noto Serif SC', serif;
- font-size: 20px;
- margin: 0;
- font-weight: 700;
-}
-.content {
- padding: 30px 20px;
- display: flex;
- flex-direction: column;
- flex-grow: 1;
-}
-.word {
- text-align: left;
- margin-bottom: 20px;
-}
-.word-main {
- font-family: 'Noto Serif SC', serif;
- font-size: 36px;
- color: var(--text-color); /* 使用莫兰迪深灰色作为主要词汇颜色 */
- margin-bottom: 10px;
- position: relative;
-}
-.word-main::after {
- content: '';
- position: absolute;
- left: 0;
- bottom: -5px;
- width: 50px;
- height: 3px;
- background-color: var(--accent-color); /* 使用莫兰迪淡棕色作为下划线 */
-}
-.word-sub {
- font-size: 14px;
- color: var(--light-text-color); /* 使用莫兰迪中灰色作为次要文字颜色 */
- margin: 5px 0;
-}
-.divider {
- width: 100%;
- height: 1px;
- background-color: var(--divider-color); /* 使用莫兰迪浅灰色作为分隔线 */
- margin: 20px 0;
-}
-.explanation {
- font-size: 18px;
- line-height: 1.6;
- text-align: left;
- flex-grow: 1;
- display: flex;
- flex-direction: column;
- justify-content: center;
-}
-.quote {
- position: relative;
- padding-left: 20px;
- border-left: 3px solid var(--accent-color); /* 使用莫兰迪淡棕色作为引用边框 */
-}
-.background-text {
- position: absolute;
- font-size: 150px;
- color: rgba(182, 181, 167, 0.15); /* 使用莫兰迪灰褐色的透明版本作为背景文字 */
- z-index: 0;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- font-weight: bold;
-}
diff --git a/packages/database/.gitignore b/packages/database/.gitignore
deleted file mode 100644
index 542b065213..0000000000
--- a/packages/database/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-data/*
-!data/.gitkeep
-
diff --git a/packages/database/.yarn/install-state.gz b/packages/database/.yarn/install-state.gz
deleted file mode 100644
index e6c0e47b75..0000000000
Binary files a/packages/database/.yarn/install-state.gz and /dev/null differ
diff --git a/packages/database/README.md b/packages/database/README.md
deleted file mode 100644
index c0b2e7b087..0000000000
--- a/packages/database/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Cherry Studio Database
-
-Cherry Studio 依赖的数据文件由这个数据库来生成,数据库文件请联系开发者获取
diff --git a/packages/database/data/.gitkeep b/packages/database/data/.gitkeep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/packages/database/package.json b/packages/database/package.json
deleted file mode 100644
index 64ed88c918..0000000000
--- a/packages/database/package.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "name": "@cherry-studio/database",
- "packageManager": "yarn@4.6.0",
- "dependencies": {
- "csv-parser": "^3.0.0",
- "sqlite3": "^5.1.7"
- },
- "scripts": {
- "agents": "node src/agents.js",
- "email": "yarn csv && node src/email.js",
- "csv": "node src/csv.js"
- }
-}
diff --git a/packages/database/src/agents.js b/packages/database/src/agents.js
deleted file mode 100644
index f10b6012ea..0000000000
--- a/packages/database/src/agents.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const sqlite3 = require('sqlite3').verbose()
-const fs = require('fs')
-
-// 连接到数据库
-const db = new sqlite3.Database('./data/CherryStudio.sqlite3', (err) => {
- if (err) {
- console.error('Error connecting to the database:', err.message)
- return
- }
- console.log('Connected to the database.')
-})
-
-// 查询数据并转换为JSON
-db.all('SELECT * FROM agents', [], (err, rows) => {
- if (err) {
- console.error('Error querying the database:', err.message)
- return
- }
-
- // 将 ID 类型转换为字符串
- for (const row of rows) {
- row.id = row.id.toString()
- row.group = row.group.toString().split(',')
- row.group = row.group.map((item) => item.trim().replace('\r\n', ''))
- }
-
- // 将查询结果转换为JSON字符串
- const jsonData = JSON.stringify(rows, null, 2)
-
- // 将JSON数据写入文件
- fs.writeFile('../../src/renderer/src/config/agents.json', jsonData, (err) => {
- if (err) {
- console.error('Error writing to file:', err.message)
- return
- }
- console.log('Data has been written to agents.json')
- })
-
- // 关闭数据库连接
- db.close((err) => {
- if (err) {
- console.error('Error closing the database:', err.message)
- return
- }
- console.log('Database connection closed.')
- })
-})
diff --git a/packages/database/src/csv.js b/packages/database/src/csv.js
deleted file mode 100644
index de11a49b2d..0000000000
--- a/packages/database/src/csv.js
+++ /dev/null
@@ -1,77 +0,0 @@
-const fs = require('fs')
-const csv = require('csv-parser')
-const sqlite3 = require('sqlite3').verbose()
-
-// 连接到 SQLite 数据库
-const db = new sqlite3.Database('./data/CherryStudio.sqlite3', (err) => {
- if (err) {
- console.error('Error opening database', err)
- return
- }
- console.log('Connected to the SQLite database.')
-})
-
-// 创建一个数组来存储 CSV 数据
-const results = []
-
-// 读取 CSV 文件
-fs.createReadStream('./data/data.csv')
- .pipe(csv())
- .on('data', (data) => results.push(data))
- .on('end', () => {
- // 准备 SQL 插入语句,使用 INSERT OR IGNORE
- const stmt = db.prepare('INSERT OR IGNORE INTO emails (email, github, sent) VALUES (?, ?, ?)')
-
- // 插入每一行数据
- let inserted = 0
- let skipped = 0
- let emptyEmail = 0
-
- db.serialize(() => {
- // 开始一个事务以提高性能
- db.run('BEGIN TRANSACTION')
-
- results.forEach((row) => {
- // 检查 email 是否为空
- if (!row.email || row.email.trim() === '') {
- emptyEmail++
- return // 跳过这一行
- }
-
- stmt.run(row.email, row['user-href'], 0, function (err) {
- if (err) {
- console.error('Error inserting row', err)
- } else {
- if (this.changes === 1) {
- inserted++
- } else {
- skipped++
- }
- }
- })
- })
-
- // 提交事务
- db.run('COMMIT', (err) => {
- if (err) {
- console.error('Error committing transaction', err)
- } else {
- console.log(
- `Insertion complete. Inserted: ${inserted}, Skipped (duplicate): ${skipped}, Skipped (empty email): ${emptyEmail}`
- )
- }
-
- // 完成插入
- stmt.finalize()
-
- // 关闭数据库连接
- db.close((err) => {
- if (err) {
- console.error('Error closing database', err)
- } else {
- console.log('Database connection closed.')
- }
- })
- })
- })
- })
diff --git a/packages/database/src/email.js b/packages/database/src/email.js
deleted file mode 100644
index 6c8bc77b40..0000000000
--- a/packages/database/src/email.js
+++ /dev/null
@@ -1,36 +0,0 @@
-const sqlite3 = require('sqlite3').verbose()
-
-// 连接到数据库
-const db = new sqlite3.Database('./data/CherryStudio.sqlite3', (err) => {
- if (err) {
- console.error('Error connecting to the database:', err.message)
- return
- }
-})
-
-// 查询数据并转换为JSON
-db.all('SELECT * FROM emails WHERE sent = 0', [], (err, rows) => {
- if (err) {
- console.error('Error querying the database:', err.message)
- return
- }
-
- for (const row of rows) {
- console.log(row.email)
- // Update row set sent = 1
- db.run('UPDATE emails SET sent = 1 WHERE id = ?', [row.id], (err) => {
- if (err) {
- console.error('Error updating the database:', err.message)
- return
- }
- })
- }
-
- // 关闭数据库连接
- db.close((err) => {
- if (err) {
- console.error('Error closing the database:', err.message)
- return
- }
- })
-})
diff --git a/packages/database/yarn.lock b/packages/database/yarn.lock
deleted file mode 100644
index f044300033..0000000000
--- a/packages/database/yarn.lock
+++ /dev/null
@@ -1,1643 +0,0 @@
-# This file is generated by running "yarn install" inside your project.
-# Manual changes might be lost - proceed with caution!
-
-__metadata:
- version: 8
- cacheKey: 10c0
-
-"@cherry-studio/database@workspace:.":
- version: 0.0.0-use.local
- resolution: "@cherry-studio/database@workspace:."
- dependencies:
- csv-parser: "npm:^3.0.0"
- sqlite3: "npm:^5.1.7"
- languageName: unknown
- linkType: soft
-
-"@gar/promisify@npm:^1.0.1":
- version: 1.1.3
- resolution: "@gar/promisify@npm:1.1.3"
- checksum: 10c0/0b3c9958d3cd17f4add3574975e3115ae05dc7f1298a60810414b16f6f558c137b5fb3cd3905df380bacfd955ec13f67c1e6710cbb5c246a7e8d65a8289b2bff
- languageName: node
- linkType: hard
-
-"@isaacs/cliui@npm:^8.0.2":
- version: 8.0.2
- resolution: "@isaacs/cliui@npm:8.0.2"
- dependencies:
- string-width: "npm:^5.1.2"
- string-width-cjs: "npm:string-width@^4.2.0"
- strip-ansi: "npm:^7.0.1"
- strip-ansi-cjs: "npm:strip-ansi@^6.0.1"
- wrap-ansi: "npm:^8.1.0"
- wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0"
- checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e
- languageName: node
- linkType: hard
-
-"@npmcli/agent@npm:^2.0.0":
- version: 2.2.2
- resolution: "@npmcli/agent@npm:2.2.2"
- dependencies:
- agent-base: "npm:^7.1.0"
- http-proxy-agent: "npm:^7.0.0"
- https-proxy-agent: "npm:^7.0.1"
- lru-cache: "npm:^10.0.1"
- socks-proxy-agent: "npm:^8.0.3"
- checksum: 10c0/325e0db7b287d4154ecd164c0815c08007abfb07653cc57bceded17bb7fd240998a3cbdbe87d700e30bef494885eccc725ab73b668020811d56623d145b524ae
- languageName: node
- linkType: hard
-
-"@npmcli/fs@npm:^1.0.0":
- version: 1.1.1
- resolution: "@npmcli/fs@npm:1.1.1"
- dependencies:
- "@gar/promisify": "npm:^1.0.1"
- semver: "npm:^7.3.5"
- checksum: 10c0/4143c317a7542af9054018b71601e3c3392e6704e884561229695f099a71336cbd580df9a9ffb965d0024bf0ed593189ab58900fd1714baef1c9ee59c738c3e2
- languageName: node
- linkType: hard
-
-"@npmcli/fs@npm:^3.1.0":
- version: 3.1.1
- resolution: "@npmcli/fs@npm:3.1.1"
- dependencies:
- semver: "npm:^7.3.5"
- checksum: 10c0/c37a5b4842bfdece3d14dfdb054f73fe15ed2d3da61b34ff76629fb5b1731647c49166fd2a8bf8b56fcfa51200382385ea8909a3cbecdad612310c114d3f6c99
- languageName: node
- linkType: hard
-
-"@npmcli/move-file@npm:^1.0.1":
- version: 1.1.2
- resolution: "@npmcli/move-file@npm:1.1.2"
- dependencies:
- mkdirp: "npm:^1.0.4"
- rimraf: "npm:^3.0.2"
- checksum: 10c0/02e946f3dafcc6743132fe2e0e2b585a96ca7265653a38df5a3e53fcf26c7c7a57fc0f861d7c689a23fdb6d6836c7eea5050c8086abf3c994feb2208d1514ff0
- languageName: node
- linkType: hard
-
-"@pkgjs/parseargs@npm:^0.11.0":
- version: 0.11.0
- resolution: "@pkgjs/parseargs@npm:0.11.0"
- checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd
- languageName: node
- linkType: hard
-
-"@tootallnate/once@npm:1":
- version: 1.1.2
- resolution: "@tootallnate/once@npm:1.1.2"
- checksum: 10c0/8fe4d006e90422883a4fa9339dd05a83ff626806262e1710cee5758d493e8cbddf2db81c0e4690636dc840b02c9fda62877866ea774ebd07c1777ed5fafbdec6
- languageName: node
- linkType: hard
-
-"abbrev@npm:1":
- version: 1.1.1
- resolution: "abbrev@npm:1.1.1"
- checksum: 10c0/3f762677702acb24f65e813070e306c61fafe25d4b2583f9dfc935131f774863f3addd5741572ed576bd69cabe473c5af18e1e108b829cb7b6b4747884f726e6
- languageName: node
- linkType: hard
-
-"abbrev@npm:^2.0.0":
- version: 2.0.0
- resolution: "abbrev@npm:2.0.0"
- checksum: 10c0/f742a5a107473946f426c691c08daba61a1d15942616f300b5d32fd735be88fef5cba24201757b6c407fd564555fb48c751cfa33519b2605c8a7aadd22baf372
- languageName: node
- linkType: hard
-
-"agent-base@npm:6, agent-base@npm:^6.0.2":
- version: 6.0.2
- resolution: "agent-base@npm:6.0.2"
- dependencies:
- debug: "npm:4"
- checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261
- languageName: node
- linkType: hard
-
-"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1":
- version: 7.1.1
- resolution: "agent-base@npm:7.1.1"
- dependencies:
- debug: "npm:^4.3.4"
- checksum: 10c0/e59ce7bed9c63bf071a30cc471f2933862044c97fd9958967bfe22521d7a0f601ce4ed5a8c011799d0c726ca70312142ae193bbebb60f576b52be19d4a363b50
- languageName: node
- linkType: hard
-
-"agentkeepalive@npm:^4.1.3":
- version: 4.5.0
- resolution: "agentkeepalive@npm:4.5.0"
- dependencies:
- humanize-ms: "npm:^1.2.1"
- checksum: 10c0/394ea19f9710f230722996e156607f48fdf3a345133b0b1823244b7989426c16019a428b56c82d3eabef616e938812981d9009f4792ecc66bd6a59e991c62612
- languageName: node
- linkType: hard
-
-"aggregate-error@npm:^3.0.0":
- version: 3.1.0
- resolution: "aggregate-error@npm:3.1.0"
- dependencies:
- clean-stack: "npm:^2.0.0"
- indent-string: "npm:^4.0.0"
- checksum: 10c0/a42f67faa79e3e6687a4923050e7c9807db3848a037076f791d10e092677d65c1d2d863b7848560699f40fc0502c19f40963fb1cd1fb3d338a7423df8e45e039
- languageName: node
- linkType: hard
-
-"ansi-regex@npm:^5.0.1":
- version: 5.0.1
- resolution: "ansi-regex@npm:5.0.1"
- checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737
- languageName: node
- linkType: hard
-
-"ansi-regex@npm:^6.0.1":
- version: 6.0.1
- resolution: "ansi-regex@npm:6.0.1"
- checksum: 10c0/cbe16dbd2c6b2735d1df7976a7070dd277326434f0212f43abf6d87674095d247968209babdaad31bb00882fa68807256ba9be340eec2f1004de14ca75f52a08
- languageName: node
- linkType: hard
-
-"ansi-styles@npm:^4.0.0":
- version: 4.3.0
- resolution: "ansi-styles@npm:4.3.0"
- dependencies:
- color-convert: "npm:^2.0.1"
- checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041
- languageName: node
- linkType: hard
-
-"ansi-styles@npm:^6.1.0":
- version: 6.2.1
- resolution: "ansi-styles@npm:6.2.1"
- checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c
- languageName: node
- linkType: hard
-
-"aproba@npm:^1.0.3 || ^2.0.0":
- version: 2.0.0
- resolution: "aproba@npm:2.0.0"
- checksum: 10c0/d06e26384a8f6245d8c8896e138c0388824e259a329e0c9f196b4fa533c82502a6fd449586e3604950a0c42921832a458bb3aa0aa9f0ba449cfd4f50fd0d09b5
- languageName: node
- linkType: hard
-
-"are-we-there-yet@npm:^3.0.0":
- version: 3.0.1
- resolution: "are-we-there-yet@npm:3.0.1"
- dependencies:
- delegates: "npm:^1.0.0"
- readable-stream: "npm:^3.6.0"
- checksum: 10c0/8373f289ba42e4b5ec713bb585acdac14b5702c75f2a458dc985b9e4fa5762bc5b46b40a21b72418a3ed0cfb5e35bdc317ef1ae132f3035f633d581dd03168c3
- languageName: node
- linkType: hard
-
-"balanced-match@npm:^1.0.0":
- version: 1.0.2
- resolution: "balanced-match@npm:1.0.2"
- checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee
- languageName: node
- linkType: hard
-
-"base64-js@npm:^1.3.1":
- version: 1.5.1
- resolution: "base64-js@npm:1.5.1"
- checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf
- languageName: node
- linkType: hard
-
-"bindings@npm:^1.5.0":
- version: 1.5.0
- resolution: "bindings@npm:1.5.0"
- dependencies:
- file-uri-to-path: "npm:1.0.0"
- checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba
- languageName: node
- linkType: hard
-
-"bl@npm:^4.0.3":
- version: 4.1.0
- resolution: "bl@npm:4.1.0"
- dependencies:
- buffer: "npm:^5.5.0"
- inherits: "npm:^2.0.4"
- readable-stream: "npm:^3.4.0"
- checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f
- languageName: node
- linkType: hard
-
-"brace-expansion@npm:^1.1.7":
- version: 1.1.11
- resolution: "brace-expansion@npm:1.1.11"
- dependencies:
- balanced-match: "npm:^1.0.0"
- concat-map: "npm:0.0.1"
- checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668
- languageName: node
- linkType: hard
-
-"brace-expansion@npm:^2.0.1":
- version: 2.0.1
- resolution: "brace-expansion@npm:2.0.1"
- dependencies:
- balanced-match: "npm:^1.0.0"
- checksum: 10c0/b358f2fe060e2d7a87aa015979ecea07f3c37d4018f8d6deb5bd4c229ad3a0384fe6029bb76cd8be63c81e516ee52d1a0673edbe2023d53a5191732ae3c3e49f
- languageName: node
- linkType: hard
-
-"buffer@npm:^5.5.0":
- version: 5.7.1
- resolution: "buffer@npm:5.7.1"
- dependencies:
- base64-js: "npm:^1.3.1"
- ieee754: "npm:^1.1.13"
- checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e
- languageName: node
- linkType: hard
-
-"cacache@npm:^15.2.0":
- version: 15.3.0
- resolution: "cacache@npm:15.3.0"
- dependencies:
- "@npmcli/fs": "npm:^1.0.0"
- "@npmcli/move-file": "npm:^1.0.1"
- chownr: "npm:^2.0.0"
- fs-minipass: "npm:^2.0.0"
- glob: "npm:^7.1.4"
- infer-owner: "npm:^1.0.4"
- lru-cache: "npm:^6.0.0"
- minipass: "npm:^3.1.1"
- minipass-collect: "npm:^1.0.2"
- minipass-flush: "npm:^1.0.5"
- minipass-pipeline: "npm:^1.2.2"
- mkdirp: "npm:^1.0.3"
- p-map: "npm:^4.0.0"
- promise-inflight: "npm:^1.0.1"
- rimraf: "npm:^3.0.2"
- ssri: "npm:^8.0.1"
- tar: "npm:^6.0.2"
- unique-filename: "npm:^1.1.1"
- checksum: 10c0/886fcc0acc4f6fd5cd142d373d8276267bc6d655d7c4ce60726fbbec10854de3395ee19bbf9e7e73308cdca9fdad0ad55060ff3bd16c6d4165c5b8d21515e1d8
- languageName: node
- linkType: hard
-
-"cacache@npm:^18.0.0":
- version: 18.0.4
- resolution: "cacache@npm:18.0.4"
- dependencies:
- "@npmcli/fs": "npm:^3.1.0"
- fs-minipass: "npm:^3.0.0"
- glob: "npm:^10.2.2"
- lru-cache: "npm:^10.0.1"
- minipass: "npm:^7.0.3"
- minipass-collect: "npm:^2.0.1"
- minipass-flush: "npm:^1.0.5"
- minipass-pipeline: "npm:^1.2.4"
- p-map: "npm:^4.0.0"
- ssri: "npm:^10.0.0"
- tar: "npm:^6.1.11"
- unique-filename: "npm:^3.0.0"
- checksum: 10c0/6c055bafed9de4f3dcc64ac3dc7dd24e863210902b7c470eb9ce55a806309b3efff78033e3d8b4f7dcc5d467f2db43c6a2857aaaf26f0094b8a351d44c42179f
- languageName: node
- linkType: hard
-
-"chownr@npm:^1.1.1":
- version: 1.1.4
- resolution: "chownr@npm:1.1.4"
- checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db
- languageName: node
- linkType: hard
-
-"chownr@npm:^2.0.0":
- version: 2.0.0
- resolution: "chownr@npm:2.0.0"
- checksum: 10c0/594754e1303672171cc04e50f6c398ae16128eb134a88f801bf5354fd96f205320f23536a045d9abd8b51024a149696e51231565891d4efdab8846021ecf88e6
- languageName: node
- linkType: hard
-
-"clean-stack@npm:^2.0.0":
- version: 2.2.0
- resolution: "clean-stack@npm:2.2.0"
- checksum: 10c0/1f90262d5f6230a17e27d0c190b09d47ebe7efdd76a03b5a1127863f7b3c9aec4c3e6c8bb3a7bbf81d553d56a1fd35728f5a8ef4c63f867ac8d690109742a8c1
- languageName: node
- linkType: hard
-
-"color-convert@npm:^2.0.1":
- version: 2.0.1
- resolution: "color-convert@npm:2.0.1"
- dependencies:
- color-name: "npm:~1.1.4"
- checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7
- languageName: node
- linkType: hard
-
-"color-name@npm:~1.1.4":
- version: 1.1.4
- resolution: "color-name@npm:1.1.4"
- checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95
- languageName: node
- linkType: hard
-
-"color-support@npm:^1.1.3":
- version: 1.1.3
- resolution: "color-support@npm:1.1.3"
- bin:
- color-support: bin.js
- checksum: 10c0/8ffeaa270a784dc382f62d9be0a98581db43e11eee301af14734a6d089bd456478b1a8b3e7db7ca7dc5b18a75f828f775c44074020b51c05fc00e6d0992b1cc6
- languageName: node
- linkType: hard
-
-"concat-map@npm:0.0.1":
- version: 0.0.1
- resolution: "concat-map@npm:0.0.1"
- checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f
- languageName: node
- linkType: hard
-
-"console-control-strings@npm:^1.1.0":
- version: 1.1.0
- resolution: "console-control-strings@npm:1.1.0"
- checksum: 10c0/7ab51d30b52d461412cd467721bb82afe695da78fff8f29fe6f6b9cbaac9a2328e27a22a966014df9532100f6dd85370460be8130b9c677891ba36d96a343f50
- languageName: node
- linkType: hard
-
-"cross-spawn@npm:^7.0.0":
- version: 7.0.3
- resolution: "cross-spawn@npm:7.0.3"
- dependencies:
- path-key: "npm:^3.1.0"
- shebang-command: "npm:^2.0.0"
- which: "npm:^2.0.1"
- checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750
- languageName: node
- linkType: hard
-
-"csv-parser@npm:^3.0.0":
- version: 3.0.0
- resolution: "csv-parser@npm:3.0.0"
- dependencies:
- minimist: "npm:^1.2.0"
- bin:
- csv-parser: bin/csv-parser
- checksum: 10c0/206aef102c10d532a31c7d85e6b1b0e53c7cb8346037eb9f23e0bd7369788960d8f2431639ea9f62e34ddf54d0182dfb345691c11c666802324f25c51dba79bc
- languageName: node
- linkType: hard
-
-"debug@npm:4, debug@npm:^4.3.3, debug@npm:^4.3.4":
- version: 4.3.5
- resolution: "debug@npm:4.3.5"
- dependencies:
- ms: "npm:2.1.2"
- peerDependenciesMeta:
- supports-color:
- optional: true
- checksum: 10c0/082c375a2bdc4f4469c99f325ff458adad62a3fc2c482d59923c260cb08152f34e2659f72b3767db8bb2f21ca81a60a42d1019605a412132d7b9f59363a005cc
- languageName: node
- linkType: hard
-
-"decompress-response@npm:^6.0.0":
- version: 6.0.0
- resolution: "decompress-response@npm:6.0.0"
- dependencies:
- mimic-response: "npm:^3.1.0"
- checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e
- languageName: node
- linkType: hard
-
-"deep-extend@npm:^0.6.0":
- version: 0.6.0
- resolution: "deep-extend@npm:0.6.0"
- checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566
- languageName: node
- linkType: hard
-
-"delegates@npm:^1.0.0":
- version: 1.0.0
- resolution: "delegates@npm:1.0.0"
- checksum: 10c0/ba05874b91148e1db4bf254750c042bf2215febd23a6d3cda2e64896aef79745fbd4b9996488bd3cafb39ce19dbce0fd6e3b6665275638befffe1c9b312b91b5
- languageName: node
- linkType: hard
-
-"detect-libc@npm:^2.0.0":
- version: 2.0.3
- resolution: "detect-libc@npm:2.0.3"
- checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7
- languageName: node
- linkType: hard
-
-"eastasianwidth@npm:^0.2.0":
- version: 0.2.0
- resolution: "eastasianwidth@npm:0.2.0"
- checksum: 10c0/26f364ebcdb6395f95124fda411f63137a4bfb5d3a06453f7f23dfe52502905bd84e0488172e0f9ec295fdc45f05c23d5d91baf16bd26f0fe9acd777a188dc39
- languageName: node
- linkType: hard
-
-"emoji-regex@npm:^8.0.0":
- version: 8.0.0
- resolution: "emoji-regex@npm:8.0.0"
- checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010
- languageName: node
- linkType: hard
-
-"emoji-regex@npm:^9.2.2":
- version: 9.2.2
- resolution: "emoji-regex@npm:9.2.2"
- checksum: 10c0/af014e759a72064cf66e6e694a7fc6b0ed3d8db680427b021a89727689671cefe9d04151b2cad51dbaf85d5ba790d061cd167f1cf32eb7b281f6368b3c181639
- languageName: node
- linkType: hard
-
-"encoding@npm:^0.1.12, encoding@npm:^0.1.13":
- version: 0.1.13
- resolution: "encoding@npm:0.1.13"
- dependencies:
- iconv-lite: "npm:^0.6.2"
- checksum: 10c0/36d938712ff00fe1f4bac88b43bcffb5930c1efa57bbcdca9d67e1d9d6c57cfb1200fb01efe0f3109b2ce99b231f90779532814a81370a1bd3274a0f58585039
- languageName: node
- linkType: hard
-
-"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1":
- version: 1.4.4
- resolution: "end-of-stream@npm:1.4.4"
- dependencies:
- once: "npm:^1.4.0"
- checksum: 10c0/870b423afb2d54bb8d243c63e07c170409d41e20b47eeef0727547aea5740bd6717aca45597a9f2745525667a6b804c1e7bede41f856818faee5806dd9ff3975
- languageName: node
- linkType: hard
-
-"env-paths@npm:^2.2.0":
- version: 2.2.1
- resolution: "env-paths@npm:2.2.1"
- checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4
- languageName: node
- linkType: hard
-
-"err-code@npm:^2.0.2":
- version: 2.0.3
- resolution: "err-code@npm:2.0.3"
- checksum: 10c0/b642f7b4dd4a376e954947550a3065a9ece6733ab8e51ad80db727aaae0817c2e99b02a97a3d6cecc648a97848305e728289cf312d09af395403a90c9d4d8a66
- languageName: node
- linkType: hard
-
-"expand-template@npm:^2.0.3":
- version: 2.0.3
- resolution: "expand-template@npm:2.0.3"
- checksum: 10c0/1c9e7afe9acadf9d373301d27f6a47b34e89b3391b1ef38b7471d381812537ef2457e620ae7f819d2642ce9c43b189b3583813ec395e2938319abe356a9b2f51
- languageName: node
- linkType: hard
-
-"exponential-backoff@npm:^3.1.1":
- version: 3.1.1
- resolution: "exponential-backoff@npm:3.1.1"
- checksum: 10c0/160456d2d647e6019640bd07111634d8c353038d9fa40176afb7cd49b0548bdae83b56d05e907c2cce2300b81cae35d800ef92fefb9d0208e190fa3b7d6bb579
- languageName: node
- linkType: hard
-
-"file-uri-to-path@npm:1.0.0":
- version: 1.0.0
- resolution: "file-uri-to-path@npm:1.0.0"
- checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519
- languageName: node
- linkType: hard
-
-"foreground-child@npm:^3.1.0":
- version: 3.2.1
- resolution: "foreground-child@npm:3.2.1"
- dependencies:
- cross-spawn: "npm:^7.0.0"
- signal-exit: "npm:^4.0.1"
- checksum: 10c0/9a53a33dbd87090e9576bef65fb4a71de60f6863a8062a7b11bc1cbe3cc86d428677d7c0b9ef61cdac11007ac580006f78bd5638618d564cfd5e6fd713d6878f
- languageName: node
- linkType: hard
-
-"fs-constants@npm:^1.0.0":
- version: 1.0.0
- resolution: "fs-constants@npm:1.0.0"
- checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8
- languageName: node
- linkType: hard
-
-"fs-minipass@npm:^2.0.0":
- version: 2.1.0
- resolution: "fs-minipass@npm:2.1.0"
- dependencies:
- minipass: "npm:^3.0.0"
- checksum: 10c0/703d16522b8282d7299337539c3ed6edddd1afe82435e4f5b76e34a79cd74e488a8a0e26a636afc2440e1a23b03878e2122e3a2cfe375a5cf63c37d92b86a004
- languageName: node
- linkType: hard
-
-"fs-minipass@npm:^3.0.0":
- version: 3.0.3
- resolution: "fs-minipass@npm:3.0.3"
- dependencies:
- minipass: "npm:^7.0.3"
- checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94
- languageName: node
- linkType: hard
-
-"fs.realpath@npm:^1.0.0":
- version: 1.0.0
- resolution: "fs.realpath@npm:1.0.0"
- checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948
- languageName: node
- linkType: hard
-
-"gauge@npm:^4.0.3":
- version: 4.0.4
- resolution: "gauge@npm:4.0.4"
- dependencies:
- aproba: "npm:^1.0.3 || ^2.0.0"
- color-support: "npm:^1.1.3"
- console-control-strings: "npm:^1.1.0"
- has-unicode: "npm:^2.0.1"
- signal-exit: "npm:^3.0.7"
- string-width: "npm:^4.2.3"
- strip-ansi: "npm:^6.0.1"
- wide-align: "npm:^1.1.5"
- checksum: 10c0/ef10d7981113d69225135f994c9f8c4369d945e64a8fc721d655a3a38421b738c9fe899951721d1b47b73c41fdb5404ac87cc8903b2ecbed95d2800363e7e58c
- languageName: node
- linkType: hard
-
-"github-from-package@npm:0.0.0":
- version: 0.0.0
- resolution: "github-from-package@npm:0.0.0"
- checksum: 10c0/737ee3f52d0a27e26332cde85b533c21fcdc0b09fb716c3f8e522cfaa9c600d4a631dec9fcde179ec9d47cca89017b7848ed4d6ae6b6b78f936c06825b1fcc12
- languageName: node
- linkType: hard
-
-"glob@npm:^10.2.2, glob@npm:^10.3.10":
- version: 10.4.5
- resolution: "glob@npm:10.4.5"
- dependencies:
- foreground-child: "npm:^3.1.0"
- jackspeak: "npm:^3.1.2"
- minimatch: "npm:^9.0.4"
- minipass: "npm:^7.1.2"
- package-json-from-dist: "npm:^1.0.0"
- path-scurry: "npm:^1.11.1"
- bin:
- glob: dist/esm/bin.mjs
- checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e
- languageName: node
- linkType: hard
-
-"glob@npm:^7.1.3, glob@npm:^7.1.4":
- version: 7.2.3
- resolution: "glob@npm:7.2.3"
- dependencies:
- fs.realpath: "npm:^1.0.0"
- inflight: "npm:^1.0.4"
- inherits: "npm:2"
- minimatch: "npm:^3.1.1"
- once: "npm:^1.3.0"
- path-is-absolute: "npm:^1.0.0"
- checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe
- languageName: node
- linkType: hard
-
-"graceful-fs@npm:^4.2.6":
- version: 4.2.11
- resolution: "graceful-fs@npm:4.2.11"
- checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
- languageName: node
- linkType: hard
-
-"has-unicode@npm:^2.0.1":
- version: 2.0.1
- resolution: "has-unicode@npm:2.0.1"
- checksum: 10c0/ebdb2f4895c26bb08a8a100b62d362e49b2190bcfd84b76bc4be1a3bd4d254ec52d0dd9f2fbcc093fc5eb878b20c52146f9dfd33e2686ed28982187be593b47c
- languageName: node
- linkType: hard
-
-"http-cache-semantics@npm:^4.1.0, http-cache-semantics@npm:^4.1.1":
- version: 4.1.1
- resolution: "http-cache-semantics@npm:4.1.1"
- checksum: 10c0/ce1319b8a382eb3cbb4a37c19f6bfe14e5bb5be3d09079e885e8c513ab2d3cd9214902f8a31c9dc4e37022633ceabfc2d697405deeaf1b8f3552bb4ed996fdfc
- languageName: node
- linkType: hard
-
-"http-proxy-agent@npm:^4.0.1":
- version: 4.0.1
- resolution: "http-proxy-agent@npm:4.0.1"
- dependencies:
- "@tootallnate/once": "npm:1"
- agent-base: "npm:6"
- debug: "npm:4"
- checksum: 10c0/4fa4774d65b5331814b74ac05cefea56854fc0d5989c80b13432c1b0d42a14c9f4342ca3ad9f0359a52e78da12b1744c9f8a28e50042136ea9171675d972a5fd
- languageName: node
- linkType: hard
-
-"http-proxy-agent@npm:^7.0.0":
- version: 7.0.2
- resolution: "http-proxy-agent@npm:7.0.2"
- dependencies:
- agent-base: "npm:^7.1.0"
- debug: "npm:^4.3.4"
- checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921
- languageName: node
- linkType: hard
-
-"https-proxy-agent@npm:^5.0.0":
- version: 5.0.1
- resolution: "https-proxy-agent@npm:5.0.1"
- dependencies:
- agent-base: "npm:6"
- debug: "npm:4"
- checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1
- languageName: node
- linkType: hard
-
-"https-proxy-agent@npm:^7.0.1":
- version: 7.0.5
- resolution: "https-proxy-agent@npm:7.0.5"
- dependencies:
- agent-base: "npm:^7.0.2"
- debug: "npm:4"
- checksum: 10c0/2490e3acec397abeb88807db52cac59102d5ed758feee6df6112ab3ccd8325e8a1ce8bce6f4b66e5470eca102d31e425ace904242e4fa28dbe0c59c4bafa7b2c
- languageName: node
- linkType: hard
-
-"humanize-ms@npm:^1.2.1":
- version: 1.2.1
- resolution: "humanize-ms@npm:1.2.1"
- dependencies:
- ms: "npm:^2.0.0"
- checksum: 10c0/f34a2c20161d02303c2807badec2f3b49cbfbbb409abd4f95a07377ae01cfe6b59e3d15ac609cffcd8f2521f0eb37b7e1091acf65da99aa2a4f1ad63c21e7e7a
- languageName: node
- linkType: hard
-
-"iconv-lite@npm:^0.6.2":
- version: 0.6.3
- resolution: "iconv-lite@npm:0.6.3"
- dependencies:
- safer-buffer: "npm:>= 2.1.2 < 3.0.0"
- checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1
- languageName: node
- linkType: hard
-
-"ieee754@npm:^1.1.13":
- version: 1.2.1
- resolution: "ieee754@npm:1.2.1"
- checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb
- languageName: node
- linkType: hard
-
-"imurmurhash@npm:^0.1.4":
- version: 0.1.4
- resolution: "imurmurhash@npm:0.1.4"
- checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6
- languageName: node
- linkType: hard
-
-"indent-string@npm:^4.0.0":
- version: 4.0.0
- resolution: "indent-string@npm:4.0.0"
- checksum: 10c0/1e1904ddb0cb3d6cce7cd09e27a90184908b7a5d5c21b92e232c93579d314f0b83c246ffb035493d0504b1e9147ba2c9b21df0030f48673fba0496ecd698161f
- languageName: node
- linkType: hard
-
-"infer-owner@npm:^1.0.4":
- version: 1.0.4
- resolution: "infer-owner@npm:1.0.4"
- checksum: 10c0/a7b241e3149c26e37474e3435779487f42f36883711f198c45794703c7556bc38af224088bd4d1a221a45b8208ae2c2bcf86200383621434d0c099304481c5b9
- languageName: node
- linkType: hard
-
-"inflight@npm:^1.0.4":
- version: 1.0.6
- resolution: "inflight@npm:1.0.6"
- dependencies:
- once: "npm:^1.3.0"
- wrappy: "npm:1"
- checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2
- languageName: node
- linkType: hard
-
-"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4":
- version: 2.0.4
- resolution: "inherits@npm:2.0.4"
- checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
- languageName: node
- linkType: hard
-
-"ini@npm:~1.3.0":
- version: 1.3.8
- resolution: "ini@npm:1.3.8"
- checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a
- languageName: node
- linkType: hard
-
-"ip-address@npm:^9.0.5":
- version: 9.0.5
- resolution: "ip-address@npm:9.0.5"
- dependencies:
- jsbn: "npm:1.1.0"
- sprintf-js: "npm:^1.1.3"
- checksum: 10c0/331cd07fafcb3b24100613e4b53e1a2b4feab11e671e655d46dc09ee233da5011284d09ca40c4ecbdfe1d0004f462958675c224a804259f2f78d2465a87824bc
- languageName: node
- linkType: hard
-
-"is-fullwidth-code-point@npm:^3.0.0":
- version: 3.0.0
- resolution: "is-fullwidth-code-point@npm:3.0.0"
- checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc
- languageName: node
- linkType: hard
-
-"is-lambda@npm:^1.0.1":
- version: 1.0.1
- resolution: "is-lambda@npm:1.0.1"
- checksum: 10c0/85fee098ae62ba6f1e24cf22678805473c7afd0fb3978a3aa260e354cb7bcb3a5806cf0a98403188465efedec41ab4348e8e4e79305d409601323855b3839d4d
- languageName: node
- linkType: hard
-
-"isexe@npm:^2.0.0":
- version: 2.0.0
- resolution: "isexe@npm:2.0.0"
- checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d
- languageName: node
- linkType: hard
-
-"isexe@npm:^3.1.1":
- version: 3.1.1
- resolution: "isexe@npm:3.1.1"
- checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7
- languageName: node
- linkType: hard
-
-"jackspeak@npm:^3.1.2":
- version: 3.4.3
- resolution: "jackspeak@npm:3.4.3"
- dependencies:
- "@isaacs/cliui": "npm:^8.0.2"
- "@pkgjs/parseargs": "npm:^0.11.0"
- dependenciesMeta:
- "@pkgjs/parseargs":
- optional: true
- checksum: 10c0/6acc10d139eaefdbe04d2f679e6191b3abf073f111edf10b1de5302c97ec93fffeb2fdd8681ed17f16268aa9dd4f8c588ed9d1d3bffbbfa6e8bf897cbb3149b9
- languageName: node
- linkType: hard
-
-"jsbn@npm:1.1.0":
- version: 1.1.0
- resolution: "jsbn@npm:1.1.0"
- checksum: 10c0/4f907fb78d7b712e11dea8c165fe0921f81a657d3443dde75359ed52eb2b5d33ce6773d97985a089f09a65edd80b11cb75c767b57ba47391fee4c969f7215c96
- languageName: node
- linkType: hard
-
-"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0":
- version: 10.4.3
- resolution: "lru-cache@npm:10.4.3"
- checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb
- languageName: node
- linkType: hard
-
-"lru-cache@npm:^6.0.0":
- version: 6.0.0
- resolution: "lru-cache@npm:6.0.0"
- dependencies:
- yallist: "npm:^4.0.0"
- checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9
- languageName: node
- linkType: hard
-
-"make-fetch-happen@npm:^13.0.0":
- version: 13.0.1
- resolution: "make-fetch-happen@npm:13.0.1"
- dependencies:
- "@npmcli/agent": "npm:^2.0.0"
- cacache: "npm:^18.0.0"
- http-cache-semantics: "npm:^4.1.1"
- is-lambda: "npm:^1.0.1"
- minipass: "npm:^7.0.2"
- minipass-fetch: "npm:^3.0.0"
- minipass-flush: "npm:^1.0.5"
- minipass-pipeline: "npm:^1.2.4"
- negotiator: "npm:^0.6.3"
- proc-log: "npm:^4.2.0"
- promise-retry: "npm:^2.0.1"
- ssri: "npm:^10.0.0"
- checksum: 10c0/df5f4dbb6d98153b751bccf4dc4cc500de85a96a9331db9805596c46aa9f99d9555983954e6c1266d9f981ae37a9e4647f42b9a4bb5466f867f4012e582c9e7e
- languageName: node
- linkType: hard
-
-"make-fetch-happen@npm:^9.1.0":
- version: 9.1.0
- resolution: "make-fetch-happen@npm:9.1.0"
- dependencies:
- agentkeepalive: "npm:^4.1.3"
- cacache: "npm:^15.2.0"
- http-cache-semantics: "npm:^4.1.0"
- http-proxy-agent: "npm:^4.0.1"
- https-proxy-agent: "npm:^5.0.0"
- is-lambda: "npm:^1.0.1"
- lru-cache: "npm:^6.0.0"
- minipass: "npm:^3.1.3"
- minipass-collect: "npm:^1.0.2"
- minipass-fetch: "npm:^1.3.2"
- minipass-flush: "npm:^1.0.5"
- minipass-pipeline: "npm:^1.2.4"
- negotiator: "npm:^0.6.2"
- promise-retry: "npm:^2.0.1"
- socks-proxy-agent: "npm:^6.0.0"
- ssri: "npm:^8.0.0"
- checksum: 10c0/2c737faf6a7f67077679da548b5bfeeef890595bf8c4323a1f76eae355d27ebb33dcf9cf1a673f944cf2f2a7cbf4e2b09f0a0a62931737728f210d902c6be966
- languageName: node
- linkType: hard
-
-"mimic-response@npm:^3.1.0":
- version: 3.1.0
- resolution: "mimic-response@npm:3.1.0"
- checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362
- languageName: node
- linkType: hard
-
-"minimatch@npm:^3.1.1":
- version: 3.1.2
- resolution: "minimatch@npm:3.1.2"
- dependencies:
- brace-expansion: "npm:^1.1.7"
- checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311
- languageName: node
- linkType: hard
-
-"minimatch@npm:^9.0.4":
- version: 9.0.5
- resolution: "minimatch@npm:9.0.5"
- dependencies:
- brace-expansion: "npm:^2.0.1"
- checksum: 10c0/de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed
- languageName: node
- linkType: hard
-
-"minimist@npm:^1.2.0, minimist@npm:^1.2.3":
- version: 1.2.8
- resolution: "minimist@npm:1.2.8"
- checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6
- languageName: node
- linkType: hard
-
-"minipass-collect@npm:^1.0.2":
- version: 1.0.2
- resolution: "minipass-collect@npm:1.0.2"
- dependencies:
- minipass: "npm:^3.0.0"
- checksum: 10c0/8f82bd1f3095b24f53a991b04b67f4c710c894e518b813f0864a31de5570441a509be1ca17e0bb92b047591a8fdbeb886f502764fefb00d2f144f4011791e898
- languageName: node
- linkType: hard
-
-"minipass-collect@npm:^2.0.1":
- version: 2.0.1
- resolution: "minipass-collect@npm:2.0.1"
- dependencies:
- minipass: "npm:^7.0.3"
- checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e
- languageName: node
- linkType: hard
-
-"minipass-fetch@npm:^1.3.2":
- version: 1.4.1
- resolution: "minipass-fetch@npm:1.4.1"
- dependencies:
- encoding: "npm:^0.1.12"
- minipass: "npm:^3.1.0"
- minipass-sized: "npm:^1.0.3"
- minizlib: "npm:^2.0.0"
- dependenciesMeta:
- encoding:
- optional: true
- checksum: 10c0/a43da7401cd7c4f24b993887d41bd37d097356083b0bb836fd655916467463a1e6e9e553b2da4fcbe8745bf23d40c8b884eab20745562199663b3e9060cd8e7a
- languageName: node
- linkType: hard
-
-"minipass-fetch@npm:^3.0.0":
- version: 3.0.5
- resolution: "minipass-fetch@npm:3.0.5"
- dependencies:
- encoding: "npm:^0.1.13"
- minipass: "npm:^7.0.3"
- minipass-sized: "npm:^1.0.3"
- minizlib: "npm:^2.1.2"
- dependenciesMeta:
- encoding:
- optional: true
- checksum: 10c0/9d702d57f556274286fdd97e406fc38a2f5c8d15e158b498d7393b1105974b21249289ec571fa2b51e038a4872bfc82710111cf75fae98c662f3d6f95e72152b
- languageName: node
- linkType: hard
-
-"minipass-flush@npm:^1.0.5":
- version: 1.0.5
- resolution: "minipass-flush@npm:1.0.5"
- dependencies:
- minipass: "npm:^3.0.0"
- checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd
- languageName: node
- linkType: hard
-
-"minipass-pipeline@npm:^1.2.2, minipass-pipeline@npm:^1.2.4":
- version: 1.2.4
- resolution: "minipass-pipeline@npm:1.2.4"
- dependencies:
- minipass: "npm:^3.0.0"
- checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2
- languageName: node
- linkType: hard
-
-"minipass-sized@npm:^1.0.3":
- version: 1.0.3
- resolution: "minipass-sized@npm:1.0.3"
- dependencies:
- minipass: "npm:^3.0.0"
- checksum: 10c0/298f124753efdc745cfe0f2bdfdd81ba25b9f4e753ca4a2066eb17c821f25d48acea607dfc997633ee5bf7b6dfffb4eee4f2051eb168663f0b99fad2fa4829cb
- languageName: node
- linkType: hard
-
-"minipass@npm:^3.0.0, minipass@npm:^3.1.0, minipass@npm:^3.1.1, minipass@npm:^3.1.3":
- version: 3.3.6
- resolution: "minipass@npm:3.3.6"
- dependencies:
- yallist: "npm:^4.0.0"
- checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c
- languageName: node
- linkType: hard
-
-"minipass@npm:^5.0.0":
- version: 5.0.0
- resolution: "minipass@npm:5.0.0"
- checksum: 10c0/a91d8043f691796a8ac88df039da19933ef0f633e3d7f0d35dcd5373af49131cf2399bfc355f41515dc495e3990369c3858cd319e5c2722b4753c90bf3152462
- languageName: node
- linkType: hard
-
-"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.1.2":
- version: 7.1.2
- resolution: "minipass@npm:7.1.2"
- checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557
- languageName: node
- linkType: hard
-
-"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
- version: 2.1.2
- resolution: "minizlib@npm:2.1.2"
- dependencies:
- minipass: "npm:^3.0.0"
- yallist: "npm:^4.0.0"
- checksum: 10c0/64fae024e1a7d0346a1102bb670085b17b7f95bf6cfdf5b128772ec8faf9ea211464ea4add406a3a6384a7d87a0cd1a96263692134323477b4fb43659a6cab78
- languageName: node
- linkType: hard
-
-"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3":
- version: 0.5.3
- resolution: "mkdirp-classic@npm:0.5.3"
- checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168
- languageName: node
- linkType: hard
-
-"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4":
- version: 1.0.4
- resolution: "mkdirp@npm:1.0.4"
- bin:
- mkdirp: bin/cmd.js
- checksum: 10c0/46ea0f3ffa8bc6a5bc0c7081ffc3907777f0ed6516888d40a518c5111f8366d97d2678911ad1a6882bf592fa9de6c784fea32e1687bb94e1f4944170af48a5cf
- languageName: node
- linkType: hard
-
-"ms@npm:2.1.2":
- version: 2.1.2
- resolution: "ms@npm:2.1.2"
- checksum: 10c0/a437714e2f90dbf881b5191d35a6db792efbca5badf112f87b9e1c712aace4b4b9b742dd6537f3edf90fd6f684de897cec230abde57e87883766712ddda297cc
- languageName: node
- linkType: hard
-
-"ms@npm:^2.0.0":
- version: 2.1.3
- resolution: "ms@npm:2.1.3"
- checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48
- languageName: node
- linkType: hard
-
-"napi-build-utils@npm:^1.0.1":
- version: 1.0.2
- resolution: "napi-build-utils@npm:1.0.2"
- checksum: 10c0/37fd2cd0ff2ad20073ce78d83fd718a740d568b225924e753ae51cb69d68f330c80544d487e5e5bd18e28702ed2ca469c2424ad948becd1862c1b0209542b2e9
- languageName: node
- linkType: hard
-
-"negotiator@npm:^0.6.2, negotiator@npm:^0.6.3":
- version: 0.6.3
- resolution: "negotiator@npm:0.6.3"
- checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2
- languageName: node
- linkType: hard
-
-"node-abi@npm:^3.3.0":
- version: 3.65.0
- resolution: "node-abi@npm:3.65.0"
- dependencies:
- semver: "npm:^7.3.5"
- checksum: 10c0/112672015d8f27d6be2f18d64569f28f5d6a15a94cc510da513c69c3e3ab5df6dac196ef13ff115a8fadb69b554974c47ef89b4f6350a2b02de2bca5c23db1e5
- languageName: node
- linkType: hard
-
-"node-addon-api@npm:^7.0.0":
- version: 7.1.1
- resolution: "node-addon-api@npm:7.1.1"
- dependencies:
- node-gyp: "npm:latest"
- checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9
- languageName: node
- linkType: hard
-
-"node-gyp@npm:8.x":
- version: 8.4.1
- resolution: "node-gyp@npm:8.4.1"
- dependencies:
- env-paths: "npm:^2.2.0"
- glob: "npm:^7.1.4"
- graceful-fs: "npm:^4.2.6"
- make-fetch-happen: "npm:^9.1.0"
- nopt: "npm:^5.0.0"
- npmlog: "npm:^6.0.0"
- rimraf: "npm:^3.0.2"
- semver: "npm:^7.3.5"
- tar: "npm:^6.1.2"
- which: "npm:^2.0.2"
- bin:
- node-gyp: bin/node-gyp.js
- checksum: 10c0/80ef333b3a882eb6a2695a8e08f31d618f4533eff192864e4a3a16b67ff0abc9d8c1d5fac0395550ec699326b9248c5e2b3be178492f7f4d1ccf97d2cf948021
- languageName: node
- linkType: hard
-
-"node-gyp@npm:latest":
- version: 10.2.0
- resolution: "node-gyp@npm:10.2.0"
- dependencies:
- env-paths: "npm:^2.2.0"
- exponential-backoff: "npm:^3.1.1"
- glob: "npm:^10.3.10"
- graceful-fs: "npm:^4.2.6"
- make-fetch-happen: "npm:^13.0.0"
- nopt: "npm:^7.0.0"
- proc-log: "npm:^4.1.0"
- semver: "npm:^7.3.5"
- tar: "npm:^6.2.1"
- which: "npm:^4.0.0"
- bin:
- node-gyp: bin/node-gyp.js
- checksum: 10c0/00630d67dbd09a45aee0a5d55c05e3916ca9e6d427ee4f7bc392d2d3dc5fad7449b21fc098dd38260a53d9dcc9c879b36704a1994235d4707e7271af7e9a835b
- languageName: node
- linkType: hard
-
-"nopt@npm:^5.0.0":
- version: 5.0.0
- resolution: "nopt@npm:5.0.0"
- dependencies:
- abbrev: "npm:1"
- bin:
- nopt: bin/nopt.js
- checksum: 10c0/fc5c4f07155cb455bf5fc3dd149fac421c1a40fd83c6bfe83aa82b52f02c17c5e88301321318adaa27611c8a6811423d51d29deaceab5fa158b585a61a551061
- languageName: node
- linkType: hard
-
-"nopt@npm:^7.0.0":
- version: 7.2.1
- resolution: "nopt@npm:7.2.1"
- dependencies:
- abbrev: "npm:^2.0.0"
- bin:
- nopt: bin/nopt.js
- checksum: 10c0/a069c7c736767121242037a22a788863accfa932ab285a1eb569eb8cd534b09d17206f68c37f096ae785647435e0c5a5a0a67b42ec743e481a455e5ae6a6df81
- languageName: node
- linkType: hard
-
-"npmlog@npm:^6.0.0":
- version: 6.0.2
- resolution: "npmlog@npm:6.0.2"
- dependencies:
- are-we-there-yet: "npm:^3.0.0"
- console-control-strings: "npm:^1.1.0"
- gauge: "npm:^4.0.3"
- set-blocking: "npm:^2.0.0"
- checksum: 10c0/0cacedfbc2f6139c746d9cd4a85f62718435ad0ca4a2d6459cd331dd33ae58206e91a0742c1558634efcde3f33f8e8e7fd3adf1bfe7978310cf00bd55cccf890
- languageName: node
- linkType: hard
-
-"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0":
- version: 1.4.0
- resolution: "once@npm:1.4.0"
- dependencies:
- wrappy: "npm:1"
- checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0
- languageName: node
- linkType: hard
-
-"p-map@npm:^4.0.0":
- version: 4.0.0
- resolution: "p-map@npm:4.0.0"
- dependencies:
- aggregate-error: "npm:^3.0.0"
- checksum: 10c0/592c05bd6262c466ce269ff172bb8de7c6975afca9b50c975135b974e9bdaafbfe80e61aaaf5be6d1200ba08b30ead04b88cfa7e25ff1e3b93ab28c9f62a2c75
- languageName: node
- linkType: hard
-
-"package-json-from-dist@npm:^1.0.0":
- version: 1.0.0
- resolution: "package-json-from-dist@npm:1.0.0"
- checksum: 10c0/e3ffaf6ac1040ab6082a658230c041ad14e72fabe99076a2081bb1d5d41210f11872403fc09082daf4387fc0baa6577f96c9c0e94c90c394fd57794b66aa4033
- languageName: node
- linkType: hard
-
-"path-is-absolute@npm:^1.0.0":
- version: 1.0.1
- resolution: "path-is-absolute@npm:1.0.1"
- checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078
- languageName: node
- linkType: hard
-
-"path-key@npm:^3.1.0":
- version: 3.1.1
- resolution: "path-key@npm:3.1.1"
- checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c
- languageName: node
- linkType: hard
-
-"path-scurry@npm:^1.11.1":
- version: 1.11.1
- resolution: "path-scurry@npm:1.11.1"
- dependencies:
- lru-cache: "npm:^10.2.0"
- minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
- checksum: 10c0/32a13711a2a505616ae1cc1b5076801e453e7aae6ac40ab55b388bb91b9d0547a52f5aaceff710ea400205f18691120d4431e520afbe4266b836fadede15872d
- languageName: node
- linkType: hard
-
-"prebuild-install@npm:^7.1.1":
- version: 7.1.2
- resolution: "prebuild-install@npm:7.1.2"
- dependencies:
- detect-libc: "npm:^2.0.0"
- expand-template: "npm:^2.0.3"
- github-from-package: "npm:0.0.0"
- minimist: "npm:^1.2.3"
- mkdirp-classic: "npm:^0.5.3"
- napi-build-utils: "npm:^1.0.1"
- node-abi: "npm:^3.3.0"
- pump: "npm:^3.0.0"
- rc: "npm:^1.2.7"
- simple-get: "npm:^4.0.0"
- tar-fs: "npm:^2.0.0"
- tunnel-agent: "npm:^0.6.0"
- bin:
- prebuild-install: bin.js
- checksum: 10c0/e64868ba9ef2068fd7264f5b03e5298a901e02a450acdb1f56258d88c09dea601eefdb3d1dfdff8513fdd230a92961712be0676192626a3b4d01ba154d48bdd3
- languageName: node
- linkType: hard
-
-"proc-log@npm:^4.1.0, proc-log@npm:^4.2.0":
- version: 4.2.0
- resolution: "proc-log@npm:4.2.0"
- checksum: 10c0/17db4757c2a5c44c1e545170e6c70a26f7de58feb985091fb1763f5081cab3d01b181fb2dd240c9f4a4255a1d9227d163d5771b7e69c9e49a561692db865efb9
- languageName: node
- linkType: hard
-
-"promise-inflight@npm:^1.0.1":
- version: 1.0.1
- resolution: "promise-inflight@npm:1.0.1"
- checksum: 10c0/d179d148d98fbff3d815752fa9a08a87d3190551d1420f17c4467f628214db12235ae068d98cd001f024453676d8985af8f28f002345646c4ece4600a79620bc
- languageName: node
- linkType: hard
-
-"promise-retry@npm:^2.0.1":
- version: 2.0.1
- resolution: "promise-retry@npm:2.0.1"
- dependencies:
- err-code: "npm:^2.0.2"
- retry: "npm:^0.12.0"
- checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96
- languageName: node
- linkType: hard
-
-"pump@npm:^3.0.0":
- version: 3.0.0
- resolution: "pump@npm:3.0.0"
- dependencies:
- end-of-stream: "npm:^1.1.0"
- once: "npm:^1.3.1"
- checksum: 10c0/bbdeda4f747cdf47db97428f3a135728669e56a0ae5f354a9ac5b74556556f5446a46f720a8f14ca2ece5be9b4d5d23c346db02b555f46739934cc6c093a5478
- languageName: node
- linkType: hard
-
-"rc@npm:^1.2.7":
- version: 1.2.8
- resolution: "rc@npm:1.2.8"
- dependencies:
- deep-extend: "npm:^0.6.0"
- ini: "npm:~1.3.0"
- minimist: "npm:^1.2.0"
- strip-json-comments: "npm:~2.0.1"
- bin:
- rc: ./cli.js
- checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15
- languageName: node
- linkType: hard
-
-"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0":
- version: 3.6.2
- resolution: "readable-stream@npm:3.6.2"
- dependencies:
- inherits: "npm:^2.0.3"
- string_decoder: "npm:^1.1.1"
- util-deprecate: "npm:^1.0.1"
- checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7
- languageName: node
- linkType: hard
-
-"retry@npm:^0.12.0":
- version: 0.12.0
- resolution: "retry@npm:0.12.0"
- checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe
- languageName: node
- linkType: hard
-
-"rimraf@npm:^3.0.2":
- version: 3.0.2
- resolution: "rimraf@npm:3.0.2"
- dependencies:
- glob: "npm:^7.1.3"
- bin:
- rimraf: bin.js
- checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8
- languageName: node
- linkType: hard
-
-"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
- version: 5.2.1
- resolution: "safe-buffer@npm:5.2.1"
- checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3
- languageName: node
- linkType: hard
-
-"safer-buffer@npm:>= 2.1.2 < 3.0.0":
- version: 2.1.2
- resolution: "safer-buffer@npm:2.1.2"
- checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4
- languageName: node
- linkType: hard
-
-"semver@npm:^7.3.5":
- version: 7.6.3
- resolution: "semver@npm:7.6.3"
- bin:
- semver: bin/semver.js
- checksum: 10c0/88f33e148b210c153873cb08cfe1e281d518aaa9a666d4d148add6560db5cd3c582f3a08ccb91f38d5f379ead256da9931234ed122057f40bb5766e65e58adaf
- languageName: node
- linkType: hard
-
-"set-blocking@npm:^2.0.0":
- version: 2.0.0
- resolution: "set-blocking@npm:2.0.0"
- checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454
- languageName: node
- linkType: hard
-
-"shebang-command@npm:^2.0.0":
- version: 2.0.0
- resolution: "shebang-command@npm:2.0.0"
- dependencies:
- shebang-regex: "npm:^3.0.0"
- checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e
- languageName: node
- linkType: hard
-
-"shebang-regex@npm:^3.0.0":
- version: 3.0.0
- resolution: "shebang-regex@npm:3.0.0"
- checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690
- languageName: node
- linkType: hard
-
-"signal-exit@npm:^3.0.7":
- version: 3.0.7
- resolution: "signal-exit@npm:3.0.7"
- checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912
- languageName: node
- linkType: hard
-
-"signal-exit@npm:^4.0.1":
- version: 4.1.0
- resolution: "signal-exit@npm:4.1.0"
- checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83
- languageName: node
- linkType: hard
-
-"simple-concat@npm:^1.0.0":
- version: 1.0.1
- resolution: "simple-concat@npm:1.0.1"
- checksum: 10c0/62f7508e674414008910b5397c1811941d457dfa0db4fd5aa7fa0409eb02c3609608dfcd7508cace75b3a0bf67a2a77990711e32cd213d2c76f4fd12ee86d776
- languageName: node
- linkType: hard
-
-"simple-get@npm:^4.0.0":
- version: 4.0.1
- resolution: "simple-get@npm:4.0.1"
- dependencies:
- decompress-response: "npm:^6.0.0"
- once: "npm:^1.3.1"
- simple-concat: "npm:^1.0.0"
- checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0
- languageName: node
- linkType: hard
-
-"smart-buffer@npm:^4.2.0":
- version: 4.2.0
- resolution: "smart-buffer@npm:4.2.0"
- checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539
- languageName: node
- linkType: hard
-
-"socks-proxy-agent@npm:^6.0.0":
- version: 6.2.1
- resolution: "socks-proxy-agent@npm:6.2.1"
- dependencies:
- agent-base: "npm:^6.0.2"
- debug: "npm:^4.3.3"
- socks: "npm:^2.6.2"
- checksum: 10c0/d75c1cf1fdd7f8309a43a77f84409b793fc0f540742ef915154e70ac09a08b0490576fe85d4f8d68bbf80e604a62957a17ab5ef50d312fe1442b0ab6f8f6e6f6
- languageName: node
- linkType: hard
-
-"socks-proxy-agent@npm:^8.0.3":
- version: 8.0.4
- resolution: "socks-proxy-agent@npm:8.0.4"
- dependencies:
- agent-base: "npm:^7.1.1"
- debug: "npm:^4.3.4"
- socks: "npm:^2.8.3"
- checksum: 10c0/345593bb21b95b0508e63e703c84da11549f0a2657d6b4e3ee3612c312cb3a907eac10e53b23ede3557c6601d63252103494caa306b66560f43af7b98f53957a
- languageName: node
- linkType: hard
-
-"socks@npm:^2.6.2, socks@npm:^2.8.3":
- version: 2.8.3
- resolution: "socks@npm:2.8.3"
- dependencies:
- ip-address: "npm:^9.0.5"
- smart-buffer: "npm:^4.2.0"
- checksum: 10c0/d54a52bf9325165770b674a67241143a3d8b4e4c8884560c4e0e078aace2a728dffc7f70150660f51b85797c4e1a3b82f9b7aa25e0a0ceae1a243365da5c51a7
- languageName: node
- linkType: hard
-
-"sprintf-js@npm:^1.1.3":
- version: 1.1.3
- resolution: "sprintf-js@npm:1.1.3"
- checksum: 10c0/09270dc4f30d479e666aee820eacd9e464215cdff53848b443964202bf4051490538e5dd1b42e1a65cf7296916ca17640aebf63dae9812749c7542ee5f288dec
- languageName: node
- linkType: hard
-
-"sqlite3@npm:^5.1.7":
- version: 5.1.7
- resolution: "sqlite3@npm:5.1.7"
- dependencies:
- bindings: "npm:^1.5.0"
- node-addon-api: "npm:^7.0.0"
- node-gyp: "npm:8.x"
- prebuild-install: "npm:^7.1.1"
- tar: "npm:^6.1.11"
- peerDependencies:
- node-gyp: 8.x
- dependenciesMeta:
- node-gyp:
- optional: true
- peerDependenciesMeta:
- node-gyp:
- optional: true
- checksum: 10c0/10daab5d7854bd0ec3c7690c00359cd3444eabc869b68c68dcb61374a8fa5e2f4be06cf0aba78f7a16336d49e83e4631e8af98f8bd33c772fe8d60b45fa60bc1
- languageName: node
- linkType: hard
-
-"ssri@npm:^10.0.0":
- version: 10.0.6
- resolution: "ssri@npm:10.0.6"
- dependencies:
- minipass: "npm:^7.0.3"
- checksum: 10c0/e5a1e23a4057a86a97971465418f22ea89bd439ac36ade88812dd920e4e61873e8abd6a9b72a03a67ef50faa00a2daf1ab745c5a15b46d03e0544a0296354227
- languageName: node
- linkType: hard
-
-"ssri@npm:^8.0.0, ssri@npm:^8.0.1":
- version: 8.0.1
- resolution: "ssri@npm:8.0.1"
- dependencies:
- minipass: "npm:^3.1.1"
- checksum: 10c0/5cfae216ae02dcd154d1bbed2d0a60038a4b3a2fcaac3c7e47401ff4e058e551ee74cfdba618871bf168cd583db7b8324f94af6747d4303b73cd4c3f6dc5c9c2
- languageName: node
- linkType: hard
-
-"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.3":
- version: 4.2.3
- resolution: "string-width@npm:4.2.3"
- dependencies:
- emoji-regex: "npm:^8.0.0"
- is-fullwidth-code-point: "npm:^3.0.0"
- strip-ansi: "npm:^6.0.1"
- checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b
- languageName: node
- linkType: hard
-
-"string-width@npm:^5.0.1, string-width@npm:^5.1.2":
- version: 5.1.2
- resolution: "string-width@npm:5.1.2"
- dependencies:
- eastasianwidth: "npm:^0.2.0"
- emoji-regex: "npm:^9.2.2"
- strip-ansi: "npm:^7.0.1"
- checksum: 10c0/ab9c4264443d35b8b923cbdd513a089a60de339216d3b0ed3be3ba57d6880e1a192b70ae17225f764d7adbf5994e9bb8df253a944736c15a0240eff553c678ca
- languageName: node
- linkType: hard
-
-"string_decoder@npm:^1.1.1":
- version: 1.3.0
- resolution: "string_decoder@npm:1.3.0"
- dependencies:
- safe-buffer: "npm:~5.2.0"
- checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d
- languageName: node
- linkType: hard
-
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
- version: 6.0.1
- resolution: "strip-ansi@npm:6.0.1"
- dependencies:
- ansi-regex: "npm:^5.0.1"
- checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952
- languageName: node
- linkType: hard
-
-"strip-ansi@npm:^7.0.1":
- version: 7.1.0
- resolution: "strip-ansi@npm:7.1.0"
- dependencies:
- ansi-regex: "npm:^6.0.1"
- checksum: 10c0/a198c3762e8832505328cbf9e8c8381de14a4fa50a4f9b2160138158ea88c0f5549fb50cb13c651c3088f47e63a108b34622ec18c0499b6c8c3a5ddf6b305ac4
- languageName: node
- linkType: hard
-
-"strip-json-comments@npm:~2.0.1":
- version: 2.0.1
- resolution: "strip-json-comments@npm:2.0.1"
- checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43
- languageName: node
- linkType: hard
-
-"tar-fs@npm:^2.0.0":
- version: 2.1.1
- resolution: "tar-fs@npm:2.1.1"
- dependencies:
- chownr: "npm:^1.1.1"
- mkdirp-classic: "npm:^0.5.2"
- pump: "npm:^3.0.0"
- tar-stream: "npm:^2.1.4"
- checksum: 10c0/871d26a934bfb7beeae4c4d8a09689f530b565f79bd0cf489823ff0efa3705da01278160da10bb006d1a793fa0425cf316cec029b32a9159eacbeaff4965fb6d
- languageName: node
- linkType: hard
-
-"tar-stream@npm:^2.1.4":
- version: 2.2.0
- resolution: "tar-stream@npm:2.2.0"
- dependencies:
- bl: "npm:^4.0.3"
- end-of-stream: "npm:^1.4.1"
- fs-constants: "npm:^1.0.0"
- inherits: "npm:^2.0.3"
- readable-stream: "npm:^3.1.1"
- checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692
- languageName: node
- linkType: hard
-
-"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2, tar@npm:^6.2.1":
- version: 6.2.1
- resolution: "tar@npm:6.2.1"
- dependencies:
- chownr: "npm:^2.0.0"
- fs-minipass: "npm:^2.0.0"
- minipass: "npm:^5.0.0"
- minizlib: "npm:^2.1.1"
- mkdirp: "npm:^1.0.3"
- yallist: "npm:^4.0.0"
- checksum: 10c0/a5eca3eb50bc11552d453488344e6507156b9193efd7635e98e867fab275d527af53d8866e2370cd09dfe74378a18111622ace35af6a608e5223a7d27fe99537
- languageName: node
- linkType: hard
-
-"tunnel-agent@npm:^0.6.0":
- version: 0.6.0
- resolution: "tunnel-agent@npm:0.6.0"
- dependencies:
- safe-buffer: "npm:^5.0.1"
- checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a
- languageName: node
- linkType: hard
-
-"unique-filename@npm:^1.1.1":
- version: 1.1.1
- resolution: "unique-filename@npm:1.1.1"
- dependencies:
- unique-slug: "npm:^2.0.0"
- checksum: 10c0/d005bdfaae6894da8407c4de2b52f38b3c58ec86e79fc2ee19939da3085374413b073478ec54e721dc8e32b102cf9e50d0481b8331abdc62202e774b789ea874
- languageName: node
- linkType: hard
-
-"unique-filename@npm:^3.0.0":
- version: 3.0.0
- resolution: "unique-filename@npm:3.0.0"
- dependencies:
- unique-slug: "npm:^4.0.0"
- checksum: 10c0/6363e40b2fa758eb5ec5e21b3c7fb83e5da8dcfbd866cc0c199d5534c42f03b9ea9ab069769cc388e1d7ab93b4eeef28ef506ab5f18d910ef29617715101884f
- languageName: node
- linkType: hard
-
-"unique-slug@npm:^2.0.0":
- version: 2.0.2
- resolution: "unique-slug@npm:2.0.2"
- dependencies:
- imurmurhash: "npm:^0.1.4"
- checksum: 10c0/9eabc51680cf0b8b197811a48857e41f1364b25362300c1ff636c0eca5ec543a92a38786f59cf0697e62c6f814b11ecbe64e8093db71246468a1f03b80c83970
- languageName: node
- linkType: hard
-
-"unique-slug@npm:^4.0.0":
- version: 4.0.0
- resolution: "unique-slug@npm:4.0.0"
- dependencies:
- imurmurhash: "npm:^0.1.4"
- checksum: 10c0/cb811d9d54eb5821b81b18205750be84cb015c20a4a44280794e915f5a0a70223ce39066781a354e872df3572e8155c228f43ff0cce94c7cbf4da2cc7cbdd635
- languageName: node
- linkType: hard
-
-"util-deprecate@npm:^1.0.1":
- version: 1.0.2
- resolution: "util-deprecate@npm:1.0.2"
- checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942
- languageName: node
- linkType: hard
-
-"which@npm:^2.0.1, which@npm:^2.0.2":
- version: 2.0.2
- resolution: "which@npm:2.0.2"
- dependencies:
- isexe: "npm:^2.0.0"
- bin:
- node-which: ./bin/node-which
- checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f
- languageName: node
- linkType: hard
-
-"which@npm:^4.0.0":
- version: 4.0.0
- resolution: "which@npm:4.0.0"
- dependencies:
- isexe: "npm:^3.1.1"
- bin:
- node-which: bin/which.js
- checksum: 10c0/449fa5c44ed120ccecfe18c433296a4978a7583bf2391c50abce13f76878d2476defde04d0f79db8165bdf432853c1f8389d0485ca6e8ebce3bbcded513d5e6a
- languageName: node
- linkType: hard
-
-"wide-align@npm:^1.1.5":
- version: 1.1.5
- resolution: "wide-align@npm:1.1.5"
- dependencies:
- string-width: "npm:^1.0.2 || 2 || 3 || 4"
- checksum: 10c0/1d9c2a3e36dfb09832f38e2e699c367ef190f96b82c71f809bc0822c306f5379df87bab47bed27ea99106d86447e50eb972d3c516c2f95782807a9d082fbea95
- languageName: node
- linkType: hard
-
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
- version: 7.0.0
- resolution: "wrap-ansi@npm:7.0.0"
- dependencies:
- ansi-styles: "npm:^4.0.0"
- string-width: "npm:^4.1.0"
- strip-ansi: "npm:^6.0.0"
- checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da
- languageName: node
- linkType: hard
-
-"wrap-ansi@npm:^8.1.0":
- version: 8.1.0
- resolution: "wrap-ansi@npm:8.1.0"
- dependencies:
- ansi-styles: "npm:^6.1.0"
- string-width: "npm:^5.0.1"
- strip-ansi: "npm:^7.0.1"
- checksum: 10c0/138ff58a41d2f877eae87e3282c0630fc2789012fc1af4d6bd626eeb9a2f9a65ca92005e6e69a75c7b85a68479fe7443c7dbe1eb8fbaa681a4491364b7c55c60
- languageName: node
- linkType: hard
-
-"wrappy@npm:1":
- version: 1.0.2
- resolution: "wrappy@npm:1.0.2"
- checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0
- languageName: node
- linkType: hard
-
-"yallist@npm:^4.0.0":
- version: 4.0.0
- resolution: "yallist@npm:4.0.0"
- checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a
- languageName: node
- linkType: hard
diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts
index 63c340e7a7..8c74ffcaed 100644
--- a/packages/shared/IpcChannel.ts
+++ b/packages/shared/IpcChannel.ts
@@ -1,4 +1,5 @@
export enum IpcChannel {
+ App_GetCacheSize = 'app:get-cache-size',
App_ClearCache = 'app:clear-cache',
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
App_SetLanguage = 'app:set-language',
@@ -10,16 +11,18 @@ export enum IpcChannel {
App_SetLaunchToTray = 'app:set-launch-to-tray',
App_SetTray = 'app:set-tray',
App_SetTrayOnClose = 'app:set-tray-on-close',
- App_RestartTray = 'app:restart-tray',
App_SetTheme = 'app:set-theme',
- App_SetCustomCss = 'app:set-custom-css',
App_SetAutoUpdate = 'app:set-auto-update',
+ App_HandleZoomFactor = 'app:handle-zoom-factor',
App_IsBinaryExist = 'app:is-binary-exist',
App_GetBinaryPath = 'app:get-binary-path',
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
+ Notification_Send = 'notification:send',
+ Notification_OnClick = 'notification:on-click',
+
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
// Open
@@ -51,6 +54,7 @@ export enum IpcChannel {
Mcp_GetInstallInfo = 'mcp:get-install-info',
Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated',
+ Mcp_CheckConnectivity = 'mcp:check-connectivity',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
@@ -82,8 +86,6 @@ export enum IpcChannel {
Windows_ResetMinimumSize = 'window:reset-minimum-size',
Windows_SetMinimumSize = 'window:set-minimum-size',
- SelectionMenu_Action = 'selection-menu:action',
-
KnowledgeBase_Create = 'knowledge-base:create',
KnowledgeBase_Reset = 'knowledge-base:reset',
KnowledgeBase_Delete = 'knowledge-base:delete',
@@ -105,12 +107,14 @@ export enum IpcChannel {
File_SelectFolder = 'file:selectFolder',
File_Create = 'file:create',
File_Write = 'file:write',
+ File_WriteWithId = 'file:writeWithId',
File_SaveImage = 'file:saveImage',
File_Base64Image = 'file:base64Image',
+ File_SaveBase64Image = 'file:saveBase64Image',
File_Download = 'file:download',
File_Copy = 'file:copy',
- File_BinaryFile = 'file:binaryFile',
-
+ File_BinaryImage = 'file:binaryImage',
+ File_Base64File = 'file:base64File',
Fs_Read = 'fs:read',
Export_Word = 'export:word',
@@ -135,10 +139,12 @@ export enum IpcChannel {
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
+ // DevTools
+ System_ToggleDevTools = 'system:toggleDevTools',
+
// events
- SelectionAction = 'selection-action',
BackupProgress = 'backup-progress',
- ThemeChange = 'theme:change',
+ ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
@@ -154,7 +160,6 @@ export enum IpcChannel {
HideMiniWindow = 'hide-mini-window',
ShowMiniWindow = 'show-mini-window',
- MiniWindowReload = 'miniwindow-reload',
ReduxStateChange = 'redux-state-change',
ReduxStoreReady = 'redux-store-ready',
@@ -162,5 +167,32 @@ export enum IpcChannel {
// Search Window
SearchWindow_Open = 'search-window:open',
SearchWindow_Close = 'search-window:close',
- SearchWindow_OpenUrl = 'search-window:open-url'
+ SearchWindow_OpenUrl = 'search-window:open-url',
+
+ //Store Sync
+ StoreSync_Subscribe = 'store-sync:subscribe',
+ StoreSync_Unsubscribe = 'store-sync:unsubscribe',
+ StoreSync_OnUpdate = 'store-sync:on-update',
+ StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
+
+ // Provider
+ Provider_AddKey = 'provider:add-key',
+
+ //Selection Assistant
+ Selection_TextSelected = 'selection:text-selected',
+ Selection_ToolbarHide = 'selection:toolbar-hide',
+ Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change',
+ Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size',
+ Selection_WriteToClipboard = 'selection:write-to-clipboard',
+ Selection_SetEnabled = 'selection:set-enabled',
+ Selection_SetTriggerMode = 'selection:set-trigger-mode',
+ Selection_SetFilterMode = 'selection:set-filter-mode',
+ Selection_SetFilterList = 'selection:set-filter-list',
+ Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
+ Selection_SetRemeberWinSize = 'selection:set-remeber-win-size',
+ Selection_ActionWindowClose = 'selection:action-window-close',
+ Selection_ActionWindowMinimize = 'selection:action-window-minimize',
+ Selection_ActionWindowPin = 'selection:action-window-pin',
+ Selection_ProcessAction = 'selection:process-action',
+ Selection_UpdateActionData = 'selection:update-action-data'
}
diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts
index df8160113c..e1fca4e6d6 100644
--- a/packages/shared/config/constant.ts
+++ b/packages/shared/config/constant.ts
@@ -4,135 +4,376 @@ export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub']
-export const textExts = [
- '.txt', // 普通文本文件
- '.md', // Markdown 文件
- '.mdx', // Markdown 文件
- '.html', // HTML 文件
- '.htm', // HTML 文件的另一种扩展名
- '.xml', // XML 文件
- '.json', // JSON 文件
- '.yaml', // YAML 文件
- '.yml', // YAML 文件的另一种扩展名
- '.csv', // 逗号分隔值文件
- '.tsv', // 制表符分隔值文件
- '.ini', // 配置文件
- '.log', // 日志文件
- '.rtf', // 富文本格式文件
- '.org', // org-mode 文件
- '.wiki', // VimWiki 文件
- '.tex', // LaTeX 文件
- '.bib', // BibTeX 文件
- '.srt', // 字幕文件
- '.xhtml', // XHTML 文件
- '.nfo', // 信息文件(主要用于场景发布)
- '.conf', // 配置文件
- '.config', // 配置文件
- '.env', // 环境变量文件
- '.rst', // reStructuredText 文件
- '.php', // PHP 脚本文件,包含嵌入的 HTML
- '.js', // JavaScript 文件(部分是文本,部分可能包含代码)
- '.ts', // TypeScript 文件
- '.jsp', // JavaServer Pages 文件
- '.aspx', // ASP.NET 文件
- '.bat', // Windows 批处理文件
- '.sh', // Unix/Linux Shell 脚本文件
- '.py', // Python 脚本文件
- '.ipynb', // Jupyter 笔记本格式
- '.rb', // Ruby 脚本文件
- '.pl', // Perl 脚本文件
- '.sql', // SQL 脚本文件
- '.css', // Cascading Style Sheets 文件
- '.less', // Less CSS 预处理器文件
- '.scss', // Sass CSS 预处理器文件
- '.sass', // Sass 文件
- '.styl', // Stylus CSS 预处理器文件
- '.coffee', // CoffeeScript 文件
- '.ino', // Arduino 代码文件
- '.asm', // Assembly 语言文件
- '.go', // Go 语言文件
- '.scala', // Scala 语言文件
- '.swift', // Swift 语言文件
- '.kt', // Kotlin 语言文件
- '.rs', // Rust 语言文件
- '.lua', // Lua 语言文件
- '.groovy', // Groovy 语言文件
- '.dart', // Dart 语言文件
- '.hs', // Haskell 语言文件
- '.clj', // Clojure 语言文件
- '.cljs', // ClojureScript 语言文件
- '.elm', // Elm 语言文件
- '.erl', // Erlang 语言文件
- '.ex', // Elixir 语言文件
- '.exs', // Elixir 脚本文件
- '.pug', // Pug (formerly Jade) 模板文件
- '.haml', // Haml 模板文件
- '.slim', // Slim 模板文件
- '.tpl', // 模板文件(通用)
- '.ejs', // Embedded JavaScript 模板文件
- '.hbs', // Handlebars 模板文件
- '.mustache', // Mustache 模板文件
- '.jade', // Jade 模板文件 (已重命名为 Pug)
- '.twig', // Twig 模板文件
- '.blade', // Blade 模板文件 (Laravel)
- '.vue', // Vue.js 单文件组件
- '.jsx', // React JSX 文件
- '.tsx', // React TSX 文件
- '.graphql', // GraphQL 查询语言文件
- '.gql', // GraphQL 查询语言文件
- '.proto', // Protocol Buffers 文件
- '.thrift', // Thrift 文件
- '.toml', // TOML 配置文件
- '.edn', // Clojure 数据表示文件
- '.cake', // CakePHP 配置文件
- '.ctp', // CakePHP 视图文件
- '.cfm', // ColdFusion 标记语言文件
- '.cfc', // ColdFusion 组件文件
- '.m', // Objective-C 或 MATLAB 源文件
- '.mm', // Objective-C++ 源文件
- '.gradle', // Gradle 构建文件
- '.groovy', // Gradle 构建文件
- '.kts', // Kotlin Script 文件
- '.java', // Java 代码文件
- '.cs', // C# 代码文件
- '.cpp', // C++ 代码文件
- '.c', // C++ 代码文件
- '.h', // C++ 头文件
- '.hpp', // C++ 头文件
- '.cc', // C++ 源文件
- '.cxx', // C++ 源文件
- '.cppm', // C++20 模块接口文件
- '.ipp', // 模板实现文件
- '.ixx', // C++20 模块实现文件
- '.f90', // Fortran 90 源文件
- '.f', // Fortran 固定格式源代码文件
- '.f03', // Fortran 2003+ 源代码文件
- '.ahk', // AutoHotKey 语言文件
- '.tcl', // Tcl 脚本
- '.do', // Questa 或 Modelsim Tcl 脚本
- '.v', // Verilog 源文件
- '.sv', // SystemVerilog 源文件
- '.svh', // SystemVerilog 头文件
- '.vhd', // VHDL 源文件
- '.vhdl', // VHDL 源文件
- '.lef', // Library Exchange Format
- '.def', // Design Exchange Format
- '.edif', // Electronic Design Interchange Format
- '.sdf', // Standard Delay Format
- '.sdc', // Synopsys Design Constraints
- '.xdc', // Xilinx Design Constraints
- '.rpt', // 报告文件
- '.lisp', // Lisp 脚本
- '.il', // Cadence SKILL 脚本
- '.ils', // Cadence SKILL++ 脚本
- '.sp', // SPICE netlist 文件
- '.spi', // SPICE netlist 文件
- '.cir', // SPICE netlist 文件
- '.net', // SPICE netlist 文件
- '.scs', // Spectre netlist 文件
- '.asc', // LTspice netlist schematic 文件
- '.tf' // Technology File
-]
+const textExtsByCategory = new Map([
+ [
+ 'language',
+ [
+ '.js',
+ '.mjs',
+ '.cjs',
+ '.ts',
+ '.jsx',
+ '.tsx', // JavaScript/TypeScript
+ '.py', // Python
+ '.java', // Java
+ '.cs', // C#
+ '.cpp',
+ '.c',
+ '.h',
+ '.hpp',
+ '.cc',
+ '.cxx',
+ '.cppm',
+ '.ipp',
+ '.ixx', // C/C++
+ '.php', // PHP
+ '.rb', // Ruby
+ '.pl', // Perl
+ '.go', // Go
+ '.rs', // Rust
+ '.swift', // Swift
+ '.kt',
+ '.kts', // Kotlin
+ '.scala', // Scala
+ '.lua', // Lua
+ '.groovy', // Groovy
+ '.dart', // Dart
+ '.hs', // Haskell
+ '.clj',
+ '.cljs', // Clojure
+ '.elm', // Elm
+ '.erl', // Erlang
+ '.ex',
+ '.exs', // Elixir
+ '.ml',
+ '.mli', // OCaml
+ '.fs', // F#
+ '.r',
+ '.R', // R
+ '.sol', // Solidity
+ '.awk', // AWK
+ '.cob', // COBOL
+ '.asm',
+ '.s', // Assembly
+ '.lisp',
+ '.lsp', // Lisp
+ '.coffee', // CoffeeScript
+ '.ino', // Arduino
+ '.jl', // Julia
+ '.nim', // Nim
+ '.zig', // Zig
+ '.d', // D语言
+ '.pas', // Pascal
+ '.vb', // Visual Basic
+ '.rkt', // Racket
+ '.scm', // Scheme
+ '.hx', // Haxe
+ '.as', // ActionScript
+ '.pde', // Processing
+ '.f90',
+ '.f',
+ '.f03',
+ '.for',
+ '.f95', // Fortran
+ '.adb',
+ '.ads', // Ada
+ '.pro', // Prolog
+ '.m',
+ '.mm', // Objective-C/MATLAB
+ '.rpy', // Ren'Py
+ '.ets', // OpenHarmony,
+ '.uniswap', // DeFi
+ '.vy', // Vyper
+ '.shader',
+ '.glsl',
+ '.frag',
+ '.vert',
+ '.gd' // Godot
+ ]
+ ],
+ [
+ 'script',
+ [
+ '.sh', // Shell
+ '.bat',
+ '.cmd', // Windows批处理
+ '.ps1', // PowerShell
+ '.tcl',
+ '.do', // Tcl
+ '.ahk', // AutoHotkey
+ '.zsh', // Zsh
+ '.fish', // Fish shell
+ '.csh', // C shell
+ '.vbs', // VBScript
+ '.applescript', // AppleScript
+ '.au3', // AutoIt
+ '.bash',
+ '.nu'
+ ]
+ ],
+ [
+ 'style',
+ [
+ '.css', // CSS
+ '.less', // Less
+ '.scss',
+ '.sass', // Sass
+ '.styl', // Stylus
+ '.pcss', // PostCSS
+ '.postcss' // PostCSS
+ ]
+ ],
+ [
+ 'template',
+ [
+ '.vue', // Vue.js
+ '.pug',
+ '.jade', // Pug/Jade
+ '.haml', // Haml
+ '.slim', // Slim
+ '.tpl', // 通用模板
+ '.ejs', // EJS
+ '.hbs', // Handlebars
+ '.mustache', // Mustache
+ '.twig', // Twig
+ '.blade', // Blade (Laravel)
+ '.liquid', // Liquid
+ '.jinja',
+ '.jinja2',
+ '.j2', // Jinja
+ '.erb', // ERB
+ '.vm', // Velocity
+ '.ftl', // FreeMarker
+ '.svelte', // Svelte
+ '.astro' // Astro
+ ]
+ ],
+ [
+ 'config',
+ [
+ '.ini', // INI配置
+ '.conf',
+ '.config', // 通用配置
+ '.env', // 环境变量
+ '.toml', // TOML
+ '.cfg', // 通用配置
+ '.properties', // Java属性
+ '.desktop', // Linux桌面文件
+ '.service', // systemd服务
+ '.rc',
+ '.bashrc',
+ '.zshrc', // Shell配置
+ '.fishrc', // Fish shell配置
+ '.vimrc', // Vim配置
+ '.htaccess', // Apache配置
+ '.robots', // robots.txt
+ '.editorconfig', // EditorConfig
+ '.eslintrc', // ESLint
+ '.prettierrc', // Prettier
+ '.babelrc', // Babel
+ '.npmrc', // npm
+ '.dockerignore', // Docker ignore
+ '.npmignore',
+ '.yarnrc',
+ '.prettierignore',
+ '.eslintignore',
+ '.browserslistrc',
+ '.json5',
+ '.tfvars'
+ ]
+ ],
+ [
+ 'document',
+ [
+ '.txt',
+ '.text', // 纯文本
+ '.md',
+ '.mdx', // Markdown
+ '.html',
+ '.htm',
+ '.xhtml', // HTML
+ '.xml', // XML
+ '.org', // Org-mode
+ '.wiki', // Wiki
+ '.tex',
+ '.bib', // LaTeX
+ '.rst', // reStructuredText
+ '.rtf', // 富文本
+ '.nfo', // 信息文件
+ '.adoc',
+ '.asciidoc', // AsciiDoc
+ '.pod', // Perl文档
+ '.1',
+ '.2',
+ '.3',
+ '.4',
+ '.5',
+ '.6',
+ '.7',
+ '.8',
+ '.9', // man页面
+ '.man', // man页面
+ '.texi',
+ '.texinfo', // Texinfo
+ '.readme',
+ '.me', // README
+ '.changelog', // 变更日志
+ '.license', // 许可证
+ '.authors', // 作者文件
+ '.po',
+ '.pot'
+ ]
+ ],
+ [
+ 'data',
+ [
+ '.json', // JSON
+ '.jsonc', // JSON with comments
+ '.yaml',
+ '.yml', // YAML
+ '.csv',
+ '.tsv', // 分隔值文件
+ '.edn', // Clojure数据
+ '.jsonl',
+ '.ndjson', // 换行分隔JSON
+ '.geojson', // GeoJSON
+ '.gpx', // GPS Exchange
+ '.kml', // Keyhole Markup
+ '.rss',
+ '.atom', // Feed格式
+ '.vcf', // vCard
+ '.ics', // iCalendar
+ '.ldif', // LDAP数据交换
+ '.pbtxt',
+ '.map'
+ ]
+ ],
+ [
+ 'build',
+ [
+ '.gradle', // Gradle
+ '.make',
+ '.mk', // Make
+ '.cmake', // CMake
+ '.sbt', // SBT
+ '.rake', // Rake
+ '.spec', // RPM spec
+ '.pom',
+ '.build', // Meson
+ '.bazel' // Bazel
+ ]
+ ],
+ [
+ 'database',
+ [
+ '.sql', // SQL
+ '.ddl',
+ '.dml', // DDL/DML
+ '.plsql', // PL/SQL
+ '.psql', // PostgreSQL
+ '.cypher', // Cypher
+ '.sparql' // SPARQL
+ ]
+ ],
+ [
+ 'web',
+ [
+ '.graphql',
+ '.gql', // GraphQL
+ '.proto', // Protocol Buffers
+ '.thrift', // Thrift
+ '.wsdl', // WSDL
+ '.raml', // RAML
+ '.swagger',
+ '.openapi' // API文档
+ ]
+ ],
+ [
+ 'version',
+ [
+ '.gitignore', // Git ignore
+ '.gitattributes', // Git attributes
+ '.gitconfig', // Git config
+ '.hgignore', // Mercurial ignore
+ '.bzrignore', // Bazaar ignore
+ '.svnignore', // SVN ignore
+ '.githistory' // Git history
+ ]
+ ],
+ [
+ 'subtitle',
+ [
+ '.srt',
+ '.sub',
+ '.ass' // 字幕格式
+ ]
+ ],
+ [
+ 'log',
+ [
+ '.log',
+ '.rpt' // 日志和报告 (移除了.out,因为通常是二进制可执行文件)
+ ]
+ ],
+ [
+ 'eda',
+ [
+ '.v',
+ '.sv',
+ '.svh', // Verilog/SystemVerilog
+ '.vhd',
+ '.vhdl', // VHDL
+ '.lef',
+ '.def', // LEF/DEF
+ '.edif', // EDIF
+ '.sdf', // SDF
+ '.sdc',
+ '.xdc', // 约束文件
+ '.sp',
+ '.spi',
+ '.cir',
+ '.net', // SPICE
+ '.scs', // Spectre
+ '.asc', // LTspice
+ '.tf', // Technology File
+ '.il',
+ '.ils' // SKILL
+ ]
+ ],
+ [
+ 'game',
+ [
+ '.mtl', // Material Template Library
+ '.x3d', // X3D文件
+ '.gltf', // glTF JSON
+ '.prefab', // Unity预制体 (YAML格式)
+ '.meta' // Unity元数据文件 (YAML格式)
+ ]
+ ],
+ [
+ 'other',
+ [
+ '.mcfunction', // Minecraft函数
+ '.jsp', // JSP
+ '.aspx', // ASP.NET
+ '.ipynb', // Jupyter Notebook
+ '.cake',
+ '.ctp', // CakePHP
+ '.cfm',
+ '.cfc' // ColdFusion
+ ]
+ ]
+])
+
+export const textExts = Array.from(textExtsByCategory.values()).flat()
+
+export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]
+
+// 从 ZOOM_LEVELS 生成 Ant Design Select 所需的 options 结构
+export const ZOOM_OPTIONS = ZOOM_LEVELS.map((level) => ({
+ value: level,
+ label: `${Math.round(level * 100)}%`
+}))
export const ZOOM_SHORTCUTS = [
{
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000000..e12ce7ab6d
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,42 @@
+import { defineConfig, devices } from '@playwright/test'
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ // Look for test files, relative to this configuration file.
+ testDir: './tests/e2e',
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'html',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ // baseURL: 'http://localhost:3000',
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry'
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] }
+ }
+ ]
+
+ /* Run your local dev server before starting the tests */
+ // webServer: {
+ // command: 'npm run start',
+ // url: 'http://localhost:3000',
+ // reuseExistingServer: !process.env.CI,
+ // },
+})
diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js
index b7784fa58f..9637c60f3a 100644
--- a/resources/scripts/install-bun.js
+++ b/resources/scripts/install-bun.js
@@ -15,6 +15,8 @@ const BUN_PACKAGES = {
'darwin-x64': 'bun-darwin-x64.zip',
'win32-x64': 'bun-windows-x64.zip',
'win32-x64-baseline': 'bun-windows-x64-baseline.zip',
+ 'win32-arm64': 'bun-windows-x64.zip',
+ 'win32-arm64-baseline': 'bun-windows-x64-baseline.zip',
'linux-x64': 'bun-linux-x64.zip',
'linux-x64-baseline': 'bun-linux-x64-baseline.zip',
'linux-arm64': 'bun-linux-aarch64.zip',
diff --git a/resources/textMonitor.swift b/resources/textMonitor.swift
deleted file mode 100644
index 3c8b97bd92..0000000000
--- a/resources/textMonitor.swift
+++ /dev/null
@@ -1,117 +0,0 @@
-import Cocoa
-import Foundation
-
-class TextSelectionObserver: NSObject {
- let workspace = NSWorkspace.shared
- var lastSelectedText: String?
-
- override init() {
- super.init()
-
- // 注册通知观察者
- let observer = NSWorkspace.shared.notificationCenter
- observer.addObserver(
- self,
- selector: #selector(handleSelectionChange),
- name: NSWorkspace.didActivateApplicationNotification,
- object: nil
- )
-
- // 监听选择变化通知
- var axObserver: AXObserver?
- let error = AXObserverCreate(getpid(), { observer, element, notification, userData in
- let selfPointer = userData!.load(as: TextSelectionObserver.self)
- selfPointer.checkSelectedText()
- }, &axObserver)
-
- if error == .success, let axObserver = axObserver {
- CFRunLoopAddSource(
- RunLoop.main.getCFRunLoop(),
- AXObserverGetRunLoopSource(axObserver),
- .defaultMode
- )
-
- // 当前活动应用添加监听
- updateActiveAppObserver(axObserver)
- }
- }
-
- @objc func handleSelectionChange(_ notification: Notification) {
- // 应用切换时更新监听
- var axObserver: AXObserver?
- let error = AXObserverCreate(getpid(), { _, _, _, _ in }, &axObserver)
- if error == .success, let axObserver = axObserver {
- updateActiveAppObserver(axObserver)
- }
- }
-
- func updateActiveAppObserver(_ axObserver: AXObserver) {
- guard let app = workspace.frontmostApplication else { return }
- let pid = app.processIdentifier
- let element = AXUIElementCreateApplication(pid)
-
- // 添加选择变化通知监听
- AXObserverAddNotification(
- axObserver,
- element,
- kAXSelectedTextChangedNotification as CFString,
- UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
- )
- }
-
- func checkSelectedText() {
- if let text = getSelectedText() {
- if text.count > 0 && text != lastSelectedText {
- print(text)
- fflush(stdout)
- lastSelectedText = text
- }
- }
- }
-
- func getSelectedText() -> String? {
- guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
- let pid = app.processIdentifier
-
- let axApp = AXUIElementCreateApplication(pid)
- var focusedElement: AnyObject?
-
- // Get focused element
- let result = AXUIElementCopyAttributeValue(axApp, kAXFocusedUIElementAttribute as CFString, &focusedElement)
- guard result == .success else { return nil }
-
- // Try different approaches to get selected text
- var selectedText: AnyObject?
-
- // First try: Direct selected text
- var textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText)
-
- // Second try: Selected text in text area
- if textResult != .success {
- var selectedTextRange: AnyObject?
- textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextRangeAttribute as CFString, &selectedTextRange)
- if textResult == .success {
- textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXValueAttribute as CFString, &selectedText)
- }
- }
-
- // Third try: Get selected text from parent element
- if textResult != .success {
- var parent: AnyObject?
- if AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXParentAttribute as CFString, &parent) == .success {
- textResult = AXUIElementCopyAttributeValue(parent as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText)
- }
- }
-
- guard textResult == .success, let text = selectedText as? String else { return nil }
- return text
- }
-}
-
-let observer = TextSelectionObserver()
-
-signal(SIGINT) { _ in
- exit(0)
-}
-
-RunLoop.main.run()
\ No newline at end of file
diff --git a/scripts/artifact-build-completed.js b/scripts/artifact-build-completed.js
index 32f082f9a4..42a14efe2a 100644
--- a/scripts/artifact-build-completed.js
+++ b/scripts/artifact-build-completed.js
@@ -8,11 +8,6 @@ exports.default = function (buildResult) {
}
let oldFilePath = buildResult.file
- if (oldFilePath.includes('-portable') && !oldFilePath.includes('-x64') && !oldFilePath.includes('-arm64')) {
- console.log('[artifact build completed] delete portable file:', oldFilePath)
- fs.unlinkSync(oldFilePath)
- return
- }
const newfilePath = oldFilePath.replace(/ /g, '-')
fs.renameSync(oldFilePath, newfilePath)
buildResult.file = newfilePath
diff --git a/scripts/check-i18n.js b/scripts/check-i18n.js
index 411ce4d558..dd36c2670d 100644
--- a/scripts/check-i18n.js
+++ b/scripts/check-i18n.js
@@ -3,7 +3,7 @@ Object.defineProperty(exports, '__esModule', { value: true })
var fs = require('fs')
var path = require('path')
var translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
-var baseLocale = 'zh-CN'
+var baseLocale = 'en-us'
var baseFileName = ''.concat(baseLocale, '.json')
var baseFilePath = path.join(translationsDir, baseFileName)
/**
diff --git a/scripts/notarize.js b/scripts/notarize.js
index 671605a0be..985da3c378 100644
--- a/scripts/notarize.js
+++ b/scripts/notarize.js
@@ -7,7 +7,6 @@ exports.default = async function notarizing(context) {
}
if (!process.env.APPLE_ID || !process.env.APPLE_APP_SPECIFIC_PASSWORD || !process.env.APPLE_TEAM_ID) {
- console.log('Skipping notarization')
return
}
diff --git a/scripts/update-i18n.ts b/scripts/update-i18n.ts
index 2140f4ceff..3af6084384 100644
--- a/scripts/update-i18n.ts
+++ b/scripts/update-i18n.ts
@@ -1,16 +1,16 @@
/**
- * OCOOL_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts
+ * Paratera_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts
*/
// OCOOL API KEY
-const OCOOL_API_KEY = process.env.OCOOL_API_KEY
+const Paratera_API_KEY = process.env.Paratera_API_KEY
const INDEX = [
// 语言的名称 代码 用来翻译的模型
- { name: 'France', code: 'fr-fr', model: 'qwen2.5-32b-instruct' },
- { name: 'Spanish', code: 'es-es', model: 'qwen2.5-32b-instruct' },
- { name: 'Portuguese', code: 'pt-pt', model: 'qwen2.5-72b-instruct' },
- { name: 'Greek', code: 'el-gr', model: 'qwen-turbo' }
+ { name: 'France', code: 'fr-fr', model: 'Qwen3-235B-A22B' },
+ { name: 'Spanish', code: 'es-es', model: 'Qwen3-235B-A22B' },
+ { name: 'Portuguese', code: 'pt-pt', model: 'Qwen3-235B-A22B' },
+ { name: 'Greek', code: 'el-gr', model: 'Qwen3-235B-A22B' }
]
const fs = require('fs')
@@ -19,8 +19,8 @@ import OpenAI from 'openai'
const zh = JSON.parse(fs.readFileSync('src/renderer/src/i18n/locales/zh-cn.json', 'utf8')) as object
const openai = new OpenAI({
- apiKey: OCOOL_API_KEY,
- baseURL: 'https://one.ocoolai.com/v1'
+ apiKey: Paratera_API_KEY,
+ baseURL: 'https://llmapi.paratera.com/v1'
})
// 递归遍历翻译
diff --git a/scripts/win-sign.js b/scripts/win-sign.js
new file mode 100644
index 0000000000..f9b37c3aed
--- /dev/null
+++ b/scripts/win-sign.js
@@ -0,0 +1,19 @@
+const { execSync } = require('child_process')
+
+exports.default = async function (configuration) {
+ if (process.env.WIN_SIGN) {
+ const { path } = configuration
+ if (configuration.path) {
+ try {
+ console.log('Start code signing...')
+ console.log('Signing file:', path)
+ const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"`
+ execSync(signCommand, { stdio: 'inherit' })
+ console.log('Code signing completed')
+ } catch (error) {
+ console.error('Code signing failed:', error)
+ throw error
+ }
+ }
+ }
+}
diff --git a/src/main/configs/SelectionConfig.ts b/src/main/configs/SelectionConfig.ts
new file mode 100644
index 0000000000..1d0a3850c9
--- /dev/null
+++ b/src/main/configs/SelectionConfig.ts
@@ -0,0 +1,57 @@
+interface IFilterList {
+ WINDOWS: string[]
+ MAC?: string[]
+}
+
+interface IFinetunedList {
+ EXCLUDE_CLIPBOARD_CURSOR_DETECT: IFilterList
+ INCLUDE_CLIPBOARD_DELAY_READ: IFilterList
+}
+
+/*************************************************************************
+ * 注意:请不要修改此配置,除非你非常清楚其含义、影响和行为的目的
+ * Note: Do not modify this configuration unless you fully understand its meaning, implications, and intended behavior.
+ * -----------------------------------------------------------------------
+ * A predefined application filter list to include commonly used software
+ * that does not require text selection but may conflict with it, and disable them in advance.
+ * Only available in the selected mode.
+ *
+ * Specification: must be all lowercase, need to accurately find the actual running program name
+ *************************************************************************/
+export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
+ WINDOWS: [
+ // Screenshot
+ 'snipaste.exe',
+ 'pixpin.exe',
+ 'sharex.exe',
+ // Office
+ 'excel.exe',
+ 'powerpnt.exe',
+ // Image Editor
+ 'photoshop.exe',
+ 'illustrator.exe',
+ // Video Editor
+ 'adobe premiere pro.exe',
+ 'afterfx.exe',
+ // Audio Editor
+ 'adobe audition.exe',
+ // 3D Editor
+ 'blender.exe',
+ '3dsmax.exe',
+ 'maya.exe',
+ // CAD
+ 'acad.exe',
+ 'sldworks.exe',
+ // Remote Desktop
+ 'mstsc.exe'
+ ]
+}
+
+export const SELECTION_FINETUNED_LIST: IFinetunedList = {
+ EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
+ WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
+ },
+ INCLUDE_CLIPBOARD_DELAY_READ: {
+ WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
+ }
+}
diff --git a/src/main/embeddings/VoyageEmbeddings.ts b/src/main/embeddings/VoyageEmbeddings.ts
index dee2695c87..ce21afe580 100644
--- a/src/main/embeddings/VoyageEmbeddings.ts
+++ b/src/main/embeddings/VoyageEmbeddings.ts
@@ -11,7 +11,6 @@ export default class VoyageEmbeddings extends BaseEmbeddings {
if (!this.configuration.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
- console.log('VoyageEmbeddings', this.configuration)
this.model = new _VoyageEmbeddings(this.configuration)
}
override async getDimensions(): Promise {
diff --git a/src/main/index.ts b/src/main/index.ts
index ad6879dfa5..e53fbb4b42 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,10 +1,12 @@
+import '@main/config'
+
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
-import { IpcChannel } from '@shared/IpcChannel'
-import { app, ipcMain } from 'electron'
+import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import Logger from 'electron-log'
+import { isDev, isWin } from './constant'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
@@ -14,16 +16,45 @@ import {
registerProtocolClient,
setupAppImageDeepLink
} from './services/ProtocolClient'
+import selectionService, { initSelectionService } from './services/SelectionService'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { setUserDataDir } from './utils/file'
+Logger.initialize()
+
+/**
+ * Disable chromium's window animations
+ * main purpose for this is to avoid the transparent window flashing when it is shown
+ * (especially on Windows for SelectionAssistant Toolbar)
+ * Know Issue: https://github.com/electron/electron/issues/12130#issuecomment-627198990
+ */
+if (isWin) {
+ app.commandLine.appendSwitch('wm-window-animations-disabled')
+}
+
+// in production mode, handle uncaught exception and unhandled rejection globally
+if (!isDev) {
+ // handle uncaught exception
+ process.on('uncaughtException', (error) => {
+ Logger.error('Uncaught Exception:', error)
+ })
+
+ // handle unhandled rejection
+ process.on('unhandledRejection', (reason, promise) => {
+ Logger.error('Unhandled Rejection at:', promise, 'reason:', reason)
+ })
+}
+
// Check for single instance lock
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
} else {
+ // Portable dir must be setup before app ready
+ setUserDataDir()
+
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@@ -56,23 +87,17 @@ if (!app.requestSingleInstanceLock()) {
replaceDevtoolsFont(mainWindow)
- setUserDataDir()
-
// Setup deep link for AppImage on Linux
await setupAppImageDeepLink()
- if (process.env.NODE_ENV === 'development') {
+ if (isDev) {
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
- ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
- return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
- })
- ipcMain.handle(IpcChannel.System_GetHostname, () => {
- return require('os').hostname()
- })
+ //start selection assistant service
+ initSelectionService()
})
registerProtocolClient(app)
@@ -99,6 +124,11 @@ if (!app.requestSingleInstanceLock()) {
app.on('before-quit', () => {
app.isQuitting = true
+
+ // quit selection service
+ if (selectionService) {
+ selectionService.quit()
+ }
})
app.on('will-quit', async () => {
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 0ae09d8b21..2fd60377c8 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -3,12 +3,13 @@ import { arch } from 'node:os'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
+import { handleZoomFactor } from '@main/utils/zoom'
import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
-import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
+import { BrowserWindow, ipcMain, session, shell } from 'electron'
import log from 'electron-log'
+import { Notification } from 'src/renderer/src/types/notification'
-import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
@@ -16,20 +17,22 @@ import CopilotService from './services/CopilotService'
import { ExportService } from './services/ExportService'
import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
-import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
+import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { searchService } from './services/SearchService'
+import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
-import { TrayService } from './services/TrayService'
+import storeSyncService from './services/StoreSyncService'
+import { themeService } from './services/ThemeService'
import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
-import { getResourcePath } from './utils'
+import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
-import { getConfigDir, getFilesDir } from './utils/file'
+import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@@ -39,6 +42,7 @@ const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
+ const notificationService = new NotificationService(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
@@ -108,10 +112,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setAutoUpdate(isActive)
})
- ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
-
- ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
- configManager.set(key, value)
+ ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
+ configManager.set(key, value, isNotify)
})
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
@@ -120,41 +122,13 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// theme
ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => {
- const notifyThemeChange = () => {
- const windows = BrowserWindow.getAllWindows()
- windows.forEach((win) =>
- win.webContents.send(IpcChannel.ThemeChange, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
- )
- }
-
- if (theme === ThemeMode.auto) {
- nativeTheme.themeSource = 'system'
- nativeTheme.on('updated', notifyThemeChange)
- } else {
- nativeTheme.themeSource = theme
- nativeTheme.removeAllListeners('updated')
- }
-
- mainWindow?.setTitleBarOverlay &&
- mainWindow.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
- configManager.setTheme(theme)
- notifyThemeChange()
+ themeService.setTheme(theme)
})
- // custom css
- ipcMain.handle(IpcChannel.App_SetCustomCss, (event, css: string) => {
- if (css === configManager.getCustomCss()) return
- configManager.setCustomCss(css)
-
- // Broadcast to all windows including the mini window
- const senderWindowId = event.sender.id
+ ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => {
const windows = BrowserWindow.getAllWindows()
- // 向其他窗口广播主题变化
- windows.forEach((win) => {
- if (win.webContents.id !== senderWindowId) {
- win.webContents.send('custom-css:update', css)
- }
- })
+ handleZoomFactor(windows, delta, reset)
+ return configManager.getZoomFactor()
})
// clear cache
@@ -179,15 +153,46 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
+ // get cache size
+ ipcMain.handle(IpcChannel.App_GetCacheSize, async () => {
+ const cachePath = getCacheDir()
+ log.info(`Calculating cache size for path: ${cachePath}`)
+
+ try {
+ const sizeInBytes = await calculateDirectorySize(cachePath)
+ const sizeInMB = (sizeInBytes / (1024 * 1024)).toFixed(2)
+ return `${sizeInMB}`
+ } catch (error: any) {
+ log.error(`Failed to calculate cache size for ${cachePath}: ${error.message}`)
+ return '0'
+ }
+ })
+
// check for update
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
- await appUpdater.checkForUpdates()
+ return await appUpdater.checkForUpdates()
+ })
+
+ // notification
+ ipcMain.handle(IpcChannel.Notification_Send, async (_, notification: Notification) => {
+ await notificationService.sendNotification(notification)
+ })
+ ipcMain.handle(IpcChannel.Notification_OnClick, (_, notification: Notification) => {
+ mainWindow.webContents.send('notification-click', notification)
})
// zip
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
+ // system
+ ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
+ ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
+ ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
+ const win = BrowserWindow.fromWebContents(e.sender)
+ win && win.webContents.toggleDevTools()
+ })
+
// backup
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup)
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore)
@@ -211,11 +216,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
+ ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
+ ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image)
+ ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
- ipcMain.handle(IpcChannel.File_BinaryFile, fileManager.binaryFile)
+ ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
// fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
@@ -260,13 +268,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
- // gemini
- ipcMain.handle(IpcChannel.Gemini_UploadFile, GeminiService.uploadFile)
- ipcMain.handle(IpcChannel.Gemini_Base64File, GeminiService.base64File)
- ipcMain.handle(IpcChannel.Gemini_RetrieveFile, GeminiService.retrieveFile)
- ipcMain.handle(IpcChannel.Gemini_ListFiles, GeminiService.listFiles)
- ipcMain.handle(IpcChannel.Gemini_DeleteFile, GeminiService.deleteFile)
-
// mini window
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
@@ -293,6 +294,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
+ ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
@@ -338,4 +340,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
+
+ // store sync
+ storeSyncService.registerIpcHandler()
+
+ // selection assistant
+ SelectionService.registerIpcHandler()
}
diff --git a/src/main/mcpServers/brave-search.ts b/src/main/mcpServers/brave-search.ts
index 56a7a0b094..6f219e1eb8 100644
--- a/src/main/mcpServers/brave-search.ts
+++ b/src/main/mcpServers/brave-search.ts
@@ -237,8 +237,7 @@ async function getPoisData(apiKey: string, ids: string[]): Promise {
@@ -257,8 +256,7 @@ async function getDescriptionsData(apiKey: string, ids: string[]): Promise {
const validatedArgs = RequestPayloadSchema.parse(args)
if (request.params.name === 'fetch_html') {
- const fetchResult = await Fetcher.html(validatedArgs)
- return fetchResult
+ return await Fetcher.html(validatedArgs)
}
if (request.params.name === 'fetch_json') {
- const fetchResult = await Fetcher.json(validatedArgs)
- return fetchResult
+ return await Fetcher.json(validatedArgs)
}
if (request.params.name === 'fetch_txt') {
- const fetchResult = await Fetcher.txt(validatedArgs)
- return fetchResult
+ return await Fetcher.txt(validatedArgs)
}
if (request.params.name === 'fetch_markdown') {
- const fetchResult = await Fetcher.markdown(validatedArgs)
- return fetchResult
+ return await Fetcher.markdown(validatedArgs)
}
throw new Error('Tool not found')
})
diff --git a/src/main/mcpServers/filesystem.ts b/src/main/mcpServers/filesystem.ts
index 624f11c658..4d99507ba2 100644
--- a/src/main/mcpServers/filesystem.ts
+++ b/src/main/mcpServers/filesystem.ts
@@ -183,7 +183,6 @@ async function searchFiles(
}
} catch (error) {
// Skip invalid paths during search
- continue
}
}
}
diff --git a/src/main/mcpServers/memory.ts b/src/main/mcpServers/memory.ts
index 211b5f2238..746670b36e 100644
--- a/src/main/mcpServers/memory.ts
+++ b/src/main/mcpServers/memory.ts
@@ -2,6 +2,7 @@ import { getConfigDir } from '@main/utils/file'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import { Mutex } from 'async-mutex' // 引入 Mutex
+import Logger from 'electron-log'
import { promises as fs } from 'fs'
import path from 'path'
@@ -355,9 +356,9 @@ class MemoryServer {
private async _initializeManager(memoryPath: string): Promise {
try {
this.knowledgeGraphManager = await KnowledgeGraphManager.create(memoryPath)
- console.log('KnowledgeGraphManager initialized successfully.')
+ Logger.log('KnowledgeGraphManager initialized successfully.')
} catch (error) {
- console.error('Failed to initialize KnowledgeGraphManager:', error)
+ Logger.error('Failed to initialize KnowledgeGraphManager:', error)
// Server might be unusable, consider how to handle this state
// Maybe set a flag and return errors for all tool calls?
this.knowledgeGraphManager = null // Ensure it's null if init fails
diff --git a/src/main/mcpServers/sequentialthinking.ts b/src/main/mcpServers/sequentialthinking.ts
index b212c54f88..4589c0bf34 100644
--- a/src/main/mcpServers/sequentialthinking.ts
+++ b/src/main/mcpServers/sequentialthinking.ts
@@ -55,8 +55,8 @@ class SequentialThinkingServer {
const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } =
thoughtData
- let prefix = ''
- let context = ''
+ let prefix: string
+ let context: string
if (isRevision) {
prefix = chalk.yellow('🔄 Revision')
diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts
index 4109f53986..f956a0573f 100644
--- a/src/main/reranker/BaseReranker.ts
+++ b/src/main/reranker/BaseReranker.ts
@@ -17,6 +17,10 @@ export default abstract class BaseReranker {
* Get Rerank Request Url
*/
protected getRerankUrl() {
+ if (this.base.rerankModelProvider === 'dashscope') {
+ return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
+ }
+
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
@@ -28,6 +32,56 @@ export default abstract class BaseReranker {
return `${baseURL}/rerank`
}
+ /**
+ * Get Rerank Request Body
+ */
+ protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) {
+ const provider = this.base.rerankModelProvider
+ const documents = searchResults.map((doc) => doc.pageContent)
+ const topN = this.base.documentCount
+
+ if (provider === 'voyageai') {
+ return {
+ model: this.base.rerankModel,
+ query,
+ documents,
+ top_k: topN
+ }
+ } else if (provider === 'dashscope') {
+ return {
+ model: this.base.rerankModel,
+ input: {
+ query,
+ documents
+ },
+ parameters: {
+ top_n: topN
+ }
+ }
+ } else {
+ return {
+ model: this.base.rerankModel,
+ query,
+ documents,
+ top_n: topN
+ }
+ }
+ }
+
+ /**
+ * Extract Rerank Result
+ */
+ protected extractRerankResult(data: any) {
+ const provider = this.base.rerankModelProvider
+ if (provider === 'dashscope') {
+ return data.output.results
+ } else if (provider === 'voyageai') {
+ return data.data
+ } else {
+ return data.results
+ }
+ }
+
/**
* Get Rerank Result
* @param searchResults
diff --git a/src/main/reranker/DefaultReranker.ts b/src/main/reranker/DefaultReranker.ts
deleted file mode 100644
index 70a4d05ac5..0000000000
--- a/src/main/reranker/DefaultReranker.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
-import { KnowledgeBaseParams } from '@types'
-
-import BaseReranker from './BaseReranker'
-
-export default class DefaultReranker extends BaseReranker {
- constructor(base: KnowledgeBaseParams) {
- super(base)
- }
-
- async rerank(): Promise {
- throw new Error('Method not implemented.')
- }
-}
diff --git a/src/main/reranker/JinaReranker.ts b/src/main/reranker/GeneralReranker.ts
similarity index 70%
rename from src/main/reranker/JinaReranker.ts
rename to src/main/reranker/GeneralReranker.ts
index 88350a5e61..185e2132c7 100644
--- a/src/main/reranker/JinaReranker.ts
+++ b/src/main/reranker/GeneralReranker.ts
@@ -4,7 +4,7 @@ import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker'
-export default class JinaReranker extends BaseReranker {
+export default class GeneralReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
@@ -12,21 +12,15 @@ export default class JinaReranker extends BaseReranker {
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => {
const url = this.getRerankUrl()
- const requestBody = {
- model: this.base.rerankModel,
- query,
- documents: searchResults.map((doc) => doc.pageContent),
- top_n: this.base.topN
- }
+ const requestBody = this.getRerankRequestBody(query, searchResults)
try {
const { data } = await AxiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
- const rerankResults = data.results
+ const rerankResults = this.extractRerankResult(data)
return this.getRerankResult(searchResults, rerankResults)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)
- console.error('Jina Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
diff --git a/src/main/reranker/Reranker.ts b/src/main/reranker/Reranker.ts
index f9f37cfca6..d42376ea20 100644
--- a/src/main/reranker/Reranker.ts
+++ b/src/main/reranker/Reranker.ts
@@ -1,13 +1,12 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
-import BaseReranker from './BaseReranker'
-import RerankerFactory from './RerankerFactory'
+import GeneralReranker from './GeneralReranker'
export default class Reranker {
- private sdk: BaseReranker
+ private sdk: GeneralReranker
constructor(base: KnowledgeBaseParams) {
- this.sdk = RerankerFactory.create(base)
+ this.sdk = new GeneralReranker(base)
}
public async rerank(query: string, searchResults: ExtractChunkData[]): Promise {
return this.sdk.rerank(query, searchResults)
diff --git a/src/main/reranker/RerankerFactory.ts b/src/main/reranker/RerankerFactory.ts
deleted file mode 100644
index 9557d58a97..0000000000
--- a/src/main/reranker/RerankerFactory.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { KnowledgeBaseParams } from '@types'
-
-import BaseReranker from './BaseReranker'
-import DefaultReranker from './DefaultReranker'
-import JinaReranker from './JinaReranker'
-import SiliconFlowReranker from './SiliconFlowReranker'
-import VoyageReranker from './VoyageReranker'
-
-export default class RerankerFactory {
- static create(base: KnowledgeBaseParams): BaseReranker {
- if (base.rerankModelProvider === 'silicon') {
- return new SiliconFlowReranker(base)
- } else if (base.rerankModelProvider === 'jina') {
- return new JinaReranker(base)
- } else if (base.rerankModelProvider === 'voyageai') {
- return new VoyageReranker(base)
- }
- return new DefaultReranker(base)
- }
-}
diff --git a/src/main/reranker/SiliconFlowReranker.ts b/src/main/reranker/SiliconFlowReranker.ts
deleted file mode 100644
index 78a213561a..0000000000
--- a/src/main/reranker/SiliconFlowReranker.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
-import axiosProxy from '@main/services/AxiosProxy'
-import { KnowledgeBaseParams } from '@types'
-
-import BaseReranker from './BaseReranker'
-
-export default class SiliconFlowReranker extends BaseReranker {
- constructor(base: KnowledgeBaseParams) {
- super(base)
- }
-
- public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => {
- const url = this.getRerankUrl()
-
- const requestBody = {
- model: this.base.rerankModel,
- query,
- documents: searchResults.map((doc) => doc.pageContent),
- top_n: this.base.topN,
- max_chunks_per_doc: this.base.chunkSize,
- overlap_tokens: this.base.chunkOverlap
- }
-
- try {
- const { data } = await axiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
-
- const rerankResults = data.results
- return this.getRerankResult(searchResults, rerankResults)
- } catch (error: any) {
- const errorDetails = this.formatErrorMessage(url, error, requestBody)
-
- console.error('SiliconFlow Reranker API 错误:', errorDetails)
- throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
- }
- }
-}
diff --git a/src/main/reranker/VoyageReranker.ts b/src/main/reranker/VoyageReranker.ts
deleted file mode 100644
index 44c800b6d5..0000000000
--- a/src/main/reranker/VoyageReranker.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
-import axiosProxy from '@main/services/AxiosProxy'
-import { KnowledgeBaseParams } from '@types'
-
-import BaseReranker from './BaseReranker'
-
-export default class VoyageReranker extends BaseReranker {
- constructor(base: KnowledgeBaseParams) {
- super(base)
- }
-
- public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => {
- const url = this.getRerankUrl()
-
- const requestBody = {
- model: this.base.rerankModel,
- query,
- documents: searchResults.map((doc) => doc.pageContent),
- top_k: this.base.topN,
- return_documents: false,
- truncation: true
- }
-
- try {
- const { data } = await axiosProxy.axios.post(url, requestBody, {
- headers: {
- ...this.defaultHeaders()
- }
- })
-
- const rerankResults = data.data
- return this.getRerankResult(searchResults, rerankResults)
- } catch (error: any) {
- const errorDetails = this.formatErrorMessage(url, error, requestBody)
-
- console.error('Voyage Reranker API Error:', errorDetails)
- throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
- }
- }
-}
diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts
index 560c73d6e4..1733bc6068 100644
--- a/src/main/services/AppUpdater.ts
+++ b/src/main/services/AppUpdater.ts
@@ -1,4 +1,5 @@
import { isWin } from '@main/constant'
+import { locales } from '@main/utils/locales'
import { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
@@ -94,15 +95,22 @@ export default class AppUpdater {
if (!this.releaseInfo) {
return
}
+ const locale = locales[configManager.getLanguage()]
+ const { update: updateLocale } = locale.translation
+
+ let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
+ if (detail === '') {
+ detail = updateLocale.noReleaseNotes
+ }
dialog
.showMessageBox({
type: 'info',
- title: '安装更新',
+ title: updateLocale.title,
icon,
- message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
- detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
- buttons: ['稍后安装', '立即安装'],
+ message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
+ detail,
+ buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
@@ -118,7 +126,7 @@ export default class AppUpdater {
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
- return '暂无更新说明'
+ return ''
}
if (typeof releaseNotes === 'string') {
diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts
index 5bf8578ea8..63a3303c0a 100644
--- a/src/main/services/BackupManager.ts
+++ b/src/main/services/BackupManager.ts
@@ -4,10 +4,10 @@ import archiver from 'archiver'
import { exec } from 'child_process'
import { app } from 'electron'
import Logger from 'electron-log'
-import extract from 'extract-zip'
import * as fs from 'fs-extra'
+import StreamZip from 'node-stream-zip'
import * as path from 'path'
-import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
+import { CreateDirectoryOptions, FileStat } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -77,7 +77,8 @@ class BackupManager {
_: Electron.IpcMainInvokeEvent,
fileName: string,
data: string,
- destinationPath: string = this.backupDir
+ destinationPath: string = this.backupDir,
+ skipBackupFile: boolean = false
): Promise {
const mainWindow = windowService.getMainWindow()
@@ -104,23 +105,30 @@ class BackupManager {
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
- // 复制 Data 目录到临时目录
- const sourcePath = path.join(app.getPath('userData'), 'Data')
- const tempDataDir = path.join(this.tempDir, 'Data')
+ Logger.log('[BackupManager IPC] ', skipBackupFile)
- // 获取源目录总大小
- const totalSize = await this.getDirSize(sourcePath)
- let copiedSize = 0
+ if (!skipBackupFile) {
+ // 复制 Data 目录到临时目录
+ const sourcePath = path.join(app.getPath('userData'), 'Data')
+ const tempDataDir = path.join(this.tempDir, 'Data')
- // 使用流式复制
- await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
- copiedSize += size
- const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
- onProgress({ stage: 'copying_files', progress, total: 100 })
- })
+ // 获取源目录总大小
+ const totalSize = await this.getDirSize(sourcePath)
+ let copiedSize = 0
- await this.setWritableRecursive(tempDataDir)
- onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
+ // 使用流式复制
+ await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
+ copiedSize += size
+ const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
+ onProgress({ stage: 'copying_files', progress, total: 100 })
+ })
+
+ await this.setWritableRecursive(tempDataDir)
+ onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
+ } else {
+ Logger.log('[BackupManager] Skip the backup of the file')
+ await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败
+ }
// 创建输出文件流
const backupedFilePath = path.join(destinationPath, fileName)
@@ -231,15 +239,10 @@ class BackupManager {
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
- // 使用 extract-zip 解压
- await extract(backupPath, {
- dir: this.tempDir,
- onEntry: () => {
- // 这里可以处理进度,但 extract-zip 不提供总条目数信息
- onProgress({ stage: 'extracting', progress: 15, total: 100 })
- }
- })
- onProgress({ stage: 'extracting', progress: 25, total: 100 })
+ const zip = new StreamZip.async({ file: backupPath })
+ onProgress({ stage: 'extracting', progress: 15, total: 100 })
+ await zip.extract(null, this.tempDir)
+ onProgress({ stage: 'extracted', progress: 25, total: 100 })
Logger.log('[backup] step 2: read data.json')
// 读取 data.json
@@ -252,19 +255,26 @@ class BackupManager {
const sourcePath = path.join(this.tempDir, 'Data')
const destPath = path.join(app.getPath('userData'), 'Data')
- // 获取源目录总大小
- const totalSize = await this.getDirSize(sourcePath)
- let copiedSize = 0
+ const dataExists = await fs.pathExists(sourcePath)
+ const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
- await this.setWritableRecursive(destPath)
- await fs.remove(destPath)
+ if (dataExists && dataFiles.length > 0) {
+ // 获取源目录总大小
+ const totalSize = await this.getDirSize(sourcePath)
+ let copiedSize = 0
- // 使用流式复制
- await this.copyDirWithProgress(sourcePath, destPath, (size) => {
- copiedSize += size
- const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
- onProgress({ stage: 'copying_files', progress, total: 100 })
- })
+ await this.setWritableRecursive(destPath)
+ await fs.remove(destPath)
+
+ // 使用流式复制
+ await this.copyDirWithProgress(sourcePath, destPath, (size) => {
+ copiedSize += size
+ const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
+ onProgress({ stage: 'copying_files', progress, total: 100 })
+ })
+ } else {
+ Logger.log('[backup] skipBackupFile is true, skip restoring Data directory')
+ }
Logger.log('[backup] step 4: clean up temp directory')
// 清理临时目录
@@ -284,11 +294,20 @@ class BackupManager {
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
- const backupedFilePath = await this.backup(_, filename, data)
+ const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
const webdavClient = new WebDav(webdavConfig)
- return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
- overwrite: true
- })
+ try {
+ const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
+ overwrite: true
+ })
+ // 上传成功后删除本地备份文件
+ await fs.remove(backupedFilePath)
+ return result
+ } catch (error) {
+ // 上传失败时也删除本地临时文件
+ await fs.remove(backupedFilePath).catch(() => {})
+ throw error
+ }
}
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
@@ -321,12 +340,8 @@ class BackupManager {
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
try {
- const client = createClient(config.webdavHost, {
- username: config.webdavUser,
- password: config.webdavPass
- })
-
- const response = await client.getDirectoryContents(config.webdavPath)
+ const client = new WebDav(config)
+ const response = await client.getDirectoryContents()
const files = Array.isArray(response) ? response : response.data
return files
diff --git a/src/main/services/ClipboardMonitor.ts b/src/main/services/ClipboardMonitor.ts
deleted file mode 100644
index 8a4ff540c6..0000000000
--- a/src/main/services/ClipboardMonitor.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { debounce, getResourcePath } from '@main/utils'
-import { exec } from 'child_process'
-import { screen } from 'electron'
-import path from 'path'
-
-import { windowService } from './WindowService'
-
-export default class ClipboardMonitor {
- private platform: string
- private lastText: string
- private user32: any
- private observer: any
- public onTextSelected: (text: string) => void
-
- constructor() {
- this.platform = process.platform
- this.lastText = ''
- this.onTextSelected = debounce((text: string) => this.handleTextSelected(text), 550)
-
- if (this.platform === 'win32') {
- this.setupWindows()
- } else if (this.platform === 'darwin') {
- this.setupMacOS()
- }
- }
-
- setupMacOS() {
- // 使用 Swift 脚本来监听文本选择
- const scriptPath = path.join(getResourcePath(), 'textMonitor.swift')
-
- // 启动 Swift 进程来监听文本选择
- const process = exec(`swift ${scriptPath}`)
-
- process?.stdout?.on('data', (data: string) => {
- console.log('[ClipboardMonitor] MacOS data:', data)
- const text = data.toString().trim()
- if (text && text !== this.lastText) {
- this.lastText = text
- this.onTextSelected(text)
- }
- })
-
- process.on('error', (error) => {
- console.error('[ClipboardMonitor] MacOS error:', error)
- })
- }
-
- setupWindows() {
- // 使用 Windows API 监听文本选择事件
- const ffi = require('ffi-napi')
- const ref = require('ref-napi')
-
- this.user32 = new ffi.Library('user32', {
- SetWinEventHook: ['pointer', ['uint32', 'uint32', 'pointer', 'pointer', 'uint32', 'uint32', 'uint32']],
- UnhookWinEvent: ['bool', ['pointer']]
- })
-
- // 定义事件常量
- const EVENT_OBJECT_SELECTION = 0x8006
- const WINEVENT_OUTOFCONTEXT = 0x0000
- const WINEVENT_SKIPOWNTHREAD = 0x0001
- const WINEVENT_SKIPOWNPROCESS = 0x0002
-
- // 创建回调函数
- const callback = ffi.Callback('void', ['pointer', 'uint32', 'pointer', 'long', 'long', 'uint32', 'uint32'], () => {
- this.getSelectedText()
- })
-
- // 设置事件钩子
- this.observer = this.user32.SetWinEventHook(
- EVENT_OBJECT_SELECTION,
- EVENT_OBJECT_SELECTION,
- ref.NULL,
- callback,
- 0,
- 0,
- WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNTHREAD | WINEVENT_SKIPOWNPROCESS
- )
- }
-
- getSelectedText() {
- // Get selected text
- if (this.platform === 'win32') {
- const ref = require('ref-napi')
- if (this.user32.OpenClipboard(ref.NULL)) {
- // Get clipboard content
- const text = this.user32.GetClipboardData(1) // CF_TEXT = 1
- this.user32.CloseClipboard()
-
- if (text && text !== this.lastText) {
- this.lastText = text
- this.onTextSelected(text)
- }
- }
- }
- }
-
- private handleTextSelected(text: string) {
- if (!text) return
-
- console.log('[ClipboardMonitor] handleTextSelected', text)
-
- windowService.setLastSelectedText(text)
-
- const mousePosition = screen.getCursorScreenPoint()
-
- windowService.showSelectionMenu({
- x: mousePosition.x,
- y: mousePosition.y + 10
- })
- }
-
- dispose() {
- if (this.platform === 'win32' && this.observer) {
- this.user32.UnhookWinEvent(this.observer)
- }
- }
-}
diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts
index 86f23f80d2..fbe871cbb4 100644
--- a/src/main/services/ConfigManager.ts
+++ b/src/main/services/ConfigManager.ts
@@ -5,7 +5,7 @@ import Store from 'electron-store'
import { locales } from '../utils/locales'
-enum ConfigKeys {
+export enum ConfigKeys {
Language = 'language',
Theme = 'theme',
LaunchToTray = 'launchToTray',
@@ -16,7 +16,13 @@ enum ConfigKeys {
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate',
- EnableDataCollection = 'enableDataCollection'
+ EnableDataCollection = 'enableDataCollection',
+ SelectionAssistantEnabled = 'selectionAssistantEnabled',
+ SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
+ SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
+ SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
+ SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
+ SelectionAssistantFilterList = 'selectionAssistantFilterList'
}
export class ConfigManager {
@@ -32,26 +38,18 @@ export class ConfigManager {
return this.get(ConfigKeys.Language, locale) as LanguageVarious
}
- setLanguage(theme: LanguageVarious) {
- this.set(ConfigKeys.Language, theme)
+ setLanguage(lang: LanguageVarious) {
+ this.setAndNotify(ConfigKeys.Language, lang)
}
getTheme(): ThemeMode {
- return this.get(ConfigKeys.Theme, ThemeMode.auto)
+ return this.get(ConfigKeys.Theme, ThemeMode.system)
}
setTheme(theme: ThemeMode) {
this.set(ConfigKeys.Theme, theme)
}
- getCustomCss(): string {
- return this.store.get('customCss', '') as string
- }
-
- setCustomCss(css: string) {
- this.store.set('customCss', css)
- }
-
getLaunchToTray(): boolean {
return !!this.get(ConfigKeys.LaunchToTray, false)
}
@@ -65,8 +63,7 @@ export class ConfigManager {
}
setTray(value: boolean) {
- this.set(ConfigKeys.Tray, value)
- this.notifySubscribers(ConfigKeys.Tray, value)
+ this.setAndNotify(ConfigKeys.Tray, value)
}
getTrayOnClose(): boolean {
@@ -82,8 +79,7 @@ export class ConfigManager {
}
setZoomFactor(factor: number) {
- this.set(ConfigKeys.ZoomFactor, factor)
- this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
+ this.setAndNotify(ConfigKeys.ZoomFactor, factor)
}
subscribe(key: string, callback: (newValue: T) => void) {
@@ -115,11 +111,10 @@ export class ConfigManager {
}
setShortcuts(shortcuts: Shortcut[]) {
- this.set(
+ this.setAndNotify(
ConfigKeys.Shortcuts,
shortcuts.filter((shortcut) => shortcut.system)
)
- this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
}
getClickTrayToShowQuickAssistant(): boolean {
@@ -135,7 +130,7 @@ export class ConfigManager {
}
setEnableQuickAssistant(value: boolean) {
- this.set(ConfigKeys.EnableQuickAssistant, value)
+ this.setAndNotify(ConfigKeys.EnableQuickAssistant, value)
}
getAutoUpdate(): boolean {
@@ -154,8 +149,64 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDataCollection, value)
}
- set(key: string, value: unknown) {
+ // Selection Assistant: is enabled the selection assistant
+ getSelectionAssistantEnabled(): boolean {
+ return this.get(ConfigKeys.SelectionAssistantEnabled, true)
+ }
+
+ setSelectionAssistantEnabled(value: boolean) {
+ this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value)
+ }
+
+ // Selection Assistant: trigger mode (selected, ctrlkey)
+ getSelectionAssistantTriggerMode(): string {
+ return this.get(ConfigKeys.SelectionAssistantTriggerMode, 'selected')
+ }
+
+ setSelectionAssistantTriggerMode(value: string) {
+ this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value)
+ }
+
+ // Selection Assistant: if action window position follow toolbar
+ getSelectionAssistantFollowToolbar(): boolean {
+ return this.get(ConfigKeys.SelectionAssistantFollowToolbar, true)
+ }
+
+ setSelectionAssistantFollowToolbar(value: boolean) {
+ this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value)
+ }
+
+ getSelectionAssistantRemeberWinSize(): boolean {
+ return this.get(ConfigKeys.SelectionAssistantRemeberWinSize, false)
+ }
+
+ setSelectionAssistantRemeberWinSize(value: boolean) {
+ this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value)
+ }
+
+ getSelectionAssistantFilterMode(): string {
+ return this.get(ConfigKeys.SelectionAssistantFilterMode, 'default')
+ }
+
+ setSelectionAssistantFilterMode(value: string) {
+ this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value)
+ }
+
+ getSelectionAssistantFilterList(): string[] {
+ return this.get(ConfigKeys.SelectionAssistantFilterList, [])
+ }
+
+ setSelectionAssistantFilterList(value: string[]) {
+ this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value)
+ }
+
+ setAndNotify(key: string, value: unknown) {
+ this.set(key, value, true)
+ }
+
+ set(key: string, value: unknown, isNotify: boolean = false) {
this.store.set(key, value)
+ isNotify && this.notifySubscribers(key, value)
}
get(key: string, defaultValue?: T) {
diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts
new file mode 100644
index 0000000000..2f4f5aa20f
--- /dev/null
+++ b/src/main/services/ContextMenu.ts
@@ -0,0 +1,77 @@
+import { Menu, MenuItemConstructorOptions } from 'electron'
+
+import { locales } from '../utils/locales'
+import { configManager } from './ConfigManager'
+
+class ContextMenu {
+ public contextMenu(w: Electron.BrowserWindow) {
+ w.webContents.on('context-menu', (_event, properties) => {
+ const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties)
+ const filtered = template.filter((item) => item.visible !== false)
+ if (filtered.length > 0) {
+ const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)])
+ menu.popup()
+ }
+ })
+ }
+
+ private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] {
+ const locale = locales[configManager.getLanguage()]
+ const { common } = locale.translation
+ const template: MenuItemConstructorOptions[] = [
+ {
+ id: 'inspect',
+ label: common.inspect,
+ click: () => {
+ w.webContents.toggleDevTools()
+ },
+ enabled: true
+ }
+ ]
+
+ return template
+ }
+
+ private createEditMenuItems(properties: Electron.ContextMenuParams): MenuItemConstructorOptions[] {
+ const locale = locales[configManager.getLanguage()]
+ const { common } = locale.translation
+ const hasText = properties.selectionText.trim().length > 0
+ const can = (type: string) => properties.editFlags[`can${type}`] && hasText
+
+ const template: MenuItemConstructorOptions[] = [
+ {
+ id: 'copy',
+ label: common.copy,
+ role: 'copy',
+ enabled: can('Copy'),
+ visible: properties.isEditable || hasText
+ },
+ {
+ id: 'paste',
+ label: common.paste,
+ role: 'paste',
+ enabled: properties.editFlags.canPaste,
+ visible: properties.isEditable
+ },
+ {
+ id: 'cut',
+ label: common.cut,
+ role: 'cut',
+ enabled: can('Cut'),
+ visible: properties.isEditable
+ }
+ ]
+
+ // remove role from items that are not enabled
+ // https://github.com/electron/electron/issues/13554
+ template.forEach((item) => {
+ if (item.enabled === false) {
+ item.role = undefined
+ }
+ })
+
+ return template
+ }
+}
+
+export const contextMenu = new ContextMenu()
diff --git a/src/main/services/CopilotService.ts b/src/main/services/CopilotService.ts
index bc3f2c4afd..0be9ee8a5e 100644
--- a/src/main/services/CopilotService.ts
+++ b/src/main/services/CopilotService.ts
@@ -1,5 +1,6 @@
import { AxiosRequestConfig } from 'axios'
import { app, safeStorage } from 'electron'
+import Logger from 'electron-log'
import fs from 'fs/promises'
import path from 'path'
@@ -227,10 +228,10 @@ class CopilotService {
try {
await fs.access(this.tokenFilePath)
await fs.unlink(this.tokenFilePath)
- console.log('Successfully logged out from Copilot')
+ Logger.log('Successfully logged out from Copilot')
} catch (error) {
// 文件不存在不是错误,只是记录一下
- console.log('Token file not found, nothing to delete')
+ Logger.log('Token file not found, nothing to delete')
}
} catch (error) {
console.error('Failed to logout:', error)
diff --git a/src/main/services/ExportService.ts b/src/main/services/ExportService.ts
index 44175f4244..b17acc9bde 100644
--- a/src/main/services/ExportService.ts
+++ b/src/main/services/ExportService.ts
@@ -47,6 +47,8 @@ export class ExportService {
let linkText = ''
let linkUrl = ''
let insideLink = false
+ let boldStack = 0 // 跟踪嵌套的粗体标记
+ let italicStack = 0 // 跟踪嵌套的斜体标记
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
@@ -82,17 +84,37 @@ export class ExportService {
insideLink = false
}
break
+ case 'strong_open':
+ boldStack++
+ break
+ case 'strong_close':
+ boldStack--
+ break
+ case 'em_open':
+ italicStack++
+ break
+ case 'em_close':
+ italicStack--
+ break
case 'text':
- runs.push(new TextRun({ text: token.content, bold: isHeaderRow ? true : false }))
- break
- case 'strong':
- runs.push(new TextRun({ text: token.content, bold: true }))
- break
- case 'em':
- runs.push(new TextRun({ text: token.content, italics: true }))
+ runs.push(
+ new TextRun({
+ text: token.content,
+ bold: isHeaderRow || boldStack > 0,
+ italics: italicStack > 0
+ })
+ )
break
case 'code_inline':
- runs.push(new TextRun({ text: token.content, font: 'Consolas', size: 20 }))
+ runs.push(
+ new TextRun({
+ text: token.content,
+ font: 'Consolas',
+ size: 20,
+ bold: isHeaderRow || boldStack > 0,
+ italics: italicStack > 0
+ })
+ )
break
}
}
diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts
index 4e575d7c6a..c6fcb14e98 100644
--- a/src/main/services/FileStorage.ts
+++ b/src/main/services/FileStorage.ts
@@ -28,11 +28,16 @@ class FileStorage {
}
private initStorageDir = (): void => {
- if (!fs.existsSync(this.storageDir)) {
- fs.mkdirSync(this.storageDir, { recursive: true })
- }
- if (!fs.existsSync(this.tempDir)) {
- fs.mkdirSync(this.tempDir, { recursive: true })
+ try {
+ if (!fs.existsSync(this.storageDir)) {
+ fs.mkdirSync(this.storageDir, { recursive: true })
+ }
+ if (!fs.existsSync(this.tempDir)) {
+ fs.mkdirSync(this.tempDir, { recursive: true })
+ }
+ } catch (error) {
+ logger.error('[FileStorage] Failed to initialize storage directories:', error)
+ throw error
}
}
@@ -263,7 +268,60 @@ class FileStorage {
}
}
- public binaryFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
+ public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise => {
+ try {
+ if (!base64Data) {
+ throw new Error('Base64 data is required')
+ }
+
+ // 移除 base64 头部信息(如果存在)
+ const base64String = base64Data.replace(/^data:.*;base64,/, '')
+ const buffer = Buffer.from(base64String, 'base64')
+ const uuid = uuidv4()
+ const ext = '.png'
+ const destPath = path.join(this.storageDir, uuid + ext)
+
+ logger.info('[FileStorage] Saving base64 image:', {
+ storageDir: this.storageDir,
+ destPath,
+ bufferSize: buffer.length
+ })
+
+ // 确保目录存在
+ if (!fs.existsSync(this.storageDir)) {
+ fs.mkdirSync(this.storageDir, { recursive: true })
+ }
+
+ await fs.promises.writeFile(destPath, buffer)
+
+ const fileMetadata: FileType = {
+ id: uuid,
+ origin_name: uuid + ext,
+ name: uuid + ext,
+ path: destPath,
+ created_at: new Date().toISOString(),
+ size: buffer.length,
+ ext: ext.slice(1),
+ type: getFileType(ext),
+ count: 1
+ }
+
+ return fileMetadata
+ } catch (error) {
+ logger.error('[FileStorage] Failed to save base64 image:', error)
+ throw error
+ }
+ }
+
+ public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
+ const filePath = path.join(this.storageDir, id)
+ const buffer = await fs.promises.readFile(filePath)
+ const base64 = buffer.toString('base64')
+ const mime = `application/${path.extname(filePath).slice(1)}`
+ return { data: base64, mime }
+ }
+
+ public binaryImage = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const mime = `image/${path.extname(filePath).slice(1)}`
@@ -323,14 +381,18 @@ class FileStorage {
...options
})
+ if (result.canceled) {
+ return Promise.reject(new Error('User canceled the save dialog'))
+ }
+
if (!result.canceled && result.filePath) {
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
return result.filePath
- } catch (err) {
+ } catch (err: any) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
- return null
+ return Promise.reject('An error occurred saving the file: ' + err?.message)
}
}
@@ -369,7 +431,11 @@ class FileStorage {
}
}
- public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise => {
+ public downloadFile = async (
+ _: Electron.IpcMainInvokeEvent,
+ url: string,
+ isUseContentType?: boolean
+ ): Promise => {
try {
const response = await fetch(url)
if (!response.ok) {
@@ -394,7 +460,7 @@ class FileStorage {
}
// 如果文件名没有后缀,根据Content-Type添加后缀
- if (!filename.includes('.')) {
+ if (isUseContentType || !filename.includes('.')) {
const contentType = response.headers.get('Content-Type')
const ext = this.getExtensionFromMimeType(contentType)
filename += ext
@@ -467,6 +533,25 @@ class FileStorage {
throw error
}
}
+
+ public writeFileWithId = async (_: Electron.IpcMainInvokeEvent, id: string, content: string): Promise => {
+ try {
+ const filePath = path.join(this.storageDir, id)
+ logger.info('[FileStorage] Writing file:', filePath)
+
+ // 确保目录存在
+ if (!fs.existsSync(this.storageDir)) {
+ logger.info('[FileStorage] Creating storage directory:', this.storageDir)
+ fs.mkdirSync(this.storageDir, { recursive: true })
+ }
+
+ await fs.promises.writeFile(filePath, content, 'utf8')
+ logger.info('[FileStorage] File written successfully:', filePath)
+ } catch (error) {
+ logger.error('[FileStorage] Failed to write file:', error)
+ throw error
+ }
+ }
}
export default FileStorage
diff --git a/src/main/services/GeminiService.ts b/src/main/services/GeminiService.ts
deleted file mode 100644
index d2e46f4b89..0000000000
--- a/src/main/services/GeminiService.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { File, FileState, GoogleGenAI, Pager } from '@google/genai'
-import { FileType } from '@types'
-import fs from 'fs'
-
-import { CacheService } from './CacheService'
-
-export class GeminiService {
- private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
- private static readonly CACHE_DURATION = 3000
-
- static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise {
- const sdk = new GoogleGenAI({ vertexai: false, apiKey })
- const uploadResult = await sdk.files.upload({
- file: file.path,
- config: {
- mimeType: 'application/pdf',
- name: file.id,
- displayName: file.origin_name
- }
- })
- return uploadResult
- }
-
- static async base64File(_: Electron.IpcMainInvokeEvent, file: FileType) {
- return {
- data: Buffer.from(fs.readFileSync(file.path)).toString('base64'),
- mimeType: 'application/pdf'
- }
- }
-
- static async retrieveFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise {
- const sdk = new GoogleGenAI({ vertexai: false, apiKey })
- const cachedResponse = CacheService.get(GeminiService.FILE_LIST_CACHE_KEY)
- if (cachedResponse) {
- return GeminiService.processResponse(cachedResponse, file)
- }
-
- const response = await sdk.files.list()
- CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, response, GeminiService.CACHE_DURATION)
-
- return GeminiService.processResponse(response, file)
- }
-
- private static async processResponse(response: Pager, file: FileType) {
- for await (const f of response) {
- if (f.state === FileState.ACTIVE) {
- if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
- return f
- }
- }
- }
-
- return undefined
- }
-
- static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string): Promise {
- const sdk = new GoogleGenAI({ vertexai: false, apiKey })
- const files: File[] = []
- for await (const f of await sdk.files.list()) {
- files.push(f)
- }
- return files
- }
-
- static async deleteFile(_: Electron.IpcMainInvokeEvent, fileId: string, apiKey: string) {
- const sdk = new GoogleGenAI({ vertexai: false, apiKey })
- await sdk.files.delete({ name: fileId })
- }
-}
diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts
index ef8fa1b88b..e82b54d0d2 100644
--- a/src/main/services/KnowledgeService.ts
+++ b/src/main/services/KnowledgeService.ts
@@ -459,7 +459,7 @@ class KnowledgeService {
{ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }
): Promise => {
const ragApplication = await this.getRagApplication(base)
- console.log(`[ KnowledgeService Remove Item UniqueId: ${uniqueId}]`)
+ Logger.log(`[ KnowledgeService Remove Item UniqueId: ${uniqueId}]`)
for (const id of uniqueIds) {
await ragApplication.deleteLoader(id)
}
diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts
index 43f1a55889..2515c91416 100644
--- a/src/main/services/MCPService.ts
+++ b/src/main/services/MCPService.ts
@@ -1,15 +1,14 @@
import crypto from 'node:crypto'
-import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
-import { isLinux, isMac, isWin } from '@main/constant'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists } from '@main/utils'
+import { buildFunctionCallToolName } from '@main/utils/mcp'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
-import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import {
StreamableHTTPClientTransport,
type StreamableHTTPClientTransportOptions
@@ -33,6 +32,7 @@ import { memoize } from 'lodash'
import { CacheService } from './CacheService'
import { CallBackServer } from './mcp/oauth/callback'
import { McpOAuthClientProvider } from './mcp/oauth/provider'
+import getLoginShellEnvironment from './mcp/shell-env'
// Generic type for caching wrapped functions
type CachedFunction = (...args: T) => Promise
@@ -70,17 +70,7 @@ function withCache(
class McpService {
private clients: Map = new Map()
-
- private getServerKey(server: MCPServer): string {
- return JSON.stringify({
- baseUrl: server.baseUrl,
- command: server.command,
- args: server.args,
- registryUrl: server.registryUrl,
- env: server.env,
- id: server.id
- })
- }
+ private pendingClients: Map> = new Map()
constructor() {
this.initClient = this.initClient.bind(this)
@@ -97,9 +87,26 @@ class McpService {
this.cleanup = this.cleanup.bind(this)
}
+ private getServerKey(server: MCPServer): string {
+ return JSON.stringify({
+ baseUrl: server.baseUrl,
+ command: server.command,
+ args: Array.isArray(server.args) ? server.args : [],
+ registryUrl: server.registryUrl,
+ env: server.env,
+ id: server.id
+ })
+ }
+
async initClient(server: MCPServer): Promise {
const serverKey = this.getServerKey(server)
+ // If there's a pending initialization, wait for it
+ const pendingClient = this.pendingClients.get(serverKey)
+ if (pendingClient) {
+ return pendingClient
+ }
+
// Check if we already have a client for this server configuration
const existingClient = this.clients.get(serverKey)
if (existingClient) {
@@ -114,194 +121,232 @@ class McpService {
} else {
return existingClient
}
- } catch (error) {
- Logger.error(`[MCP] Error pinging server ${server.name}:`, error)
+ } catch (error: any) {
+ Logger.error(`[MCP] Error pinging server ${server.name}:`, error?.message)
this.clients.delete(serverKey)
}
}
- // Create new client instance for each connection
- const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
- const args = [...(server.args || [])]
+ // Create a promise for the initialization process
+ const initPromise = (async () => {
+ try {
+ // Create new client instance for each connection
+ const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
- // let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
- const authProvider = new McpOAuthClientProvider({
- serverUrlHash: crypto
- .createHash('md5')
- .update(server.baseUrl || '')
- .digest('hex')
- })
+ const args = [...(server.args || [])]
- const initTransport = async (): Promise<
- StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
- > => {
- // Create appropriate transport based on configuration
- if (server.type === 'inMemory') {
- Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
- // start the in-memory server with the given name and environment variables
- const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
- try {
- await inMemoryServer.connect(serverTransport)
- Logger.info(`[MCP] In-memory server started: ${server.name}`)
- } catch (error: Error | any) {
- Logger.error(`[MCP] Error starting in-memory server: ${error}`)
- throw new Error(`Failed to start in-memory server: ${error.message}`)
- }
- // set the client transport to the client
- return clientTransport
- } else if (server.baseUrl) {
- if (server.type === 'streamableHttp') {
- const options: StreamableHTTPClientTransportOptions = {
- requestInit: {
- headers: server.headers || {}
- },
- authProvider
- }
- return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
- } else if (server.type === 'sse') {
- const options: SSEClientTransportOptions = {
- eventSourceInit: {
- fetch: (url, init) => fetch(url, { ...init, headers: server.headers || {} })
- },
- requestInit: {
- headers: server.headers || {}
- },
- authProvider
- }
- return new SSEClientTransport(new URL(server.baseUrl!), options)
- } else {
- throw new Error('Invalid server type')
- }
- } else if (server.command) {
- let cmd = server.command
-
- if (server.command === 'npx') {
- cmd = await getBinaryPath('bun')
- Logger.info(`[MCP] Using command: ${cmd}`)
-
- // add -x to args if args exist
- if (args && args.length > 0) {
- if (!args.includes('-y')) {
- !args.includes('-y') && args.unshift('-y')
- }
- if (!args.includes('x')) {
- args.unshift('x')
- }
- }
- if (server.registryUrl) {
- server.env = {
- ...server.env,
- NPM_CONFIG_REGISTRY: server.registryUrl
- }
-
- // if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
- if (server.name.includes('mcp-auto-install')) {
- const binPath = await getBinaryPath()
- makeSureDirExists(binPath)
- server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
- }
- }
- } else if (server.command === 'uvx' || server.command === 'uv') {
- cmd = await getBinaryPath(server.command)
- if (server.registryUrl) {
- server.env = {
- ...server.env,
- UV_DEFAULT_INDEX: server.registryUrl,
- PIP_INDEX_URL: server.registryUrl
- }
- }
- }
-
- Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
- // Logger.info(`[MCP] Environment variables for server:`, server.env)
-
- const stdioTransport = new StdioClientTransport({
- command: cmd,
- args,
- env: {
- ...getDefaultEnvironment(),
- PATH: await this.getEnhancedPath(process.env.PATH || ''),
- ...server.env
- },
- stderr: 'pipe'
+ // let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
+ const authProvider = new McpOAuthClientProvider({
+ serverUrlHash: crypto
+ .createHash('md5')
+ .update(server.baseUrl || '')
+ .digest('hex')
})
- stdioTransport.stderr?.on('data', (data) =>
- Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
- )
- return stdioTransport
- } else {
- throw new Error('Either baseUrl or command must be provided')
- }
- }
- const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
- Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
- // Create an event emitter for the OAuth callback
- const events = new EventEmitter()
+ const initTransport = async (): Promise<
+ StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
+ > => {
+ // Create appropriate transport based on configuration
+ if (server.type === 'inMemory') {
+ Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
+ // start the in-memory server with the given name and environment variables
+ const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
+ try {
+ await inMemoryServer.connect(serverTransport)
+ Logger.info(`[MCP] In-memory server started: ${server.name}`)
+ } catch (error: Error | any) {
+ Logger.error(`[MCP] Error starting in-memory server: ${error}`)
+ throw new Error(`Failed to start in-memory server: ${error.message}`)
+ }
+ // set the client transport to the client
+ return clientTransport
+ } else if (server.baseUrl) {
+ if (server.type === 'streamableHttp') {
+ const options: StreamableHTTPClientTransportOptions = {
+ requestInit: {
+ headers: server.headers || {}
+ },
+ authProvider
+ }
+ return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
+ } else if (server.type === 'sse') {
+ const options: SSEClientTransportOptions = {
+ eventSourceInit: {
+ fetch: async (url, init) => {
+ const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
- // Create a callback server
- const callbackServer = new CallBackServer({
- port: authProvider.config.callbackPort,
- path: authProvider.config.callbackPath || '/oauth/callback',
- events
- })
+ // Get tokens from authProvider to make sure using the latest tokens
+ if (authProvider && typeof authProvider.tokens === 'function') {
+ try {
+ const tokens = await authProvider.tokens()
+ if (tokens && tokens.access_token) {
+ headers['Authorization'] = `Bearer ${tokens.access_token}`
+ }
+ } catch (error) {
+ Logger.error('Failed to fetch tokens:', error)
+ }
+ }
- // Set a timeout to close the callback server
- const timeoutId = setTimeout(() => {
- Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
- callbackServer.close()
- }, 300000) // 5 minutes timeout
+ return fetch(url, { ...init, headers })
+ }
+ },
+ requestInit: {
+ headers: server.headers || {}
+ },
+ authProvider
+ }
+ return new SSEClientTransport(new URL(server.baseUrl!), options)
+ } else {
+ throw new Error('Invalid server type')
+ }
+ } else if (server.command) {
+ let cmd = server.command
- try {
- // Wait for the authorization code
- const authCode = await callbackServer.waitForAuthCode()
- Logger.info(`[MCP] Received auth code: ${authCode}`)
+ if (server.command === 'npx') {
+ cmd = await getBinaryPath('bun')
+ Logger.info(`[MCP] Using command: ${cmd}`)
- // Complete the OAuth flow
- await transport.finishAuth(authCode)
+ // add -x to args if args exist
+ if (args && args.length > 0) {
+ if (!args.includes('-y')) {
+ args.unshift('-y')
+ }
+ if (!args.includes('x')) {
+ args.unshift('x')
+ }
+ }
+ if (server.registryUrl) {
+ server.env = {
+ ...server.env,
+ NPM_CONFIG_REGISTRY: server.registryUrl
+ }
- Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
+ // if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
+ if (server.name.includes('mcp-auto-install')) {
+ const binPath = await getBinaryPath()
+ makeSureDirExists(binPath)
+ server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
+ }
+ }
+ } else if (server.command === 'uvx' || server.command === 'uv') {
+ cmd = await getBinaryPath(server.command)
+ if (server.registryUrl) {
+ server.env = {
+ ...server.env,
+ UV_DEFAULT_INDEX: server.registryUrl,
+ PIP_INDEX_URL: server.registryUrl
+ }
+ }
+ }
- const newTransport = await initTransport()
- // Try to connect again
- await client.connect(newTransport)
+ Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
+ // Logger.info(`[MCP] Environment variables for server:`, server.env)
+ const loginShellEnv = await this.getLoginShellEnv()
- Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
- } catch (oauthError) {
- Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
- throw new Error(
- `OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
- )
- } finally {
- // Clear the timeout and close the callback server
- clearTimeout(timeoutId)
- callbackServer.close()
- }
- }
+ // Bun not support proxy https://github.com/oven-sh/bun/issues/16812
+ if (cmd.includes('bun')) {
+ this.removeProxyEnv(loginShellEnv)
+ }
- try {
- const transport = await initTransport()
- try {
- await client.connect(transport)
- } catch (error: Error | any) {
- if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
- Logger.info(`[MCP] Authentication required for server: ${server.name}`)
- await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
- } else {
- throw error
+ const stdioTransport = new StdioClientTransport({
+ command: cmd,
+ args,
+ env: {
+ ...loginShellEnv,
+ ...server.env
+ },
+ stderr: 'pipe'
+ })
+ stdioTransport.stderr?.on('data', (data) =>
+ Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
+ )
+ return stdioTransport
+ } else {
+ throw new Error('Either baseUrl or command must be provided')
+ }
}
+
+ const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
+ Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
+ // Create an event emitter for the OAuth callback
+ const events = new EventEmitter()
+
+ // Create a callback server
+ const callbackServer = new CallBackServer({
+ port: authProvider.config.callbackPort,
+ path: authProvider.config.callbackPath || '/oauth/callback',
+ events
+ })
+
+ // Set a timeout to close the callback server
+ const timeoutId = setTimeout(() => {
+ Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
+ callbackServer.close()
+ }, 300000) // 5 minutes timeout
+
+ try {
+ // Wait for the authorization code
+ const authCode = await callbackServer.waitForAuthCode()
+ Logger.info(`[MCP] Received auth code: ${authCode}`)
+
+ // Complete the OAuth flow
+ await transport.finishAuth(authCode)
+
+ Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
+
+ const newTransport = await initTransport()
+ // Try to connect again
+ await client.connect(newTransport)
+
+ Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
+ } catch (oauthError) {
+ Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
+ throw new Error(
+ `OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
+ )
+ } finally {
+ // Clear the timeout and close the callback server
+ clearTimeout(timeoutId)
+ callbackServer.close()
+ }
+ }
+
+ try {
+ const transport = await initTransport()
+ try {
+ await client.connect(transport)
+ } catch (error: Error | any) {
+ if (
+ error instanceof Error &&
+ (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
+ ) {
+ Logger.info(`[MCP] Authentication required for server: ${server.name}`)
+ await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
+ } else {
+ throw error
+ }
+ }
+
+ // Store the new client in the cache
+ this.clients.set(serverKey, client)
+
+ Logger.info(`[MCP] Activated server: ${server.name}`)
+ return client
+ } catch (error: any) {
+ Logger.error(`[MCP] Error activating server ${server.name}:`, error?.message)
+ throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
+ }
+ } finally {
+ // Clean up the pending promise when done
+ this.pendingClients.delete(serverKey)
}
+ })()
- // Store the new client in the cache
- this.clients.set(serverKey, client)
+ // Store the pending promise
+ this.pendingClients.set(serverKey, initPromise)
- Logger.info(`[MCP] Activated server: ${server.name}`)
- return client
- } catch (error: any) {
- Logger.error(`[MCP] Error activating server ${server.name}:`, error)
- throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
- }
+ return initPromise
}
async closeClient(serverKey: string) {
@@ -343,12 +388,32 @@ class McpService {
for (const [key] of this.clients) {
try {
await this.closeClient(key)
- } catch (error) {
- Logger.error(`[MCP] Failed to close client: ${error}`)
+ } catch (error: any) {
+ Logger.error(`[MCP] Failed to close client: ${error?.message}`)
}
}
}
+ /**
+ * Check connectivity for an MCP server
+ */
+ public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise {
+ Logger.info(`[MCP] Checking connectivity for server: ${server.name}`)
+ try {
+ const client = await this.initClient(server)
+ // Attempt to list tools as a way to check connectivity
+ await client.listTools()
+ Logger.info(`[MCP] Connectivity check successful for server: ${server.name}`)
+ return true
+ } catch (error) {
+ Logger.error(`[MCP] Connectivity check failed for server: ${server.name}`, error)
+ // Close the client if connectivity check fails to ensure a clean state for the next attempt
+ const serverKey = this.getServerKey(server)
+ await this.closeClient(serverKey)
+ return false
+ }
+ }
+
private async listToolsImpl(server: MCPServer): Promise {
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const client = await this.initClient(server)
@@ -358,15 +423,15 @@ class McpService {
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
- id: `f${nanoid()}`,
+ id: buildFunctionCallToolName(server.name, tool.name),
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
return serverTools
- } catch (error) {
- Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error)
+ } catch (error: any) {
+ Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error?.message)
return []
}
}
@@ -425,19 +490,21 @@ class McpService {
* List prompts available on an MCP server
*/
private async listPromptsImpl(server: MCPServer): Promise {
- Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
const client = await this.initClient(server)
+ Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
try {
const { prompts } = await client.listPrompts()
- const serverPrompts = prompts.map((prompt: any) => ({
+ return prompts.map((prompt: any) => ({
...prompt,
id: `p${nanoid()}`,
serverId: server.id,
serverName: server.name
}))
- return serverPrompts
- } catch (error) {
- Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error)
+ } catch (error: any) {
+ // -32601 is the code for the method not found
+ if (error?.code !== -32601) {
+ Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error?.message)
+ }
return []
}
}
@@ -495,19 +562,21 @@ class McpService {
* List resources available on an MCP server (implementation)
*/
private async listResourcesImpl(server: MCPServer): Promise {
- Logger.info(`[MCP] Listing resources for server: ${server.name}`)
const client = await this.initClient(server)
+ Logger.info(`[MCP] Listing resources for server: ${server.name}`)
try {
const result = await client.listResources()
const resources = result.resources || []
- const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({
+ return (Array.isArray(resources) ? resources : []).map((resource: any) => ({
...resource,
serverId: server.id,
serverName: server.name
}))
- return serverResources
- } catch (error) {
- Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error)
+ } catch (error: any) {
+ // -32601 is the code for the method not found
+ if (error?.code !== -32601) {
+ Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error?.message)
+ }
return []
}
}
@@ -550,7 +619,7 @@ class McpService {
contents: contents
}
} catch (error: Error | any) {
- Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error)
+ Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error.message)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}
}
@@ -574,164 +643,27 @@ class McpService {
return await cachedGetResource(server, uri)
}
- private findPowerShellExecutable() {
- const psPath = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' // Standard WinPS path
- const pwshPath = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
-
- if (fs.existsSync(psPath)) {
- return psPath
+ private getLoginShellEnv = memoize(async (): Promise> => {
+ try {
+ const loginEnv = await getLoginShellEnvironment()
+ const pathSeparator = process.platform === 'win32' ? ';' : ':'
+ const cherryBinPath = path.join(os.homedir(), '.cherrystudio', 'bin')
+ loginEnv.PATH = `${loginEnv.PATH}${pathSeparator}${cherryBinPath}`
+ Logger.info('[MCP] Successfully fetched login shell environment variables:')
+ return loginEnv
+ } catch (error) {
+ Logger.error('[MCP] Failed to fetch login shell environment variables:', error)
+ return {}
}
- if (fs.existsSync(pwshPath)) {
- return pwshPath
- }
- return 'powershell.exe'
- }
-
- private getSystemPath = memoize(async (): Promise => {
- return new Promise((resolve, reject) => {
- let command: string
- let shell: string
-
- if (process.platform === 'win32') {
- shell = this.findPowerShellExecutable()
- command = '$env:PATH'
- } else {
- // 尝试获取当前用户的默认 shell
-
- let userShell = process.env.SHELL
- if (!userShell) {
- if (fs.existsSync('/bin/zsh')) {
- userShell = '/bin/zsh'
- } else if (fs.existsSync('/bin/bash')) {
- userShell = '/bin/bash'
- } else if (fs.existsSync('/bin/fish')) {
- userShell = '/bin/fish'
- } else {
- userShell = '/bin/sh'
- }
- }
- shell = userShell
-
- // 根据不同的 shell 构建不同的命令
- if (userShell.includes('zsh')) {
- command =
- 'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
- } else if (userShell.includes('bash')) {
- command =
- 'source /etc/profile 2>/dev/null || true; source ~/.bash_profile 2>/dev/null || true; source ~/.bash_login 2>/dev/null || true; source ~/.profile 2>/dev/null || true; source ~/.bashrc 2>/dev/null || true; echo $PATH'
- } else if (userShell.includes('fish')) {
- command =
- 'source /etc/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.local.fish 2>/dev/null || true; echo $PATH'
- } else {
- // 默认使用 zsh
- shell = '/bin/zsh'
- command =
- 'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
- }
- }
-
- console.log(`Using shell: ${shell} with command: ${command}`)
- const child = require('child_process').spawn(shell, ['-c', command], {
- env: { ...process.env },
- cwd: app.getPath('home')
- })
-
- let path = ''
- child.stdout.on('data', (data: Buffer) => {
- path += data.toString()
- })
-
- child.stderr.on('data', (data: Buffer) => {
- console.error('Error getting PATH:', data.toString())
- })
-
- child.on('error', (error: Error) => {
- reject(new Error(`Failed to get system PATH, ${error.message}`))
- })
-
- child.on('close', (code: number) => {
- if (code === 0) {
- const trimmedPath = path.trim()
- resolve(trimmedPath)
- } else {
- reject(new Error(`Failed to get system PATH, exit code: ${code}`))
- }
- })
- })
})
- /**
- * Get enhanced PATH including common tool locations
- */
- private async getEnhancedPath(originalPath: string): Promise {
- let systemPath = ''
- try {
- systemPath = await this.getSystemPath()
- } catch (error) {
- Logger.error('[MCP] Failed to get system PATH:', error)
- }
- // 将原始 PATH 按分隔符分割成数组
- const pathSeparator = process.platform === 'win32' ? ';' : ':'
- const existingPaths = new Set(
- [...systemPath.split(pathSeparator), ...originalPath.split(pathSeparator)].filter(Boolean)
- )
- const homeDir = process.env.HOME || process.env.USERPROFILE || ''
-
- // 定义要添加的新路径
- const newPaths: string[] = []
-
- if (isMac) {
- newPaths.push(
- '/bin',
- '/usr/bin',
- '/usr/local/bin',
- '/usr/local/sbin',
- '/opt/homebrew/bin',
- '/opt/homebrew/sbin',
- '/usr/local/opt/node/bin',
- `${homeDir}/.nvm/current/bin`,
- `${homeDir}/.npm-global/bin`,
- `${homeDir}/.yarn/bin`,
- `${homeDir}/.cargo/bin`,
- `${homeDir}/.cherrystudio/bin`,
- '/opt/local/bin'
- )
- }
-
- if (isLinux) {
- newPaths.push(
- '/bin',
- '/usr/bin',
- '/usr/local/bin',
- `${homeDir}/.nvm/current/bin`,
- `${homeDir}/.npm-global/bin`,
- `${homeDir}/.yarn/bin`,
- `${homeDir}/.cargo/bin`,
- `${homeDir}/.cherrystudio/bin`,
- '/snap/bin'
- )
- }
-
- if (isWin) {
- newPaths.push(
- `${process.env.APPDATA}\\npm`,
- `${homeDir}\\AppData\\Local\\Yarn\\bin`,
- `${homeDir}\\.cargo\\bin`,
- `${homeDir}\\.cherrystudio\\bin`
- )
- }
-
- // 只添加不存在的路径
- newPaths.forEach((path) => {
- if (path && !existingPaths.has(path)) {
- existingPaths.add(path)
- }
- })
-
- // 转换回字符串
- return Array.from(existingPaths).join(pathSeparator)
+ private removeProxyEnv(env: Record) {
+ delete env.HTTPS_PROXY
+ delete env.HTTP_PROXY
+ delete env.grpc_proxy
+ delete env.http_proxy
+ delete env.https_proxy
}
}
-const mcpService = new McpService()
-export default mcpService
+export default new McpService()
diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts
new file mode 100644
index 0000000000..e06036b523
--- /dev/null
+++ b/src/main/services/NotificationService.ts
@@ -0,0 +1,31 @@
+import { BrowserWindow, Notification as ElectronNotification } from 'electron'
+import { Notification } from 'src/renderer/src/types/notification'
+
+import icon from '../../../build/icon.png?asset'
+
+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({
+ title: notification.title,
+ body: notification.message,
+ icon: icon
+ })
+
+ electronNotification.on('click', () => {
+ this.window.show()
+ this.window.webContents.send('notification-click', notification)
+ })
+
+ electronNotification.show()
+ }
+}
+
+export default NotificationService
diff --git a/src/main/services/NutstoreService.ts b/src/main/services/NutstoreService.ts
index e106fa0cc0..5f256f52c3 100644
--- a/src/main/services/NutstoreService.ts
+++ b/src/main/services/NutstoreService.ts
@@ -32,10 +32,9 @@ interface WebDAVResponse {
}
export async function getNutstoreSSOUrl() {
- const url = await createOAuthUrl({
+ return await createOAuthUrl({
app: 'cherrystudio'
})
- return url
}
export async function decryptToken(token: string) {
diff --git a/src/main/services/ObsidianVaultService.ts b/src/main/services/ObsidianVaultService.ts
index 544c9c07b5..0f9b33c475 100644
--- a/src/main/services/ObsidianVaultService.ts
+++ b/src/main/services/ObsidianVaultService.ts
@@ -1,4 +1,5 @@
import { app } from 'electron'
+import Logger from 'electron-log'
import fs from 'fs'
import path from 'path'
@@ -155,7 +156,7 @@ class ObsidianVaultService {
return []
}
- console.log('获取Vault文件结构:', vault.name, vault.path)
+ Logger.log('获取Vault文件结构:', vault.name, vault.path)
return this.getVaultStructure(vault.path)
} catch (error) {
console.error('获取Vault文件结构时发生错误:', error)
diff --git a/src/main/services/ProtocolClient.ts b/src/main/services/ProtocolClient.ts
index 1d98f31c92..7e0b274816 100644
--- a/src/main/services/ProtocolClient.ts
+++ b/src/main/services/ProtocolClient.ts
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
import { app } from 'electron'
import Logger from 'electron-log'
+import { handleProvidersProtocolUrl } from './urlschema/handle-providers'
import { handleMcpProtocolUrl } from './urlschema/mcp-install'
import { windowService } from './WindowService'
@@ -25,7 +26,6 @@ export function handleProtocolUrl(url: string) {
if (!url) return
// Process the URL that was used to open the app
// The url will be in the format: cherrystudio://data?param1=value1¶m2=value2
- console.log('Received URL:', url)
// Parse the URL and extract parameters
const urlObj = new URL(url)
@@ -35,6 +35,9 @@ export function handleProtocolUrl(url: string) {
case 'mcp':
handleMcpProtocolUrl(urlObj)
return
+ case 'providers':
+ handleProvidersProtocolUrl(urlObj)
+ return
}
// You can send the data to your renderer process
diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts
index 79191b461b..3a5ff5b438 100644
--- a/src/main/services/ProxyManager.ts
+++ b/src/main/services/ProxyManager.ts
@@ -1,12 +1,12 @@
// import { ProxyConfig as _ProxyConfig, session } from 'electron'
import { session } from 'electron'
-declare type _ProxyConfig = any;
+declare type _ProxyConfig = any
-import { socksDispatcher } from 'fetch-socks'
+// import { socksDispatcher } from 'fetch-socks'
import { getSystemProxy } from 'os-proxy-config'
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
-import { ProxyAgent, setGlobalDispatcher } from 'undici'
+// import { ProxyAgent, setGlobalDispatcher } from 'undici'
type ProxyMode = 'system' | 'custom' | 'none'
@@ -125,23 +125,22 @@ export class ProxyManager {
return this.config.url || ''
}
- setGlobalProxy() {
- const proxyUrl = this.config.url
- if (proxyUrl) {
- const [protocol, address] = proxyUrl.split('://')
- const [host, port] = address.split(':')
- if (!protocol.includes('socks')) {
- setGlobalDispatcher(new ProxyAgent(proxyUrl))
- } else {
- const dispatcher = socksDispatcher({
- port: parseInt(port),
- type: protocol === 'socks5' ? 5 : 4,
- host: host
- })
- global[Symbol.for('undici.globalDispatcher.1')] = dispatcher
- }
- }
- }
+ // setGlobalProxy() {
+ // const proxyUrl = this.config.url
+ // if (proxyUrl) {
+ // const [protocol, address] = proxyUrl.split('://')
+ // const [host, port] = address.split(':')
+ // if (!protocol.includes('socks')) {
+ // setGlobalDispatcher(new ProxyAgent(proxyUrl))
+ // } else {
+ // global[Symbol.for('undici.globalDispatcher.1')] = socksDispatcher({
+ // port: parseInt(port),
+ // type: protocol === 'socks5' ? 5 : 4,
+ // host: host
+ // })
+ // }
+ // }
+ // }
}
export const proxyManager = new ProxyManager()
diff --git a/src/main/services/ReduxService.ts b/src/main/services/ReduxService.ts
index ec9b1c0873..3cddd0e947 100644
--- a/src/main/services/ReduxService.ts
+++ b/src/main/services/ReduxService.ts
@@ -191,7 +191,7 @@ export const reduxService = new ReduxService()
try {
// 读取状态
const settings = await reduxService.select('state.settings')
- console.log('settings', settings)
+ Logger.log('settings', settings)
// 派发 action
await reduxService.dispatch({
@@ -201,7 +201,7 @@ export const reduxService = new ReduxService()
// 订阅状态变化
const unsubscribe = await reduxService.subscribe('state.settings.apiKey', (newValue) => {
- console.log('API key changed:', newValue)
+ Logger.log('API key changed:', newValue)
})
// 批量执行 actions
@@ -212,16 +212,16 @@ export const reduxService = new ReduxService()
// 同步方法虽然可能不是最新的数据,但响应更快
const apiKey = reduxService.selectSync('state.settings.apiKey')
- console.log('apiKey', apiKey)
+ Logger.log('apiKey', apiKey)
// 处理保证是最新的数据
const apiKey1 = await reduxService.select('state.settings.apiKey')
- console.log('apiKey1', apiKey1)
+ Logger.log('apiKey1', apiKey1)
// 取消订阅
unsubscribe()
} catch (error) {
- console.error('Error:', error)
+ Logger.error('Error:', error)
}
}
*/
diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts
new file mode 100644
index 0000000000..b62489bbbe
--- /dev/null
+++ b/src/main/services/RemoteStorage.ts
@@ -0,0 +1,57 @@
+// import Logger from 'electron-log'
+// import { Operator } from 'opendal'
+
+// export default class RemoteStorage {
+// public instance: Operator | undefined
+
+// /**
+// *
+// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
+// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
+// *
+// * For example, use minio as remote storage:
+// *
+// * ```typescript
+// * const storage = new RemoteStorage('s3', {
+// * endpoint: 'http://localhost:9000',
+// * region: 'us-east-1',
+// * bucket: 'testbucket',
+// * access_key_id: 'user',
+// * secret_access_key: 'password',
+// * root: '/path/to/basepath',
+// * })
+// * ```
+// */
+// constructor(scheme: string, options?: Record | undefined | null) {
+// this.instance = new Operator(scheme, options)
+
+// this.putFileContents = this.putFileContents.bind(this)
+// this.getFileContents = this.getFileContents.bind(this)
+// }
+
+// public putFileContents = async (filename: string, data: string | Buffer) => {
+// if (!this.instance) {
+// return new Error('RemoteStorage client not initialized')
+// }
+
+// try {
+// return await this.instance.write(filename, data)
+// } catch (error) {
+// Logger.error('[RemoteStorage] Error putting file contents:', error)
+// throw error
+// }
+// }
+
+// public getFileContents = async (filename: string) => {
+// if (!this.instance) {
+// throw new Error('RemoteStorage client not initialized')
+// }
+
+// try {
+// return await this.instance.read(filename)
+// } catch (error) {
+// Logger.error('[RemoteStorage] Error getting file contents:', error)
+// throw error
+// }
+// }
+// }
diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts
index 327bf6e7ff..95e9d8b1be 100644
--- a/src/main/services/SearchService.ts
+++ b/src/main/services/SearchService.ts
@@ -74,8 +74,7 @@ export class SearchService {
})
// Get the page content after ensuring it's fully loaded
- const content = await window.webContents.executeJavaScript('document.documentElement.outerHTML')
- return content
+ return await window.webContents.executeJavaScript('document.documentElement.outerHTML')
}
}
diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts
new file mode 100644
index 0000000000..0ffef4e851
--- /dev/null
+++ b/src/main/services/SelectionService.ts
@@ -0,0 +1,1234 @@
+import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
+import { isDev, isWin } from '@main/constant'
+import { IpcChannel } from '@shared/IpcChannel'
+import { BrowserWindow, ipcMain, screen } from 'electron'
+import Logger from 'electron-log'
+import { join } from 'path'
+import type {
+ KeyboardEventData,
+ MouseEventData,
+ SelectionHookConstructor,
+ SelectionHookInstance,
+ TextSelectionData
+} from 'selection-hook'
+
+import type { ActionItem } from '../../renderer/src/types/selectionTypes'
+import { ConfigKeys, configManager } from './ConfigManager'
+
+let SelectionHook: SelectionHookConstructor | null = null
+try {
+ if (isWin) {
+ SelectionHook = require('selection-hook')
+ }
+} catch (error) {
+ Logger.error('Failed to load selection-hook:', error)
+}
+
+// Type definitions
+type Point = { x: number; y: number }
+type RelativeOrientation =
+ | 'topLeft'
+ | 'topRight'
+ | 'topMiddle'
+ | 'bottomLeft'
+ | 'bottomRight'
+ | 'bottomMiddle'
+ | 'middleLeft'
+ | 'middleRight'
+ | 'center'
+
+enum TriggerMode {
+ Selected = 'selected',
+ Ctrlkey = 'ctrlkey'
+}
+
+/** SelectionService is a singleton class that manages the selection hook and the toolbar window
+ *
+ * Features:
+ * - Text selection detection and processing
+ * - Floating toolbar management
+ * - Action window handling
+ * - Multiple trigger modes (selection/alt-key)
+ * - Screen boundary-aware positioning
+ *
+ * Usage:
+ * import selectionService from '/src/main/services/SelectionService'
+ * selectionService?.start()
+ */
+export class SelectionService {
+ private static instance: SelectionService | null = null
+ private selectionHook: SelectionHookInstance | null = null
+
+ private static isIpcHandlerRegistered = false
+
+ private initStatus: boolean = false
+ private started: boolean = false
+
+ private triggerMode = TriggerMode.Selected
+ private isFollowToolbar = true
+ private isRemeberWinSize = false
+ private filterMode = 'default'
+ private filterList: string[] = []
+
+ private toolbarWindow: BrowserWindow | null = null
+ private actionWindows = new Set()
+ private preloadedActionWindows: BrowserWindow[] = []
+ private readonly PRELOAD_ACTION_WINDOW_COUNT = 1
+
+ private isHideByMouseKeyListenerActive: boolean = false
+ private isCtrlkeyListenerActive: boolean = false
+ /**
+ * Ctrlkey action states:
+ * 0 - Ready to monitor ctrlkey action
+ * >0 - Currently monitoring ctrlkey action
+ * -1 - Ctrlkey action triggered, no need to process again
+ */
+ private lastCtrlkeyDownTime: number = 0
+
+ private zoomFactor: number = 1
+
+ private TOOLBAR_WIDTH = 350
+ private TOOLBAR_HEIGHT = 43
+
+ private readonly ACTION_WINDOW_WIDTH = 500
+ private readonly ACTION_WINDOW_HEIGHT = 400
+
+ private lastActionWindowSize: { width: number; height: number } = {
+ width: this.ACTION_WINDOW_WIDTH,
+ height: this.ACTION_WINDOW_HEIGHT
+ }
+
+ private constructor() {
+ try {
+ if (!SelectionHook) {
+ throw new Error('module selection-hook not exists')
+ }
+
+ this.selectionHook = new SelectionHook()
+ if (this.selectionHook) {
+ this.initZoomFactor()
+
+ this.initStatus = true
+ }
+ } catch (error) {
+ this.logError('Failed to initialize SelectionService:', error as Error)
+ }
+ }
+
+ public static getInstance(): SelectionService | null {
+ if (!isWin) return null
+
+ if (!SelectionService.instance) {
+ SelectionService.instance = new SelectionService()
+ }
+
+ if (SelectionService.instance.initStatus) {
+ return SelectionService.instance
+ }
+ return null
+ }
+
+ public getSelectionHook(): SelectionHookInstance | null {
+ return this.selectionHook
+ }
+
+ /**
+ * Initialize zoom factor from config and subscribe to changes
+ * Ensures UI elements scale properly with system DPI settings
+ */
+ private initZoomFactor() {
+ const zoomFactor = configManager.getZoomFactor()
+ if (zoomFactor) {
+ this.setZoomFactor(zoomFactor)
+ }
+
+ configManager.subscribe('ZoomFactor', this.setZoomFactor)
+ }
+
+ public setZoomFactor = (zoomFactor: number) => {
+ this.zoomFactor = zoomFactor
+ }
+
+ private initConfig() {
+ this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
+ this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
+ this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
+ this.filterMode = configManager.getSelectionAssistantFilterMode()
+ this.filterList = configManager.getSelectionAssistantFilterList()
+
+ this.setHookGlobalFilterMode(this.filterMode, this.filterList)
+ this.setHookFineTunedList()
+
+ configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: TriggerMode) => {
+ const oldTriggerMode = this.triggerMode
+
+ this.triggerMode = triggerMode
+ this.processTriggerMode()
+
+ //trigger mode changed, need to update the filter list
+ if (oldTriggerMode !== triggerMode) {
+ this.setHookGlobalFilterMode(this.filterMode, this.filterList)
+ }
+ })
+
+ configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => {
+ this.isFollowToolbar = isFollowToolbar
+ })
+
+ configManager.subscribe(ConfigKeys.SelectionAssistantRemeberWinSize, (isRemeberWinSize: boolean) => {
+ this.isRemeberWinSize = isRemeberWinSize
+ //when off, reset the last action window size to default
+ if (!this.isRemeberWinSize) {
+ this.lastActionWindowSize = {
+ width: this.ACTION_WINDOW_WIDTH,
+ height: this.ACTION_WINDOW_HEIGHT
+ }
+ }
+ })
+
+ configManager.subscribe(ConfigKeys.SelectionAssistantFilterMode, (filterMode: string) => {
+ this.filterMode = filterMode
+ this.setHookGlobalFilterMode(this.filterMode, this.filterList)
+ })
+
+ configManager.subscribe(ConfigKeys.SelectionAssistantFilterList, (filterList: string[]) => {
+ this.filterList = filterList
+ this.setHookGlobalFilterMode(this.filterMode, this.filterList)
+ })
+ }
+
+ /**
+ * Set the global filter mode for the selection-hook
+ * @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist'
+ * @param list - An array of strings representing the list of items to include or exclude
+ */
+ private setHookGlobalFilterMode(mode: string, list: string[]) {
+ if (!this.selectionHook) return
+
+ const modeMap = {
+ default: SelectionHook!.FilterMode.DEFAULT,
+ whitelist: SelectionHook!.FilterMode.INCLUDE_LIST,
+ blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
+ }
+
+ let combinedList: string[] = list
+ let combinedMode = mode
+
+ //only the selected mode need to combine the predefined blacklist with the user-defined blacklist
+ if (this.triggerMode === TriggerMode.Selected) {
+ switch (mode) {
+ case 'blacklist':
+ //combine the predefined blacklist with the user-defined blacklist
+ combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
+ break
+ case 'whitelist':
+ combinedList = [...list]
+ break
+ case 'default':
+ default:
+ //use the predefined blacklist as the default filter list
+ combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
+ combinedMode = 'blacklist'
+ break
+ }
+ }
+
+ if (!this.selectionHook.setGlobalFilterMode(modeMap[combinedMode], combinedList)) {
+ this.logError(new Error('Failed to set selection-hook global filter mode'))
+ }
+ }
+
+ private setHookFineTunedList() {
+ if (!this.selectionHook) return
+
+ this.selectionHook.setFineTunedList(
+ SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
+ SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
+ )
+
+ this.selectionHook.setFineTunedList(
+ SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
+ SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
+ )
+ }
+
+ /**
+ * Start the selection service and initialize required windows
+ * @returns {boolean} Success status of service start
+ */
+ public start(): boolean {
+ if (!this.selectionHook || this.started) {
+ this.logError(new Error('SelectionService start(): instance is null or already started'))
+ return false
+ }
+
+ try {
+ //make sure the toolbar window is ready
+ this.createToolbarWindow()
+ // Initialize preloaded windows
+ this.initPreloadedActionWindows()
+ // Handle errors
+ this.selectionHook.on('error', (error: { message: string }) => {
+ this.logError('Error in SelectionHook:', error as Error)
+ })
+ // Handle text selection events
+ this.selectionHook.on('text-selection', this.processTextSelection)
+
+ // Start the hook
+ if (this.selectionHook.start({ debug: isDev })) {
+ //init basic configs
+ this.initConfig()
+
+ //init trigger mode configs
+ this.processTriggerMode()
+
+ this.started = true
+ this.logInfo('SelectionService Started')
+ return true
+ }
+
+ this.logError(new Error('Failed to start text selection hook.'))
+ return false
+ } catch (error) {
+ this.logError('Failed to set up text selection hook:', error as Error)
+ return false
+ }
+ }
+
+ /**
+ * Stop the selection service and cleanup resources
+ * Called when user disables selection assistant
+ * @returns {boolean} Success status of service stop
+ */
+ public stop(): boolean {
+ if (!this.selectionHook) return false
+
+ this.selectionHook.stop()
+ this.selectionHook.cleanup() //already remove all listeners
+
+ //reset the listener states
+ this.isCtrlkeyListenerActive = false
+ this.isHideByMouseKeyListenerActive = false
+
+ if (this.toolbarWindow) {
+ this.toolbarWindow.close()
+ this.toolbarWindow = null
+ }
+ this.started = false
+ this.logInfo('SelectionService Stopped')
+ return true
+ }
+
+ /**
+ * Completely quit the selection service
+ * Called when the app is closing
+ */
+ public quit(): void {
+ if (!this.selectionHook) return
+
+ this.stop()
+
+ this.selectionHook = null
+ this.initStatus = false
+ SelectionService.instance = null
+ this.logInfo('SelectionService Quitted')
+ }
+
+ /**
+ * Create and configure the toolbar window
+ * Sets up window properties, event handlers, and loads the toolbar UI
+ * @param readyCallback Optional callback when window is ready to show
+ */
+ private createToolbarWindow(readyCallback?: () => void) {
+ if (this.isToolbarAlive()) return
+
+ const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
+
+ this.toolbarWindow = new BrowserWindow({
+ width: toolbarWidth,
+ height: toolbarHeight,
+ frame: false,
+ transparent: true,
+ alwaysOnTop: true,
+ skipTaskbar: true,
+ resizable: false,
+ minimizable: false,
+ maximizable: false,
+ movable: true,
+ focusable: false,
+ hasShadow: false,
+ thickFrame: false,
+ roundedCorners: true,
+ // backgroundMaterial: 'none',
+ type: 'toolbar',
+ show: false,
+ webPreferences: {
+ preload: join(__dirname, '../preload/index.js'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: false,
+ devTools: isDev ? true : false
+ }
+ })
+
+ // Hide when losing focus
+ this.toolbarWindow.on('blur', () => {
+ this.hideToolbar()
+ })
+
+ // Clean up when closed
+ this.toolbarWindow.on('closed', () => {
+ this.toolbarWindow = null
+ })
+
+ // Add show/hide event listeners
+ this.toolbarWindow.on('show', () => {
+ this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true)
+ })
+
+ this.toolbarWindow.on('hide', () => {
+ this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, false)
+ })
+
+ /** uncomment to open dev tools in dev mode */
+ // if (isDev) {
+ // this.toolbarWindow.once('ready-to-show', () => {
+ // this.toolbarWindow!.webContents.openDevTools({ mode: 'detach' })
+ // })
+ // }
+
+ if (readyCallback) {
+ this.toolbarWindow.once('ready-to-show', readyCallback)
+ }
+
+ /** get ready to load the toolbar window */
+
+ if (isDev && process.env['ELECTRON_RENDERER_URL']) {
+ this.toolbarWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionToolbar.html')
+ } else {
+ this.toolbarWindow.loadFile(join(__dirname, '../renderer/selectionToolbar.html'))
+ }
+ }
+
+ /**
+ * Show toolbar at specified position with given orientation
+ * @param point Reference point for positioning, logical coordinates
+ * @param orientation Preferred position relative to reference point
+ */
+ private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) {
+ if (!this.isToolbarAlive()) {
+ this.createToolbarWindow(() => {
+ this.showToolbarAtPosition(point, orientation)
+ })
+ return
+ }
+
+ const { x: posX, y: posY } = this.calculateToolbarPosition(point, orientation)
+
+ const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
+ this.toolbarWindow!.setPosition(posX, posY, false)
+ // Prevent window resize
+ this.toolbarWindow!.setBounds({
+ width: toolbarWidth,
+ height: toolbarHeight,
+ x: posX,
+ y: posY
+ })
+ this.toolbarWindow!.show()
+ this.toolbarWindow!.setOpacity(1)
+ this.startHideByMouseKeyListener()
+ }
+
+ /**
+ * Hide the toolbar window and cleanup listeners
+ */
+ public hideToolbar(): void {
+ if (!this.isToolbarAlive()) return
+
+ this.toolbarWindow!.setOpacity(0)
+ this.toolbarWindow!.hide()
+
+ this.stopHideByMouseKeyListener()
+ }
+
+ /**
+ * Check if toolbar window exists and is not destroyed
+ * @returns {boolean} Toolbar window status
+ */
+ private isToolbarAlive() {
+ return this.toolbarWindow && !this.toolbarWindow.isDestroyed()
+ }
+
+ /**
+ * Update toolbar size based on renderer feedback
+ * Only updates width if it has changed
+ * @param width New toolbar width
+ * @param height New toolbar height
+ */
+ public determineToolbarSize(width: number, height: number) {
+ const toolbarWidth = Math.ceil(width)
+
+ // only update toolbar width if it's changed
+ if (toolbarWidth > 0 && toolbarWidth !== this.TOOLBAR_WIDTH && height > 0) {
+ this.TOOLBAR_WIDTH = toolbarWidth
+ }
+ }
+
+ /**
+ * Get actual toolbar dimensions accounting for zoom factor
+ * @returns Object containing toolbar width and height
+ */
+ private getToolbarRealSize() {
+ return {
+ toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor,
+ toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor
+ }
+ }
+
+ /**
+ * Calculate optimal toolbar position based on selection context
+ * Ensures toolbar stays within screen boundaries and follows selection direction
+ * @param point Reference point for positioning, must be INTEGER
+ * @param orientation Preferred position relative to reference point
+ * @returns Calculated screen coordinates for toolbar, INTEGER
+ */
+ private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point {
+ // Calculate initial position based on the specified anchor
+ let posX: number, posY: number
+
+ const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
+
+ switch (orientation) {
+ case 'topLeft':
+ posX = point.x - toolbarWidth
+ posY = point.y - toolbarHeight
+ break
+ case 'topRight':
+ posX = point.x
+ posY = point.y - toolbarHeight
+ break
+ case 'topMiddle':
+ posX = point.x - toolbarWidth / 2
+ posY = point.y - toolbarHeight
+ break
+ case 'bottomLeft':
+ posX = point.x - toolbarWidth
+ posY = point.y
+ break
+ case 'bottomRight':
+ posX = point.x
+ posY = point.y
+ break
+ case 'bottomMiddle':
+ posX = point.x - toolbarWidth / 2
+ posY = point.y
+ break
+ case 'middleLeft':
+ posX = point.x - toolbarWidth
+ posY = point.y - toolbarHeight / 2
+ break
+ case 'middleRight':
+ posX = point.x
+ posY = point.y - toolbarHeight / 2
+ break
+ case 'center':
+ posX = point.x - toolbarWidth / 2
+ posY = point.y - toolbarHeight / 2
+ break
+ default:
+ // Default to 'topMiddle' if invalid position
+ posX = point.x - toolbarWidth / 2
+ posY = point.y - toolbarHeight / 2
+ }
+
+ //use original point to get the display
+ const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y })
+
+ // Ensure toolbar stays within screen boundaries
+ posX = Math.round(
+ Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth))
+ )
+ posY = Math.round(
+ Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight))
+ )
+
+ return { x: posX, y: posY }
+ }
+
+ private isSamePoint(point1: Point, point2: Point): boolean {
+ return point1.x === point2.x && point1.y === point2.y
+ }
+
+ private isSameLineWithRectPoint(startTop: Point, startBottom: Point, endTop: Point, endBottom: Point): boolean {
+ return startTop.y === endTop.y && startBottom.y === endBottom.y
+ }
+
+ /**
+ * Determine if the text selection should be processed by filter mode&list
+ * @param selectionData Text selection information and coordinates
+ * @returns {boolean} True if the selection should be processed, false otherwise
+ */
+ private shouldProcessTextSelection(selectionData: TextSelectionData): boolean {
+ if (selectionData.programName === '' || this.filterMode === 'default') {
+ return true
+ }
+
+ const programName = selectionData.programName.toLowerCase()
+ //items in filterList are already in lower case
+ const isFound = this.filterList.some((item) => programName.includes(item))
+
+ switch (this.filterMode) {
+ case 'whitelist':
+ return isFound
+ case 'blacklist':
+ return !isFound
+ }
+
+ return false
+ }
+
+ /**
+ * Process text selection data and show toolbar
+ * Handles different selection scenarios:
+ * - Single click (cursor position)
+ * - Mouse selection (single/double line)
+ * - Keyboard selection (full/detailed)
+ * @param selectionData Text selection information and coordinates
+ */
+ private processTextSelection = (selectionData: TextSelectionData) => {
+ // Skip if no text or toolbar already visible
+ if (!selectionData.text || (this.isToolbarAlive() && this.toolbarWindow!.isVisible())) {
+ return
+ }
+
+ if (!this.shouldProcessTextSelection(selectionData)) {
+ return
+ }
+
+ // Determine reference point and position for toolbar
+ let refPoint: { x: number; y: number } = { x: 0, y: 0 }
+ let isLogical = false
+ let refOrientation: RelativeOrientation = 'bottomRight'
+
+ switch (selectionData.posLevel) {
+ case SelectionHook?.PositionLevel.NONE:
+ {
+ const cursorPoint = screen.getCursorScreenPoint()
+ refPoint = { x: cursorPoint.x, y: cursorPoint.y }
+ refOrientation = 'bottomMiddle'
+ isLogical = true
+ }
+ break
+ case SelectionHook?.PositionLevel.MOUSE_SINGLE:
+ {
+ refOrientation = 'bottomMiddle'
+ refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.mousePosEnd.y + 16 }
+ }
+ break
+ case SelectionHook?.PositionLevel.MOUSE_DUAL:
+ {
+ const yDistance = selectionData.mousePosEnd.y - selectionData.mousePosStart.y
+ const xDistance = selectionData.mousePosEnd.x - selectionData.mousePosStart.x
+
+ // not in the same line
+ if (Math.abs(yDistance) > 14) {
+ if (yDistance > 0) {
+ refOrientation = 'bottomLeft'
+ refPoint = {
+ x: selectionData.mousePosEnd.x,
+ y: selectionData.mousePosEnd.y + 16
+ }
+ } else {
+ refOrientation = 'topRight'
+ refPoint = {
+ x: selectionData.mousePosEnd.x,
+ y: selectionData.mousePosEnd.y - 16
+ }
+ }
+ } else {
+ // in the same line
+ if (xDistance > 0) {
+ refOrientation = 'bottomLeft'
+ refPoint = {
+ x: selectionData.mousePosEnd.x,
+ y: Math.max(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16
+ }
+ } else {
+ refOrientation = 'bottomRight'
+ refPoint = {
+ x: selectionData.mousePosEnd.x,
+ y: Math.min(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16
+ }
+ }
+ }
+ }
+ break
+ case SelectionHook?.PositionLevel.SEL_FULL:
+ case SelectionHook?.PositionLevel.SEL_DETAILED:
+ {
+ //some case may not have mouse position, so use the endBottom point as reference
+ const isNoMouse =
+ selectionData.mousePosStart.x === 0 &&
+ selectionData.mousePosStart.y === 0 &&
+ selectionData.mousePosEnd.x === 0 &&
+ selectionData.mousePosEnd.y === 0
+
+ if (isNoMouse) {
+ refOrientation = 'bottomLeft'
+ refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 }
+ break
+ }
+
+ const isDoubleClick = this.isSamePoint(selectionData.mousePosStart, selectionData.mousePosEnd)
+
+ const isSameLine = this.isSameLineWithRectPoint(
+ selectionData.startTop,
+ selectionData.startBottom,
+ selectionData.endTop,
+ selectionData.endBottom
+ )
+
+ // Note: shift key + mouse click == DoubleClick
+
+ //double click to select a word
+ if (isDoubleClick && isSameLine) {
+ refOrientation = 'bottomMiddle'
+ refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.endBottom.y + 4 }
+ break
+ }
+
+ // below: isDoubleClick || isSameLine
+ if (isSameLine) {
+ const direction = selectionData.mousePosEnd.x - selectionData.mousePosStart.x
+
+ if (direction > 0) {
+ refOrientation = 'bottomLeft'
+ refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 }
+ } else {
+ refOrientation = 'bottomRight'
+ refPoint = { x: selectionData.startBottom.x, y: selectionData.startBottom.y + 4 }
+ }
+ break
+ }
+
+ // below: !isDoubleClick && !isSameLine
+ const direction = selectionData.mousePosEnd.y - selectionData.mousePosStart.y
+
+ if (direction > 0) {
+ refOrientation = 'bottomLeft'
+ refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 }
+ } else {
+ refOrientation = 'topRight'
+ refPoint = { x: selectionData.startTop.x, y: selectionData.startTop.y - 4 }
+ }
+ }
+ break
+ }
+
+ if (!isLogical) {
+ //screenToDipPoint can be float, so we need to round it
+ refPoint = screen.screenToDipPoint(refPoint)
+ refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
+ }
+
+ this.showToolbarAtPosition(refPoint, refOrientation)
+ this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
+ }
+
+ /**
+ * Global Mouse Event Handling
+ */
+
+ // Start monitoring global mouse clicks
+ private startHideByMouseKeyListener() {
+ try {
+ // Register event handlers
+ this.selectionHook!.on('mouse-down', this.handleMouseDownHide)
+ this.selectionHook!.on('mouse-wheel', this.handleMouseWheelHide)
+ this.selectionHook!.on('key-down', this.handleKeyDownHide)
+ this.isHideByMouseKeyListenerActive = true
+ } catch (error) {
+ this.logError('Failed to start global mouse event listener:', error as Error)
+ }
+ }
+
+ // Stop monitoring global mouse clicks
+ private stopHideByMouseKeyListener() {
+ if (!this.isHideByMouseKeyListenerActive) return
+
+ try {
+ this.selectionHook!.off('mouse-down', this.handleMouseDownHide)
+ this.selectionHook!.off('mouse-wheel', this.handleMouseWheelHide)
+ this.selectionHook!.off('key-down', this.handleKeyDownHide)
+ this.isHideByMouseKeyListenerActive = false
+ } catch (error) {
+ this.logError('Failed to stop global mouse event listener:', error as Error)
+ }
+ }
+
+ /**
+ * Handle mouse wheel events to hide toolbar
+ * Hides toolbar when user scrolls
+ * @param data Mouse wheel event data
+ */
+ private handleMouseWheelHide = () => {
+ this.hideToolbar()
+ }
+
+ /**
+ * Handle mouse down events to hide toolbar
+ * Hides toolbar when clicking outside of it
+ * @param data Mouse event data
+ */
+ private handleMouseDownHide = (data: MouseEventData) => {
+ if (!this.isToolbarAlive()) {
+ return
+ }
+
+ //data point is physical coordinates, convert to logical coordinates
+ const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y })
+
+ const bounds = this.toolbarWindow!.getBounds()
+
+ // Check if click is outside toolbar
+ const isInsideToolbar =
+ mousePoint.x >= bounds.x &&
+ mousePoint.x <= bounds.x + bounds.width &&
+ mousePoint.y >= bounds.y &&
+ mousePoint.y <= bounds.y + bounds.height
+
+ if (!isInsideToolbar) {
+ this.hideToolbar()
+ }
+ }
+
+ /**
+ * Handle key down events to hide toolbar
+ * Hides toolbar on any key press except alt key in ctrlkey mode
+ * @param data Keyboard event data
+ */
+ private handleKeyDownHide = (data: KeyboardEventData) => {
+ //dont hide toolbar when ctrlkey is pressed
+ if (this.triggerMode === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) {
+ return
+ }
+ //dont hide toolbar when shiftkey is pressed, because it's used for selection
+ if (this.isShiftkey(data.vkCode)) {
+ return
+ }
+
+ this.hideToolbar()
+ }
+
+ /**
+ * Handle key down events in ctrlkey trigger mode
+ * Processes alt key presses to trigger selection toolbar
+ * @param data Keyboard event data
+ */
+ private handleKeyDownCtrlkeyMode = (data: KeyboardEventData) => {
+ if (!this.isCtrlkey(data.vkCode)) {
+ // reset the lastCtrlkeyDownTime if any other key is pressed
+ if (this.lastCtrlkeyDownTime > 0) {
+ this.lastCtrlkeyDownTime = -1
+ }
+ return
+ }
+
+ if (this.lastCtrlkeyDownTime === -1) {
+ return
+ }
+
+ //ctrlkey pressed
+ if (this.lastCtrlkeyDownTime === 0) {
+ this.lastCtrlkeyDownTime = Date.now()
+ //add the mouse-wheel listener, detect if user is zooming in/out
+ this.selectionHook!.on('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
+ return
+ }
+
+ if (Date.now() - this.lastCtrlkeyDownTime < 350) {
+ return
+ }
+
+ this.lastCtrlkeyDownTime = -1
+
+ const selectionData = this.selectionHook!.getCurrentSelection()
+
+ if (selectionData) {
+ this.processTextSelection(selectionData)
+ }
+ }
+
+ /**
+ * Handle key up events in ctrlkey trigger mode
+ * Resets alt key state when key is released
+ * @param data Keyboard event data
+ */
+ private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => {
+ if (!this.isCtrlkey(data.vkCode)) return
+ //remove the mouse-wheel listener
+ this.selectionHook!.off('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
+ this.lastCtrlkeyDownTime = 0
+ }
+
+ /**
+ * Handle mouse wheel events in ctrlkey trigger mode
+ * ignore CtrlKey pressing when mouse wheel is used
+ * because user is zooming in/out
+ */
+ private handleMouseWheelCtrlkeyMode = () => {
+ this.lastCtrlkeyDownTime = -1
+ }
+
+ //check if the key is ctrl key
+ private isCtrlkey(vkCode: number) {
+ return vkCode === 162 || vkCode === 163
+ }
+
+ //check if the key is shift key
+ private isShiftkey(vkCode: number) {
+ return vkCode === 160 || vkCode === 161
+ }
+
+ /**
+ * Create a preloaded action window for quick response
+ * Action windows handle specific operations on selected text
+ * @returns Configured BrowserWindow instance
+ */
+ private createPreloadedActionWindow(): BrowserWindow {
+ const preloadedActionWindow = new BrowserWindow({
+ width: this.isRemeberWinSize ? this.lastActionWindowSize.width : this.ACTION_WINDOW_WIDTH,
+ height: this.isRemeberWinSize ? this.lastActionWindowSize.height : this.ACTION_WINDOW_HEIGHT,
+ minWidth: 300,
+ minHeight: 200,
+ frame: false,
+ transparent: true,
+ autoHideMenuBar: true,
+ titleBarStyle: 'hidden',
+ hasShadow: false,
+ thickFrame: false,
+ show: false,
+ webPreferences: {
+ preload: join(__dirname, '../preload/index.js'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ devTools: true
+ }
+ })
+
+ // Load the base URL without action data
+ if (isDev && process.env['ELECTRON_RENDERER_URL']) {
+ preloadedActionWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionAction.html')
+ } else {
+ preloadedActionWindow.loadFile(join(__dirname, '../renderer/selectionAction.html'))
+ }
+
+ return preloadedActionWindow
+ }
+
+ /**
+ * Initialize preloaded action windows
+ * Creates a pool of windows at startup for faster response
+ */
+ private async initPreloadedActionWindows() {
+ try {
+ // Create initial pool of preloaded windows
+ for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) {
+ await this.pushNewActionWindow()
+ }
+ } catch (error) {
+ this.logError('Failed to initialize preloaded windows:', error as Error)
+ }
+ }
+
+ /**
+ * Preload a new action window asynchronously
+ * This method is called after popping a window to ensure we always have windows ready
+ */
+ private async pushNewActionWindow() {
+ try {
+ const actionWindow = this.createPreloadedActionWindow()
+ this.preloadedActionWindows.push(actionWindow)
+ } catch (error) {
+ this.logError('Failed to push new action window:', error as Error)
+ }
+ }
+
+ /**
+ * Pop an action window from the preloadedActionWindows queue
+ * Immediately returns a window and asynchronously creates a new one
+ * @returns {BrowserWindow} The action window
+ */
+ private popActionWindow() {
+ // Get a window from the preloaded queue or create a new one if empty
+ const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow()
+
+ // Set up event listeners for this instance
+ actionWindow.on('closed', () => {
+ this.actionWindows.delete(actionWindow)
+ if (!actionWindow.isDestroyed()) {
+ actionWindow.destroy()
+ }
+ })
+
+ //remember the action window size
+ actionWindow.on('resized', () => {
+ if (this.isRemeberWinSize) {
+ this.lastActionWindowSize = {
+ width: actionWindow.getBounds().width,
+ height: actionWindow.getBounds().height
+ }
+ }
+ })
+
+ this.actionWindows.add(actionWindow)
+
+ // Asynchronously create a new preloaded window
+ this.pushNewActionWindow()
+
+ return actionWindow
+ }
+
+ public processAction(actionItem: ActionItem): void {
+ const actionWindow = this.popActionWindow()
+
+ actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
+
+ this.showActionWindow(actionWindow)
+ }
+
+ /**
+ * Show action window with proper positioning relative to toolbar
+ * Ensures window stays within screen boundaries
+ * @param actionWindow Window to position and show
+ */
+ private showActionWindow(actionWindow: BrowserWindow) {
+ let actionWindowWidth = this.ACTION_WINDOW_WIDTH
+ let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
+
+ //if remember win size is true, use the last remembered size
+ if (this.isRemeberWinSize) {
+ actionWindowWidth = this.lastActionWindowSize.width
+ actionWindowHeight = this.lastActionWindowSize.height
+ }
+
+ //center way
+ if (!this.isFollowToolbar || !this.toolbarWindow) {
+ if (this.isRemeberWinSize) {
+ actionWindow.setBounds({
+ width: actionWindowWidth,
+ height: actionWindowHeight
+ })
+ }
+
+ actionWindow.show()
+ this.hideToolbar()
+ return
+ }
+
+ //follow toolbar
+
+ const toolbarBounds = this.toolbarWindow!.getBounds()
+ const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y })
+ const workArea = display.workArea
+ const GAP = 6 // 6px gap from screen edges
+
+ //make sure action window is inside screen
+ if (actionWindowWidth > workArea.width - 2 * GAP) {
+ actionWindowWidth = workArea.width - 2 * GAP
+ }
+
+ if (actionWindowHeight > workArea.height - 2 * GAP) {
+ actionWindowHeight = workArea.height - 2 * GAP
+ }
+
+ // Calculate initial position to center action window horizontally below toolbar
+ let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
+ let posY = Math.round(toolbarBounds.y)
+
+ // Ensure action window stays within screen boundaries with a small gap
+ if (posX + actionWindowWidth > workArea.x + workArea.width) {
+ posX = workArea.x + workArea.width - actionWindowWidth - GAP
+ } else if (posX < workArea.x) {
+ posX = workArea.x + GAP
+ }
+ if (posY + actionWindowHeight > workArea.y + workArea.height) {
+ // If window would go below screen, try to position it above toolbar
+ posY = workArea.y + workArea.height - actionWindowHeight - GAP
+ } else if (posY < workArea.y) {
+ posY = workArea.y + GAP
+ }
+
+ actionWindow.setPosition(posX, posY, false)
+ //KEY to make window not resize
+ actionWindow.setBounds({
+ width: actionWindowWidth,
+ height: actionWindowHeight,
+ x: posX,
+ y: posY
+ })
+
+ actionWindow.show()
+ }
+
+ public closeActionWindow(actionWindow: BrowserWindow): void {
+ actionWindow.close()
+ }
+
+ public minimizeActionWindow(actionWindow: BrowserWindow): void {
+ actionWindow.minimize()
+ }
+
+ public pinActionWindow(actionWindow: BrowserWindow, isPinned: boolean): void {
+ actionWindow.setAlwaysOnTop(isPinned)
+ }
+
+ /**
+ * Update trigger mode behavior
+ * Switches between selection-based and alt-key based triggering
+ * Manages appropriate event listeners for each mode
+ */
+ private processTriggerMode() {
+ if (this.triggerMode === TriggerMode.Selected) {
+ if (this.isCtrlkeyListenerActive) {
+ this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
+ this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
+
+ this.isCtrlkeyListenerActive = false
+ }
+
+ this.selectionHook!.setSelectionPassiveMode(false)
+ } else if (this.triggerMode === TriggerMode.Ctrlkey) {
+ if (!this.isCtrlkeyListenerActive) {
+ this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
+ this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
+
+ this.isCtrlkeyListenerActive = true
+ }
+
+ this.selectionHook!.setSelectionPassiveMode(true)
+ }
+ }
+
+ public writeToClipboard(text: string): boolean {
+ return this.selectionHook?.writeToClipboard(text) ?? false
+ }
+
+ /**
+ * Register IPC handlers for communication with renderer process
+ * Handles toolbar, action window, and selection-related commands
+ */
+ public static registerIpcHandler(): void {
+ if (this.isIpcHandlerRegistered) return
+
+ ipcMain.handle(IpcChannel.Selection_ToolbarHide, () => {
+ selectionService?.hideToolbar()
+ })
+
+ ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => {
+ return selectionService?.writeToClipboard(text) ?? false
+ })
+
+ ipcMain.handle(IpcChannel.Selection_ToolbarDetermineSize, (_, width: number, height: number) => {
+ selectionService?.determineToolbarSize(width, height)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_SetEnabled, (_, enabled: boolean) => {
+ configManager.setSelectionAssistantEnabled(enabled)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_SetTriggerMode, (_, triggerMode: string) => {
+ configManager.setSelectionAssistantTriggerMode(triggerMode)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_SetFollowToolbar, (_, isFollowToolbar: boolean) => {
+ configManager.setSelectionAssistantFollowToolbar(isFollowToolbar)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_SetRemeberWinSize, (_, isRemeberWinSize: boolean) => {
+ configManager.setSelectionAssistantRemeberWinSize(isRemeberWinSize)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_SetFilterMode, (_, filterMode: string) => {
+ configManager.setSelectionAssistantFilterMode(filterMode)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_SetFilterList, (_, filterList: string[]) => {
+ configManager.setSelectionAssistantFilterList(filterList)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
+ selectionService?.processAction(actionItem)
+ })
+
+ ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {
+ const actionWindow = BrowserWindow.fromWebContents(event.sender)
+ if (actionWindow) {
+ selectionService?.closeActionWindow(actionWindow)
+ }
+ })
+
+ ipcMain.handle(IpcChannel.Selection_ActionWindowMinimize, (event) => {
+ const actionWindow = BrowserWindow.fromWebContents(event.sender)
+ if (actionWindow) {
+ selectionService?.minimizeActionWindow(actionWindow)
+ }
+ })
+
+ ipcMain.handle(IpcChannel.Selection_ActionWindowPin, (event, isPinned: boolean) => {
+ const actionWindow = BrowserWindow.fromWebContents(event.sender)
+ if (actionWindow) {
+ selectionService?.pinActionWindow(actionWindow, isPinned)
+ }
+ })
+
+ this.isIpcHandlerRegistered = true
+ }
+
+ private logInfo(message: string) {
+ isDev && Logger.info('[SelectionService] Info: ', message)
+ }
+
+ private logError(...args: [...string[], Error]) {
+ Logger.error('[SelectionService] Error: ', ...args)
+ }
+}
+
+/**
+ * Initialize selection service when app starts
+ * Sets up config subscription and starts service if enabled
+ * @returns {boolean} Success status of initialization
+ */
+export function initSelectionService(): boolean {
+ if (!isWin) return false
+
+ configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => {
+ //avoid closure
+ const ss = SelectionService.getInstance()
+ if (!ss) {
+ Logger.error('SelectionService not initialized: instance is null')
+ return
+ }
+
+ if (enabled) {
+ ss.start()
+ } else {
+ ss.stop()
+ }
+ })
+
+ if (!configManager.getSelectionAssistantEnabled()) return false
+
+ const ss = SelectionService.getInstance()
+ if (!ss) {
+ Logger.error('SelectionService not initialized: instance is null')
+ return false
+ }
+
+ return ss.start()
+}
+
+const selectionService = SelectionService.getInstance()
+
+export default selectionService
diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts
index 03caa02d24..d69c80b325 100644
--- a/src/main/services/ShortcutService.ts
+++ b/src/main/services/ShortcutService.ts
@@ -1,3 +1,4 @@
+import { handleZoomFactor } from '@main/utils/zoom'
import { Shortcut } from '@types'
import { BrowserWindow, globalShortcut } from 'electron'
import Logger from 'electron-log'
@@ -14,14 +15,11 @@ const windowOnHandlers = new Map void; on
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
case 'zoom_in':
- return (window: BrowserWindow) => handleZoom(0.1)(window)
+ return (window: BrowserWindow) => handleZoomFactor([window], 0.1)
case 'zoom_out':
- return (window: BrowserWindow) => handleZoom(-0.1)(window)
+ return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
case 'zoom_reset':
- return (window: BrowserWindow) => {
- window.webContents.setZoomFactor(1)
- configManager.setZoomFactor(1)
- }
+ return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
case 'show_app':
return () => {
windowService.toggleMainWindow()
@@ -39,17 +37,6 @@ function formatShortcutKey(shortcut: string[]): string {
return shortcut.join('+')
}
-function handleZoom(delta: number) {
- return (window: BrowserWindow) => {
- const currentZoom = configManager.getZoomFactor()
- const newZoom = Number((currentZoom + delta).toFixed(1))
- if (newZoom >= 0.1 && newZoom <= 5.0) {
- window.webContents.setZoomFactor(newZoom)
- configManager.setZoomFactor(newZoom)
- }
- }
-}
-
const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat = (
shortcut: string | string[]
): string => {
diff --git a/src/main/services/StoreSyncService.ts b/src/main/services/StoreSyncService.ts
new file mode 100644
index 0000000000..84d84d1ad4
--- /dev/null
+++ b/src/main/services/StoreSyncService.ts
@@ -0,0 +1,116 @@
+import { IpcChannel } from '@shared/IpcChannel'
+import type { StoreSyncAction } from '@types'
+import { BrowserWindow, ipcMain } from 'electron'
+
+/**
+ * StoreSyncService class manages Redux store synchronization between multiple windows in the main process
+ * It uses singleton pattern to ensure only one sync service instance exists in the application
+ *
+ * Main features:
+ * 1. Manages window subscriptions for store sync
+ * 2. Handles IPC communication for store sync between windows
+ * 3. Broadcasts Redux actions from one window to all other windows
+ * 4. Adds metadata to synced actions to prevent infinite sync loops
+ */
+export class StoreSyncService {
+ private static instance: StoreSyncService
+ private windowIds: number[] = []
+ private isIpcHandlerRegistered = false
+
+ private constructor() {
+ return
+ }
+
+ /**
+ * Get the singleton instance of StoreSyncService
+ */
+ public static getInstance(): StoreSyncService {
+ if (!StoreSyncService.instance) {
+ StoreSyncService.instance = new StoreSyncService()
+ }
+ return StoreSyncService.instance
+ }
+
+ /**
+ * Subscribe a window to store sync
+ * @param windowId ID of the window to subscribe
+ */
+ public subscribe(windowId: number): void {
+ if (!this.windowIds.includes(windowId)) {
+ this.windowIds.push(windowId)
+ }
+ }
+
+ /**
+ * Unsubscribe a window from store sync
+ * @param windowId ID of the window to unsubscribe
+ */
+ public unsubscribe(windowId: number): void {
+ this.windowIds = this.windowIds.filter((id) => id !== windowId)
+ }
+
+ /**
+ * Register IPC handlers for store sync communication
+ * Handles window subscription, unsubscription and action broadcasting
+ */
+ public registerIpcHandler(): void {
+ if (this.isIpcHandlerRegistered) return
+
+ ipcMain.handle(IpcChannel.StoreSync_Subscribe, (event) => {
+ const windowId = BrowserWindow.fromWebContents(event.sender)?.id
+ if (windowId) {
+ this.subscribe(windowId)
+ }
+ })
+
+ ipcMain.handle(IpcChannel.StoreSync_Unsubscribe, (event) => {
+ const windowId = BrowserWindow.fromWebContents(event.sender)?.id
+ if (windowId) {
+ this.unsubscribe(windowId)
+ }
+ })
+
+ ipcMain.handle(IpcChannel.StoreSync_OnUpdate, (event, action: StoreSyncAction) => {
+ const sourceWindowId = BrowserWindow.fromWebContents(event.sender)?.id
+
+ if (!sourceWindowId) return
+
+ // Broadcast the action to all other windows
+ this.broadcastToOtherWindows(sourceWindowId, action)
+ })
+
+ this.isIpcHandlerRegistered = true
+ }
+
+ /**
+ * Broadcast a Redux action to all other windows except the source
+ * @param sourceWindowId ID of the window that originated the action
+ * @param action Redux action to broadcast
+ */
+ private broadcastToOtherWindows(sourceWindowId: number, action: StoreSyncAction): void {
+ // Add metadata to indicate this action came from sync
+ const syncAction = {
+ ...action,
+ meta: {
+ ...action.meta,
+ fromSync: true,
+ source: `windowId:${sourceWindowId}`
+ }
+ }
+
+ // Send to all windows except the source
+ this.windowIds.forEach((windowId) => {
+ if (windowId !== sourceWindowId) {
+ const targetWindow = BrowserWindow.fromId(windowId)
+ if (targetWindow && !targetWindow.isDestroyed()) {
+ targetWindow.webContents.send(IpcChannel.StoreSync_BroadcastSync, syncAction)
+ } else {
+ this.unsubscribe(windowId)
+ }
+ }
+ })
+ }
+}
+
+// Export singleton instance
+export default StoreSyncService.getInstance()
diff --git a/src/main/services/ThemeService.ts b/src/main/services/ThemeService.ts
new file mode 100644
index 0000000000..7ccaf3bf9a
--- /dev/null
+++ b/src/main/services/ThemeService.ts
@@ -0,0 +1,48 @@
+import { IpcChannel } from '@shared/IpcChannel'
+import { ThemeMode } from '@types'
+import { BrowserWindow, nativeTheme } from 'electron'
+
+import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
+import { configManager } from './ConfigManager'
+
+class ThemeService {
+ private theme: ThemeMode = ThemeMode.system
+ constructor() {
+ this.theme = configManager.getTheme()
+
+ if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
+ nativeTheme.themeSource = this.theme
+ } else {
+ // 兼容旧版本
+ configManager.setTheme(ThemeMode.system)
+ nativeTheme.themeSource = ThemeMode.system
+ }
+ nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
+ }
+
+ themeUpdatadHandler() {
+ BrowserWindow.getAllWindows().forEach((win) => {
+ if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
+ try {
+ win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
+ } catch (error) {
+ // don't throw error if setTitleBarOverlay failed
+ // Because it may be called with some windows have some title bar
+ }
+ }
+ win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
+ })
+ }
+
+ setTheme(theme: ThemeMode) {
+ if (theme === this.theme) {
+ return
+ }
+
+ this.theme = theme
+ nativeTheme.themeSource = theme
+ configManager.setTheme(theme)
+ }
+}
+
+export const themeService = new ThemeService()
diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts
index a8faf90727..abfb6c037b 100644
--- a/src/main/services/TrayService.ts
+++ b/src/main/services/TrayService.ts
@@ -5,17 +5,17 @@ import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray }
import icon from '../../../build/tray_icon.png?asset'
import iconDark from '../../../build/tray_icon_dark.png?asset'
import iconLight from '../../../build/tray_icon_light.png?asset'
-import { configManager } from './ConfigManager'
+import { ConfigKeys, configManager } from './ConfigManager'
import { windowService } from './WindowService'
-import { APP_NAME } from '../../shared/app-meta'
export class TrayService {
private static instance: TrayService
private tray: Tray | null = null
+ private contextMenu: Menu | null = null
constructor() {
+ this.watchConfigChanges()
this.updateTray()
- this.watchTrayChanges()
TrayService.instance = this
}
@@ -44,6 +44,30 @@ export class TrayService {
this.tray = tray
+ this.updateContextMenu()
+
+ if (process.platform === 'linux') {
+ this.tray.setContextMenu(this.contextMenu)
+ }
+
+ this.tray.setToolTip('Cherry Studio')
+
+ this.tray.on('right-click', () => {
+ if (this.contextMenu) {
+ this.tray?.popUpContextMenu(this.contextMenu)
+ }
+ })
+
+ this.tray.on('click', () => {
+ if (configManager.getEnableQuickAssistant() && configManager.getClickTrayToShowQuickAssistant()) {
+ windowService.showMiniWindow()
+ } else {
+ windowService.showMainWindow()
+ }
+ })
+ }
+
+ private updateContextMenu() {
const locale = locales[configManager.getLanguage()]
const { tray: trayLocale } = locale.translation
@@ -65,25 +89,7 @@ export class TrayService {
}
].filter(Boolean) as MenuItemConstructorOptions[]
- const contextMenu = Menu.buildFromTemplate(template)
-
- if (process.platform === 'linux') {
- this.tray.setContextMenu(contextMenu)
- }
-
- this.tray.setToolTip(APP_NAME || 'Cherry Studio')
-
- this.tray.on('right-click', () => {
- this.tray?.popUpContextMenu(contextMenu)
- })
-
- this.tray.on('click', () => {
- if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
- windowService.showMiniWindow()
- } else {
- windowService.showMainWindow()
- }
- })
+ this.contextMenu = Menu.buildFromTemplate(template)
}
private updateTray() {
@@ -95,13 +101,6 @@ export class TrayService {
}
}
- public restartTray() {
- if (configManager.getTray()) {
- this.destroyTray()
- this.createTray()
- }
- }
-
private destroyTray() {
if (this.tray) {
this.tray.destroy()
@@ -109,8 +108,16 @@ export class TrayService {
}
}
- private watchTrayChanges() {
- configManager.subscribe('tray', () => this.updateTray())
+ private watchConfigChanges() {
+ configManager.subscribe(ConfigKeys.Tray, () => this.updateTray())
+
+ configManager.subscribe(ConfigKeys.Language, () => {
+ this.updateContextMenu()
+ })
+
+ configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
+ this.updateContextMenu()
+ })
}
private quit() {
diff --git a/src/main/services/WebDav.ts b/src/main/services/WebDav.ts
index e78dfdee7e..ad1a127b3a 100644
--- a/src/main/services/WebDav.ts
+++ b/src/main/services/WebDav.ts
@@ -1,6 +1,7 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import Stream from 'stream'
+import https from 'https'
import {
BufferLike,
createClient,
@@ -20,7 +21,8 @@ export default class WebDav {
username: params.webdavUser,
password: params.webdavPass,
maxBodyLength: Infinity,
- maxContentLength: Infinity
+ maxContentLength: Infinity,
+ httpsAgent: new https.Agent({ rejectUnauthorized: false })
})
this.putFileContents = this.putFileContents.bind(this)
@@ -74,6 +76,19 @@ export default class WebDav {
}
}
+ public getDirectoryContents = async () => {
+ if (!this.instance) {
+ throw new Error('WebDAV client not initialized')
+ }
+
+ try {
+ return await this.instance.getDirectoryContents(this.webdavPath)
+ } catch (error) {
+ Logger.error('[WebDAV] Error getting directory contents on WebDAV:', error)
+ throw error
+ }
+ }
+
public checkConnection = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
diff --git a/src/main/services/WebviewService.ts b/src/main/services/WebviewService.ts
index 50da5cd1e5..7a14e65c19 100644
--- a/src/main/services/WebviewService.ts
+++ b/src/main/services/WebviewService.ts
@@ -6,12 +6,8 @@ import { session, shell, webContents } from 'electron'
*/
export function initSessionUserAgent() {
const wvSession = session.fromPartition('persist:webview')
- const newChromeVersion = '135.0.7049.96'
const originUA = wvSession.getUserAgent()
- const newUA = originUA
- .replace(/CherryStudio\/\S+\s/, '')
- .replace(/Electron\/\S+\s/, '')
- .replace(/Chrome\/\d+\.\d+\.\d+\.\d+/, `Chrome/${newChromeVersion}`)
+ const newUA = originUA.replace(/CherryStudio\/\S+\s/, '').replace(/Electron\/\S+\s/, '')
wvSession.setUserAgent(newUA)
}
diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts
index f5d90de04d..1cdcc2754d 100644
--- a/src/main/services/WindowService.ts
+++ b/src/main/services/WindowService.ts
@@ -1,17 +1,19 @@
+// just import the themeService to ensure the theme is initialized
+import './ThemeService'
+
import { is } from '@electron-toolkit/utils'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file'
import { IpcChannel } from '@shared/IpcChannel'
-import { ThemeMode } from '@types'
-import { app, BrowserWindow, ipcMain, Menu, MenuItem, nativeTheme, shell } from 'electron'
+import { app, BrowserWindow, nativeTheme, shell } from 'electron'
import Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state'
import { join } from 'path'
import icon from '../../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
-import { locales } from '../utils/locales'
import { configManager } from './ConfigManager'
+import { contextMenu } from './ContextMenu'
import { initSessionUserAgent } from './WebviewService'
export class WindowService {
@@ -22,9 +24,7 @@ export class WindowService {
//hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides
private wasMainWindowFocused: boolean = false
- private selectionMenuWindow: BrowserWindow | null = null
- private lastSelectedText: string = ''
- private contextMenu: Menu | null = null
+ private lastRendererProcessCrashTime: number = 0
public static getInstance(): WindowService {
if (!WindowService.instance) {
@@ -47,13 +47,6 @@ export class WindowService {
maximize: false
})
- const theme = configManager.getTheme()
- if (theme === ThemeMode.auto) {
- nativeTheme.themeSource = 'system'
- } else {
- nativeTheme.themeSource = theme
- }
-
this.mainWindow = new BrowserWindow({
x: mainWindowState.x,
y: mainWindowState.y,
@@ -66,7 +59,7 @@ export class WindowService {
transparent: isMac,
vibrancy: 'sidebar',
visualEffectState: 'active',
- titleBarStyle: isLinux ? 'default' : 'hidden',
+ titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
darkTheme: nativeTheme.shouldUseDarkColors,
@@ -77,7 +70,9 @@ export class WindowService {
sandbox: false,
webSecurity: false,
webviewTag: true,
- allowRunningInsecureContent: true
+ allowRunningInsecureContent: true,
+ zoomFactor: configManager.getZoomFactor(),
+ backgroundThrottling: false
}
})
@@ -103,9 +98,32 @@ export class WindowService {
this.setupWindowEvents(mainWindow)
this.setupWebContentsHandlers(mainWindow)
this.setupWindowLifecycleEvents(mainWindow)
+ this.setupMainWindowMonitor(mainWindow)
this.loadMainWindowContent(mainWindow)
}
+ private setupMainWindowMonitor(mainWindow: BrowserWindow) {
+ mainWindow.webContents.on('render-process-gone', (_, details) => {
+ Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`)
+ const currentTime = Date.now()
+ const lastCrashTime = this.lastRendererProcessCrashTime
+ this.lastRendererProcessCrashTime = currentTime
+ if (currentTime - lastCrashTime > 60 * 1000) {
+ // 如果大于1分钟,则重启渲染进程
+ mainWindow.webContents.reload()
+ } else {
+ // 如果小于1分钟,则退出应用, 可能是连续crash,需要退出应用
+ app.exit(1)
+ }
+ })
+
+ mainWindow.webContents.on('unresponsive', () => {
+ // 在升级到electron 34后,可以获取具体js stack trace,目前只打个日志监控下
+ // https://www.electronjs.org/blog/electron-34-0#unresponsive-renderer-javascript-call-stacks
+ Logger.error('Renderer process unresponsive')
+ })
+ }
+
private setupMaximize(mainWindow: BrowserWindow, isMaximized: boolean) {
if (isMaximized) {
// 如果是从托盘启动,则需要延迟最大化,否则显示的就不是重启前的最大化窗口了
@@ -118,18 +136,9 @@ export class WindowService {
}
private setupContextMenu(mainWindow: BrowserWindow) {
- if (!this.contextMenu) {
- const locale = locales[configManager.getLanguage()]
- const { common } = locale.translation
-
- this.contextMenu = new Menu()
- this.contextMenu.append(new MenuItem({ label: common.copy, role: 'copy' }))
- this.contextMenu.append(new MenuItem({ label: common.paste, role: 'paste' }))
- this.contextMenu.append(new MenuItem({ label: common.cut, role: 'cut' }))
- }
-
- mainWindow.webContents.on('context-menu', () => {
- this.contextMenu?.popup()
+ contextMenu.contextMenu(mainWindow)
+ app.on('browser-window-created', (_, win) => {
+ contextMenu.contextMenu(win)
})
// Dangerous API
@@ -138,13 +147,6 @@ export class WindowService {
webPreferences.preload = join(__dirname, '../preload/index.js')
})
}
-
- // Handle webview context menu
- mainWindow.webContents.on('did-attach-webview', (_, webContents) => {
- webContents.on('context-menu', () => {
- this.contextMenu?.popup()
- })
- })
}
private setupWindowEvents(mainWindow: BrowserWindow) {
@@ -179,6 +181,12 @@ export class WindowService {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
+ // set the zoom factor again when the window is going to restore
+ // minimize and restore will cause zoom reset
+ mainWindow.on('restore', () => {
+ mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
+ })
+
// ARCH: as `will-resize` is only for Win & Mac,
// linux has the same problem, use `resize` listener instead
// but `resize` will fliker the ui
@@ -193,10 +201,21 @@ export class WindowService {
// 当按下Escape键且窗口处于全屏状态时退出全屏
if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
if (mainWindow.isFullScreen()) {
- event.preventDefault()
- mainWindow.setFullScreen(false)
+ // 获取 shortcuts 配置
+ const shortcuts = configManager.getShortcuts()
+ const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
+ if (exitFullscreenShortcut == undefined) {
+ mainWindow.setFullScreen(false)
+ return
+ }
+ if (exitFullscreenShortcut?.enabled) {
+ event.preventDefault()
+ mainWindow.setFullScreen(false)
+ return
+ }
}
}
+ return
})
}
@@ -301,7 +320,7 @@ export class WindowService {
/**
* 上述逻辑以下:
- * win/linux: 是“开启托盘+设置关闭时最小化到托盘”的情况
+ * win/linux: 是"开启托盘+设置关闭时最小化到托盘"的情况
* mac: 任何情况都会到这里,因此需要单独处理mac
*/
@@ -424,7 +443,8 @@ export class WindowService {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
- webviewTag: true
+ webviewTag: true,
+ backgroundThrottling: false
}
})
@@ -462,16 +482,10 @@ export class WindowService {
this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow)
})
- ipcMain.on(IpcChannel.MiniWindowReload, () => {
- this.miniWindow?.reload()
- })
-
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
- this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/mini')
+ this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/miniWindow.html')
} else {
- this.miniWindow.loadFile(join(__dirname, '../renderer/index.html'), {
- hash: '#/mini'
- })
+ this.miniWindow.loadFile(join(__dirname, '../renderer/miniWindow.html'))
}
return this.miniWindow
@@ -484,10 +498,6 @@ export class WindowService {
return
}
- if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
- this.selectionMenuWindow.hide()
- }
-
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
@@ -534,74 +544,6 @@ export class WindowService {
public setPinMiniWindow(isPinned) {
this.isPinnedMiniWindow = isPinned
}
-
- public showSelectionMenu(bounds: { x: number; y: number }) {
- if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
- this.selectionMenuWindow.setPosition(bounds.x, bounds.y)
- this.selectionMenuWindow.show()
- return
- }
-
- const theme = configManager.getTheme()
-
- this.selectionMenuWindow = new BrowserWindow({
- width: 280,
- height: 40,
- x: bounds.x,
- y: bounds.y,
- show: true,
- autoHideMenuBar: true,
- transparent: true,
- frame: false,
- alwaysOnTop: false,
- skipTaskbar: true,
- backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
- resizable: false,
- vibrancy: 'popover',
- webPreferences: {
- preload: join(__dirname, '../preload/index.js'),
- sandbox: false,
- webSecurity: false
- }
- })
-
- // 点击其他地方时隐藏窗口
- this.selectionMenuWindow.on('blur', () => {
- this.selectionMenuWindow?.hide()
- this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
- action: 'home',
- selectedText: this.lastSelectedText
- })
- })
-
- if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
- this.selectionMenuWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/src/windows/menu/menu.html')
- } else {
- this.selectionMenuWindow.loadFile(join(__dirname, '../renderer/src/windows/menu/menu.html'))
- }
-
- this.setupSelectionMenuEvents()
- }
-
- private setupSelectionMenuEvents() {
- if (!this.selectionMenuWindow) return
-
- ipcMain.removeHandler(IpcChannel.SelectionMenu_Action)
- ipcMain.handle(IpcChannel.SelectionMenu_Action, (_, action) => {
- this.selectionMenuWindow?.hide()
- this.showMiniWindow()
- setTimeout(() => {
- this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
- action,
- selectedText: this.lastSelectedText
- })
- }, 100)
- })
- }
-
- public setLastSelectedText(text: string) {
- this.lastSelectedText = text
- }
}
export const windowService = WindowService.getInstance()
diff --git a/src/main/services/mcp/oauth/callback.ts b/src/main/services/mcp/oauth/callback.ts
index 87ae6f95e3..db70827d00 100644
--- a/src/main/services/mcp/oauth/callback.ts
+++ b/src/main/services/mcp/oauth/callback.ts
@@ -44,7 +44,7 @@ export class CallBackServer {
Logger.error('OAuth callback server error:', error)
})
- const runningServer = new Promise((resolve, reject) => {
+ return new Promise((resolve, reject) => {
server.listen(port, () => {
Logger.info(`OAuth callback server listening on port ${port}`)
resolve(server)
@@ -54,7 +54,6 @@ export class CallBackServer {
reject(error)
})
})
- return runningServer
}
get getServer(): Promise {
diff --git a/src/main/services/mcp/shell-env.ts b/src/main/services/mcp/shell-env.ts
new file mode 100644
index 0000000000..a4128b3651
--- /dev/null
+++ b/src/main/services/mcp/shell-env.ts
@@ -0,0 +1,120 @@
+import { spawn } from 'child_process'
+import Logger from 'electron-log'
+import os from 'os'
+
+/**
+ * Spawns a login shell in the user's home directory to capture its environment variables.
+ * @returns {Promise} A promise that resolves with an object containing
+ * the environment variables, or rejects with an error.
+ */
+function getLoginShellEnvironment(): Promise> {
+ return new Promise((resolve, reject) => {
+ const homeDirectory = os.homedir()
+ if (!homeDirectory) {
+ return reject(new Error("Could not determine user's home directory."))
+ }
+
+ let shellPath = process.env.SHELL
+ let commandArgs
+ let shellCommandToGetEnv
+
+ const platform = os.platform()
+
+ if (platform === 'win32') {
+ // On Windows, 'cmd.exe' is the common shell.
+ // The 'set' command lists environment variables.
+ // We don't typically talk about "login shells" in the same way,
+ // but cmd will load the user's environment.
+ shellPath = process.env.COMSPEC || 'cmd.exe'
+ shellCommandToGetEnv = 'set'
+ commandArgs = ['/c', shellCommandToGetEnv] // /c Carries out the command specified by string and then terminates
+ } else {
+ // For POSIX systems (Linux, macOS)
+ if (!shellPath) {
+ // Fallback if process.env.SHELL is not set (less common for interactive users)
+ // Defaulting to bash, but this might not be the user's actual login shell.
+ // A more robust solution might involve checking /etc/passwd or similar,
+ // but that's more complex and often requires higher privileges or native modules.
+ Logger.warn("process.env.SHELL is not set. Defaulting to /bin/bash. This might not be the user's login shell.")
+ shellPath = '/bin/bash' // A common default
+ }
+ // -l: Make it a login shell. This sources profile files like .profile, .bash_profile, .zprofile etc.
+ // -i: Make it interactive. Some shells or profile scripts behave differently.
+ // 'env': The command to print environment variables.
+ // Using 'env -0' would be more robust for parsing if values contain newlines,
+ // but requires splitting by null character. For simplicity, we'll use 'env'.
+ shellCommandToGetEnv = 'env'
+ commandArgs = ['-ilc', shellCommandToGetEnv] // -i for interactive, -l for login, -c to execute command
+ }
+
+ Logger.log(`[ShellEnv] Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`)
+
+ const child = spawn(shellPath, commandArgs, {
+ cwd: homeDirectory, // Run the command in the user's home directory
+ detached: true, // Allows the parent to exit independently of the child
+ stdio: ['ignore', 'pipe', 'pipe'], // stdin, stdout, stderr
+ shell: false // We are specifying the shell command directly
+ })
+
+ let output = ''
+ let errorOutput = ''
+
+ child.stdout.on('data', (data) => {
+ output += data.toString()
+ })
+
+ child.stderr.on('data', (data) => {
+ errorOutput += data.toString()
+ })
+
+ child.on('error', (error) => {
+ Logger.error(`Failed to start shell process: ${shellPath}`, error)
+ reject(new Error(`Failed to start shell: ${error.message}`))
+ })
+
+ child.on('close', (code) => {
+ if (code !== 0) {
+ const errorMessage = `Shell process exited with code ${code}. Shell: ${shellPath}. Args: ${commandArgs.join(' ')}. CWD: ${homeDirectory}. Stderr: ${errorOutput.trim()}`
+ Logger.error(errorMessage)
+ return reject(new Error(errorMessage))
+ }
+
+ if (errorOutput.trim()) {
+ // Some shells might output warnings or non-fatal errors to stderr
+ // during profile loading. Log it, but proceed if exit code is 0.
+ Logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`)
+ }
+
+ const env: Record = {}
+ const lines = output.split('\n')
+
+ lines.forEach((line) => {
+ const trimmedLine = line.trim()
+ if (trimmedLine) {
+ const separatorIndex = trimmedLine.indexOf('=')
+ if (separatorIndex > 0) {
+ // Ensure '=' is present and it's not the first character
+ const key = trimmedLine.substring(0, separatorIndex)
+ const value = trimmedLine.substring(separatorIndex + 1)
+ env[key] = value
+ }
+ }
+ })
+
+ if (Object.keys(env).length === 0 && output.length < 100) {
+ // Arbitrary small length check
+ // This might indicate an issue if no env vars were parsed or output was minimal
+ Logger.warn(
+ 'Parsed environment is empty or output was very short. This might indicate an issue with shell execution or environment variable retrieval.'
+ )
+ Logger.warn('Raw output from shell:\n', output)
+ }
+
+ env.PATH = env.Path || env.PATH || ''
+
+ resolve(env)
+ })
+ })
+}
+
+export default getLoginShellEnvironment
diff --git a/src/main/services/urlschema/handle-providers.ts b/src/main/services/urlschema/handle-providers.ts
new file mode 100644
index 0000000000..bc109437e6
--- /dev/null
+++ b/src/main/services/urlschema/handle-providers.ts
@@ -0,0 +1,37 @@
+import { IpcChannel } from '@shared/IpcChannel'
+import Logger from 'electron-log'
+
+import { windowService } from '../WindowService'
+
+export function handleProvidersProtocolUrl(url: URL) {
+ const params = new URLSearchParams(url.search)
+ switch (url.pathname) {
+ case '/api-keys': {
+ // jsonConfig example:
+ // {
+ // "id": "tokenflux",
+ // "baseUrl": "https://tokenflux.ai/v1",
+ // "apiKey": "sk-xxxx"
+ // }
+ // cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))}
+ const data = params.get('data')
+ if (data) {
+ const stringify = Buffer.from(data, 'base64').toString('utf8')
+ Logger.info('get api keys from urlschema: ', stringify)
+ const jsonConfig = JSON.parse(stringify)
+ Logger.info('get api keys from urlschema: ', jsonConfig)
+ const mainWindow = windowService.getMainWindow()
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig)
+ mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`)
+ }
+ } else {
+ Logger.error('No data found in URL')
+ }
+ break
+ }
+ default:
+ console.error(`Unknown MCP protocol URL: ${url}`)
+ break
+ }
+}
diff --git a/src/main/utils/__tests__/aes.test.ts b/src/main/utils/__tests__/aes.test.ts
new file mode 100644
index 0000000000..59fb1d42d3
--- /dev/null
+++ b/src/main/utils/__tests__/aes.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+
+import { decrypt, encrypt } from '../aes'
+
+const key = '12345678901234567890123456789012' // 32字节
+const iv = '1234567890abcdef1234567890abcdef' // 32字节hex,实际应16字节hex
+
+function getIv16() {
+ // 取前16字节作为 hex
+ return iv.slice(0, 32)
+}
+
+describe('aes utils', () => {
+ it('should encrypt and decrypt normal string', () => {
+ const text = 'hello world'
+ const { iv: outIv, encryptedData } = encrypt(text, key, getIv16())
+ expect(typeof encryptedData).toBe('string')
+ expect(outIv).toBe(getIv16())
+ const decrypted = decrypt(encryptedData, getIv16(), key)
+ expect(decrypted).toBe(text)
+ })
+
+ it('should support unicode and special chars', () => {
+ const text = '你好,世界!🌟🚀'
+ const { encryptedData } = encrypt(text, key, getIv16())
+ const decrypted = decrypt(encryptedData, getIv16(), key)
+ expect(decrypted).toBe(text)
+ })
+
+ it('should handle empty string', () => {
+ const text = ''
+ const { encryptedData } = encrypt(text, key, getIv16())
+ const decrypted = decrypt(encryptedData, getIv16(), key)
+ expect(decrypted).toBe(text)
+ })
+
+ it('should encrypt and decrypt long string', () => {
+ const text = 'a'.repeat(100_000)
+ const { encryptedData } = encrypt(text, key, getIv16())
+ const decrypted = decrypt(encryptedData, getIv16(), key)
+ expect(decrypted).toBe(text)
+ })
+
+ it('should throw error for wrong key', () => {
+ const text = 'test'
+ const { encryptedData } = encrypt(text, key, getIv16())
+ expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow()
+ })
+
+ it('should throw error for wrong iv', () => {
+ const text = 'test'
+ const { encryptedData } = encrypt(text, key, getIv16())
+ expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow()
+ })
+
+ it('should throw error for invalid key/iv length', () => {
+ expect(() => encrypt('test', 'shortkey', getIv16())).toThrow()
+ expect(() => encrypt('test', key, 'shortiv')).toThrow()
+ })
+
+ it('should throw error for invalid encrypted data', () => {
+ expect(() => decrypt('nothexdata', getIv16(), key)).toThrow()
+ })
+
+ it('should throw error for non-string input', () => {
+ // @ts-expect-error purposely pass wrong type to test error branch
+ expect(() => encrypt(null, key, getIv16())).toThrow()
+ // @ts-expect-error purposely pass wrong type to test error branch
+ expect(() => decrypt(null, getIv16(), key)).toThrow()
+ })
+})
diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts
new file mode 100644
index 0000000000..aae00e85d4
--- /dev/null
+++ b/src/main/utils/__tests__/file.test.ts
@@ -0,0 +1,243 @@
+import * as fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+import { FileTypes } from '@types'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
+
+// Mock dependencies
+vi.mock('node:fs')
+vi.mock('node:os')
+vi.mock('node:path')
+vi.mock('uuid', () => ({
+ v4: () => 'mock-uuid'
+}))
+vi.mock('electron', () => ({
+ app: {
+ getPath: vi.fn((key) => {
+ if (key === 'temp') return '/mock/temp'
+ if (key === 'userData') return '/mock/userData'
+ return '/mock/unknown'
+ })
+ }
+}))
+
+describe('file', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Mock path.extname
+ vi.mocked(path.extname).mockImplementation((file) => {
+ const parts = file.split('.')
+ return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''
+ })
+
+ // Mock path.basename
+ vi.mocked(path.basename).mockImplementation((file) => {
+ const parts = file.split('/')
+ return parts[parts.length - 1]
+ })
+
+ // Mock path.join
+ vi.mocked(path.join).mockImplementation((...args) => args.join('/'))
+
+ // Mock os.homedir
+ vi.mocked(os.homedir).mockReturnValue('/mock/home')
+ })
+
+ afterEach(() => {
+ vi.resetAllMocks()
+ })
+
+ describe('getFileType', () => {
+ it('should return IMAGE for image extensions', () => {
+ expect(getFileType('.jpg')).toBe(FileTypes.IMAGE)
+ expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE)
+ expect(getFileType('.png')).toBe(FileTypes.IMAGE)
+ expect(getFileType('.gif')).toBe(FileTypes.IMAGE)
+ expect(getFileType('.webp')).toBe(FileTypes.IMAGE)
+ expect(getFileType('.bmp')).toBe(FileTypes.IMAGE)
+ })
+
+ it('should return VIDEO for video extensions', () => {
+ expect(getFileType('.mp4')).toBe(FileTypes.VIDEO)
+ expect(getFileType('.avi')).toBe(FileTypes.VIDEO)
+ expect(getFileType('.mov')).toBe(FileTypes.VIDEO)
+ expect(getFileType('.mkv')).toBe(FileTypes.VIDEO)
+ expect(getFileType('.flv')).toBe(FileTypes.VIDEO)
+ })
+
+ it('should return AUDIO for audio extensions', () => {
+ expect(getFileType('.mp3')).toBe(FileTypes.AUDIO)
+ expect(getFileType('.wav')).toBe(FileTypes.AUDIO)
+ expect(getFileType('.ogg')).toBe(FileTypes.AUDIO)
+ expect(getFileType('.flac')).toBe(FileTypes.AUDIO)
+ expect(getFileType('.aac')).toBe(FileTypes.AUDIO)
+ })
+
+ it('should return TEXT for text extensions', () => {
+ expect(getFileType('.txt')).toBe(FileTypes.TEXT)
+ expect(getFileType('.md')).toBe(FileTypes.TEXT)
+ expect(getFileType('.html')).toBe(FileTypes.TEXT)
+ expect(getFileType('.json')).toBe(FileTypes.TEXT)
+ expect(getFileType('.js')).toBe(FileTypes.TEXT)
+ expect(getFileType('.ts')).toBe(FileTypes.TEXT)
+ expect(getFileType('.css')).toBe(FileTypes.TEXT)
+ expect(getFileType('.java')).toBe(FileTypes.TEXT)
+ expect(getFileType('.py')).toBe(FileTypes.TEXT)
+ })
+
+ it('should return DOCUMENT for document extensions', () => {
+ expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
+ expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
+ expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
+ expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
+ expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
+ })
+
+ it('should return OTHER for unknown extensions', () => {
+ expect(getFileType('.unknown')).toBe(FileTypes.OTHER)
+ expect(getFileType('')).toBe(FileTypes.OTHER)
+ expect(getFileType('.')).toBe(FileTypes.OTHER)
+ expect(getFileType('...')).toBe(FileTypes.OTHER)
+ expect(getFileType('.123')).toBe(FileTypes.OTHER)
+ })
+
+ it('should handle case-insensitive extensions', () => {
+ expect(getFileType('.JPG')).toBe(FileTypes.IMAGE)
+ expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT)
+ expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO)
+ expect(getFileType('.HtMl')).toBe(FileTypes.TEXT)
+ expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT)
+ })
+
+ it('should handle extensions without leading dot', () => {
+ expect(getFileType('jpg')).toBe(FileTypes.OTHER)
+ expect(getFileType('pdf')).toBe(FileTypes.OTHER)
+ expect(getFileType('mp3')).toBe(FileTypes.OTHER)
+ })
+
+ it('should handle extreme cases', () => {
+ expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER)
+ expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER)
+ expect(getFileType('.文件')).toBe(FileTypes.OTHER)
+ expect(getFileType('.файл')).toBe(FileTypes.OTHER)
+ })
+ })
+
+ describe('getAllFiles', () => {
+ it('should return all valid files recursively', () => {
+ // Mock file system
+ // @ts-ignore - override type for testing
+ vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => {
+ if (dirPath === '/test') {
+ return ['file1.txt', 'file2.pdf', 'subdir']
+ } else if (dirPath === '/test/subdir') {
+ return ['file3.md', 'file4.docx']
+ }
+ return []
+ })
+
+ vi.mocked(fs.statSync).mockImplementation((filePath) => {
+ const isDir = String(filePath).endsWith('subdir')
+ return {
+ isDirectory: () => isDir,
+ size: 1024
+ } as fs.Stats
+ })
+
+ const result = getAllFiles('/test')
+
+ expect(result).toHaveLength(4)
+ expect(result[0].id).toBe('mock-uuid')
+ expect(result[0].name).toBe('file1.txt')
+ expect(result[0].type).toBe(FileTypes.TEXT)
+ expect(result[1].name).toBe('file2.pdf')
+ expect(result[1].type).toBe(FileTypes.DOCUMENT)
+ })
+
+ it('should skip hidden files', () => {
+ // @ts-ignore - override type for testing
+ vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt'])
+ vi.mocked(fs.statSync).mockReturnValue({
+ isDirectory: () => false,
+ size: 1024
+ } as fs.Stats)
+
+ const result = getAllFiles('/test')
+
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('visible.txt')
+ })
+
+ it('should skip unsupported file types', () => {
+ // @ts-ignore - override type for testing
+ vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf'])
+ vi.mocked(fs.statSync).mockReturnValue({
+ isDirectory: () => false,
+ size: 1024
+ } as fs.Stats)
+
+ const result = getAllFiles('/test')
+
+ // Should only include document.pdf as the others are excluded types
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('document.pdf')
+ expect(result[0].type).toBe(FileTypes.DOCUMENT)
+ })
+
+ it('should return empty array for empty directory', () => {
+ // @ts-ignore - override type for testing
+ vi.spyOn(fs, 'readdirSync').mockReturnValue([])
+
+ const result = getAllFiles('/empty')
+
+ expect(result).toHaveLength(0)
+ })
+
+ it('should handle file system errors', () => {
+ // @ts-ignore - override type for testing
+ vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
+ throw new Error('Directory not found')
+ })
+
+ // Since the function doesn't have error handling, we expect it to propagate
+ expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found')
+ })
+ })
+
+ describe('getTempDir', () => {
+ it('should return correct temp directory path', () => {
+ const tempDir = getTempDir()
+ expect(tempDir).toBe('/mock/temp/CherryStudio')
+ })
+ })
+
+ describe('getFilesDir', () => {
+ it('should return correct files directory path', () => {
+ const filesDir = getFilesDir()
+ expect(filesDir).toBe('/mock/userData/Data/Files')
+ })
+ })
+
+ describe('getConfigDir', () => {
+ it('should return correct config directory path', () => {
+ const configDir = getConfigDir()
+ expect(configDir).toBe('/mock/home/.cherrystudio/config')
+ })
+ })
+
+ describe('getAppConfigDir', () => {
+ it('should return correct app config directory path', () => {
+ const appConfigDir = getAppConfigDir('test-app')
+ expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app')
+ })
+
+ it('should handle empty app name', () => {
+ const appConfigDir = getAppConfigDir('')
+ expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
+ })
+ })
+})
diff --git a/src/main/utils/__tests__/zip.test.ts b/src/main/utils/__tests__/zip.test.ts
new file mode 100644
index 0000000000..6c84b16e93
--- /dev/null
+++ b/src/main/utils/__tests__/zip.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+
+import { compress, decompress } from '../zip'
+
+const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] })
+
+// 辅助函数:生成大字符串
+function makeLargeString(size: number) {
+ return 'a'.repeat(size)
+}
+
+describe('zip', () => {
+ describe('compress & decompress', () => {
+ it('should compress and decompress a normal JSON string', async () => {
+ const compressed = await compress(jsonStr)
+ expect(compressed).toBeInstanceOf(Buffer)
+
+ const decompressed = await decompress(compressed)
+ expect(decompressed).toBe(jsonStr)
+ })
+
+ it('should handle empty string', async () => {
+ const compressed = await compress('')
+ expect(compressed).toBeInstanceOf(Buffer)
+ const decompressed = await decompress(compressed)
+ expect(decompressed).toBe('')
+ })
+
+ it('should handle large string', async () => {
+ const largeStr = makeLargeString(100_000)
+ const compressed = await compress(largeStr)
+ expect(compressed).toBeInstanceOf(Buffer)
+ expect(compressed.length).toBeLessThan(largeStr.length)
+ const decompressed = await decompress(compressed)
+ expect(decompressed).toBe(largeStr)
+ })
+
+ it('should throw error when decompressing invalid buffer', async () => {
+ const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8')
+ await expect(decompress(invalidBuffer)).rejects.toThrow()
+ })
+
+ it('should throw error when compress input is not string', async () => {
+ // @ts-expect-error purposely pass wrong type to test error branch
+ await expect(compress(null)).rejects.toThrow()
+ // @ts-expect-error purposely pass wrong type to test error branch
+ await expect(compress(undefined)).rejects.toThrow()
+ // @ts-expect-error purposely pass wrong type to test error branch
+ await expect(compress(123)).rejects.toThrow()
+ })
+
+ it('should throw error when decompress input is not buffer', async () => {
+ // @ts-expect-error purposely pass wrong type to test error branch
+ await expect(decompress(null)).rejects.toThrow()
+ // @ts-expect-error purposely pass wrong type to test error branch
+ await expect(decompress(undefined)).rejects.toThrow()
+ // @ts-expect-error purposely pass wrong type to test error branch
+ await expect(decompress('string')).rejects.toThrow()
+ })
+ })
+})
diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts
index 25a4ed7323..f01a6d47bf 100644
--- a/src/main/utils/file.ts
+++ b/src/main/utils/file.ts
@@ -81,6 +81,10 @@ export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
}
+export function getCacheDir() {
+ return path.join(app.getPath('userData'), 'Cache')
+}
+
export function getAppConfigDir(name: string) {
return path.join(getConfigDir(), name)
}
diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts
index 4a6fde670d..a5f63fcc42 100644
--- a/src/main/utils/index.ts
+++ b/src/main/utils/index.ts
@@ -1,4 +1,5 @@
import fs from 'node:fs'
+import fsAsync from 'node:fs/promises'
import path from 'node:path'
import { app } from 'electron'
@@ -52,3 +53,20 @@ export function makeSureDirExists(dir: string) {
fs.mkdirSync(dir, { recursive: true })
}
}
+
+export async function calculateDirectorySize(directoryPath: string): Promise {
+ let totalSize = 0
+ const items = await fsAsync.readdir(directoryPath)
+
+ for (const item of items) {
+ const itemPath = path.join(directoryPath, item)
+ const stats = await fsAsync.stat(itemPath)
+
+ if (stats.isFile()) {
+ totalSize += stats.size
+ } else if (stats.isDirectory()) {
+ totalSize += await calculateDirectorySize(itemPath)
+ }
+ }
+ return totalSize
+}
diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts
new file mode 100644
index 0000000000..23d19806d9
--- /dev/null
+++ b/src/main/utils/mcp.ts
@@ -0,0 +1,34 @@
+export function buildFunctionCallToolName(serverName: string, toolName: string) {
+ const sanitizedServer = serverName.trim().replace(/-/g, '_')
+ const sanitizedTool = toolName.trim().replace(/-/g, '_')
+
+ // Combine server name and tool name
+ let name = sanitizedTool
+ if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) {
+ name = `${sanitizedServer.slice(0, 7) || ''}-${sanitizedTool || ''}`
+ }
+
+ // Replace invalid characters with underscores or dashes
+ // Keep a-z, A-Z, 0-9, underscores and dashes
+ name = name.replace(/[^a-zA-Z0-9_-]/g, '_')
+
+ // Ensure name starts with a letter or underscore (for valid JavaScript identifier)
+ if (!/^[a-zA-Z]/.test(name)) {
+ name = `tool-${name}`
+ }
+
+ // Remove consecutive underscores/dashes (optional improvement)
+ name = name.replace(/[_-]{2,}/g, '_')
+
+ // Truncate to 63 characters maximum
+ if (name.length > 63) {
+ name = name.slice(0, 63)
+ }
+
+ // Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges
+ if (name.endsWith('_') || name.endsWith('-')) {
+ name = name.slice(0, -1)
+ }
+
+ return name
+}
diff --git a/src/main/utils/windowUtil.ts b/src/main/utils/windowUtil.ts
index d64929deb1..4000156fff 100644
--- a/src/main/utils/windowUtil.ts
+++ b/src/main/utils/windowUtil.ts
@@ -1,5 +1,7 @@
import { BrowserWindow } from 'electron'
+import { isDev, isWin } from '../constant'
+
function isTilingWindowManager() {
if (process.platform === 'darwin') {
return false
@@ -15,31 +17,59 @@ function isTilingWindowManager() {
return tilingSystems.some((system) => desktopEnv?.includes(system))
}
+//see: https://github.com/electron/electron/issues/42055#issuecomment-2449365647
export const replaceDevtoolsFont = (browserWindow: BrowserWindow) => {
- if (process.platform === 'win32') {
+ //only for windows and dev, don't do this in production to avoid performance issues
+ if (isWin && isDev) {
browserWindow.webContents.on('devtools-opened', () => {
const css = `
:root {
--sys-color-base: var(--ref-palette-neutral100);
- --source-code-font-family: consolas;
+ --source-code-font-family: consolas !important;
--source-code-font-size: 12px;
- --monospace-font-family: consolas;
+ --monospace-font-family: consolas !important;
--monospace-font-size: 12px;
--default-font-family: system-ui, sans-serif;
--default-font-size: 12px;
+ --ref-palette-neutral99: #ffffffff;
}
- .-theme-with-dark-background {
+ .theme-with-dark-background {
--sys-color-base: var(--ref-palette-secondary25);
}
body {
- --default-font-family: system-ui,sans-serif;
- }`
-
+ --default-font-family: system-ui, sans-serif;
+ }
+ `
browserWindow.webContents.devToolsWebContents?.executeJavaScript(`
const overriddenStyle = document.createElement('style');
overriddenStyle.innerHTML = '${css.replaceAll('\n', ' ')}';
document.body.append(overriddenStyle);
- document.body.classList.remove('platform-windows');`)
+ document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));
+ addStyleToAutoComplete();
+ const observer = new MutationObserver((mutationList, observer) => {
+ for (const mutation of mutationList) {
+ if (mutation.type === 'childList') {
+ for (let i = 0; i < mutation.addedNodes.length; i++) {
+ const item = mutation.addedNodes[i];
+ if (item.classList.contains('editor-tooltip-host')) {
+ addStyleToAutoComplete();
+ }
+ }
+ }
+ }
+ });
+ observer.observe(document.body, {childList: true});
+ function addStyleToAutoComplete() {
+ document.querySelectorAll('.editor-tooltip-host').forEach(element => {
+ if (element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) {
+ const overriddenStyle = document.createElement('style');
+ overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');
+ overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';
+ element.shadowRoot.append(overriddenStyle);
+ }
+ });
+ }
+ `)
})
}
}
diff --git a/src/main/utils/zip.ts b/src/main/utils/zip.ts
index 7b456973c0..b2762f7a98 100644
--- a/src/main/utils/zip.ts
+++ b/src/main/utils/zip.ts
@@ -9,14 +9,13 @@ const gunzipPromise = util.promisify(zlib.gunzip)
/**
* 压缩字符串
- * @param {string} string - 要压缩的 JSON 字符串
+ * @param {string} str 要压缩的 JSON 字符串
* @returns {Promise} 压缩后的 Buffer
*/
-export async function compress(str) {
+export async function compress(str: string): Promise {
try {
const buffer = Buffer.from(str, 'utf-8')
- const compressedBuffer = await gzipPromise(buffer)
- return compressedBuffer
+ return await gzipPromise(buffer)
} catch (error) {
logger.error('Compression failed:', error)
throw error
@@ -28,7 +27,7 @@ export async function compress(str) {
* @param {Buffer} compressedBuffer - 压缩的 Buffer
* @returns {Promise} 解压缩后的 JSON 字符串
*/
-export async function decompress(compressedBuffer) {
+export async function decompress(compressedBuffer: Buffer): Promise {
try {
const buffer = await gunzipPromise(compressedBuffer)
return buffer.toString('utf-8')
diff --git a/src/main/utils/zoom.ts b/src/main/utils/zoom.ts
new file mode 100644
index 0000000000..8e7145313c
--- /dev/null
+++ b/src/main/utils/zoom.ts
@@ -0,0 +1,26 @@
+import { BrowserWindow } from 'electron'
+
+import { configManager } from '../services/ConfigManager'
+
+export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
+ if (reset) {
+ wins.forEach((win) => {
+ win.webContents.setZoomFactor(1)
+ })
+ configManager.setZoomFactor(1)
+ return
+ }
+
+ if (delta === 0) {
+ return
+ }
+
+ const currentZoom = configManager.getZoomFactor()
+ const newZoom = Number((currentZoom + delta).toFixed(1))
+ if (newZoom >= 0.5 && newZoom <= 2.0) {
+ wins.forEach((win) => {
+ win.webContents.setZoomFactor(newZoom)
+ })
+ configManager.setZoomFactor(newZoom)
+ }
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 0e3f432f65..2e4fd44414 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,10 +1,13 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { IpcChannel } from '@shared/IpcChannel'
-import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
+import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
+import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
+import type { ActionItem } from '../renderer/src/types/selectionTypes'
+
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
@@ -17,23 +20,30 @@ const api = {
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
- restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
- setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
- setCustomCss: (css: string) => ipcRenderer.invoke(IpcChannel.App_SetCustomCss, css),
+ setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
+ handleZoomFactor: (delta: number, reset: boolean = false) =>
+ ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
+ getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
+ notification: {
+ send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
+ },
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
},
+ devTools: {
+ toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
+ },
zip: {
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
},
backup: {
- backup: (fileName: string, data: string, destinationPath?: string) =>
- ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath),
+ backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
+ ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
@@ -57,6 +67,7 @@ const api = {
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
+ writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) =>
@@ -64,9 +75,17 @@ const api = {
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
- download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
+ saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
+ download: (url: string, isUseContentType?: boolean) =>
+ ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
- binaryFile: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryFile, fileId)
+ binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
+ base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
+ getPathForFile: (file: File) => {
+ const electronFile = file as File & { path?: string }
+ return electronFile.path || null
+ }
+ // getPathForFile: (file: File) => webUtils.getPathForFile(file)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
@@ -104,17 +123,16 @@ const api = {
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
},
gemini: {
- uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, apiKey),
+ uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
+ ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
},
- selectionMenu: {
- action: (action: string) => ipcRenderer.invoke(IpcChannel.SelectionMenu_Action, action)
- },
config: {
- set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
+ set: (key: string, value: any, isNotify: boolean = false) =>
+ ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
},
miniWindow: {
@@ -143,7 +161,8 @@ const api = {
listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server),
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
- getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
+ getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
+ checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
@@ -158,7 +177,6 @@ const api = {
logout: () => ipcRenderer.invoke(IpcChannel.Copilot_Logout),
getUser: (token: string) => ipcRenderer.invoke(IpcChannel.Copilot_GetUser, token)
},
-
// Binary related APIs
isBinaryExist: (name: string) => ipcRenderer.invoke(IpcChannel.App_IsBinaryExist, name),
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
@@ -189,6 +207,29 @@ const api = {
webview: {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal)
+ },
+ storeSync: {
+ subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
+ unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
+ onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action)
+ },
+ selection: {
+ hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide),
+ writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text),
+ determineToolbarSize: (width: number, height: number) =>
+ ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height),
+ setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled),
+ setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
+ setFollowToolbar: (isFollowToolbar: boolean) =>
+ ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
+ setRemeberWinSize: (isRemeberWinSize: boolean) =>
+ ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
+ setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
+ setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
+ processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
+ closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
+ minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
+ pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
}
}
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 19a44594df..c8832dc573 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -1,42 +1,46 @@
-
-
-
-
- Cherry Studio
-
-
+ #spinner {
+ position: fixed;
+ width: 100vw;
+ height: 100vh;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+ }
-
-
-
-
-
-
-
-
-
+ #spinner img {
+ width: 100px;
+ border-radius: 50px;
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+