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 -![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f) -![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1) -![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be) +![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18) + +![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930) + +![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026) # 🌟 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 | 中文 | 日本語

+
kangfenmao%2Fcherry-studio | Trendshift Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt
+ # 🍒 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 # 🌠 スクリーンショット -![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f) -![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1) -![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be) +![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18) + +![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930) + +![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026) # 🌟 主な機能 @@ -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 | 中文 | 日本語
+

kangfenmao%2Fcherry-studio | Trendshift Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt @@ -18,15 +19,25 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客 ❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️ +# GitCode✖️Cherry Studio【新源力】贡献挑战赛 + +

+ + banner + +

+ # 📖 使用教程 https://docs.cherry-ai.com # 🌠 界面 -![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f) -![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1) -![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be) +![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18) + +![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930) + +![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026) # 🌟 主要特性 @@ -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 + +![](https://github.com/user-attachments/assets/61db64a2-fab1-4a16-8253-0c64c9df1a63) + +## 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` 分支并打上版本标签 + +## 工作流程 + +![](https://github.com/user-attachments/assets/61db64a2-fab1-4a16-8253-0c64c9df1a63) + +## 拉取请求(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 @@ +# 消息的生命周期 + +![image](./message-lifecycle.png) 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 */} + + {/* ... */} +
+ ); +} +``` + +## 返回值 + +`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; + } + + + + +
+
+ +
+ + + + + + \ No newline at end of file diff --git a/src/renderer/miniWindow.html b/src/renderer/miniWindow.html new file mode 100644 index 0000000000..c2748618f1 --- /dev/null +++ b/src/renderer/miniWindow.html @@ -0,0 +1,24 @@ + + + + + + + + Cherry Studio + + + + + +
+ + + + \ No newline at end of file diff --git a/src/renderer/selectionAction.html b/src/renderer/selectionAction.html new file mode 100644 index 0000000000..1dd3fa616c --- /dev/null +++ b/src/renderer/selectionAction.html @@ -0,0 +1,41 @@ + + + + + + + + Cherry Studio Selection Assistant + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/selectionToolbar.html b/src/renderer/selectionToolbar.html new file mode 100644 index 0000000000..1a219f6472 --- /dev/null +++ b/src/renderer/selectionToolbar.html @@ -0,0 +1,43 @@ + + + + + + + + Cherry Studio Selection Toolbar + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c5e70a920e..b46910cd65 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,8 +8,9 @@ import { PersistGate } from 'redux-persist/integration/react' import Sidebar from './components/app/Sidebar' import TopViewContainer from './components/TopView' import AntdProvider from './context/AntdProvider' +import { CodeStyleProvider } from './context/CodeStyleProvider' +import { NotificationProvider } from './context/NotificationProvider' import StyleSheetManager from './context/StyleSheetManager' -import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider' import { ThemeProvider } from './context/ThemeProvider' import NavigationHandler from './handler/NavigationHandler' import AgentsPage from './pages/agents/AgentsPage' @@ -17,7 +18,7 @@ import AppsPage from './pages/apps/AppsPage' import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' import KnowledgePage from './pages/knowledge/KnowledgePage' -import PaintingsPage from './pages/paintings/PaintingsPage' +import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage' import SettingsPage from './pages/settings/SettingsPage' import TranslatePage from './pages/translate/TranslatePage' @@ -27,26 +28,28 @@ function App(): React.ReactElement { - - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - + + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + diff --git a/src/renderer/src/assets/images/apps/application.png b/src/renderer/src/assets/images/apps/application.png new file mode 100644 index 0000000000..c4c65bb158 Binary files /dev/null and b/src/renderer/src/assets/images/apps/application.png differ diff --git a/src/renderer/src/assets/images/apps/google.svg b/src/renderer/src/assets/images/apps/google.svg new file mode 100644 index 0000000000..b518c52704 --- /dev/null +++ b/src/renderer/src/assets/images/apps/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/assets/images/apps/grok-x.png b/src/renderer/src/assets/images/apps/grok-x.png new file mode 100644 index 0000000000..82d8dfb553 Binary files /dev/null and b/src/renderer/src/assets/images/apps/grok-x.png differ diff --git a/src/renderer/src/assets/images/apps/n8n.svg b/src/renderer/src/assets/images/apps/n8n.svg new file mode 100644 index 0000000000..82f0a6da2e --- /dev/null +++ b/src/renderer/src/assets/images/apps/n8n.svg @@ -0,0 +1 @@ +n8n \ No newline at end of file diff --git a/src/renderer/src/assets/images/models/hailuo.png b/src/renderer/src/assets/images/models/hailuo.png index e89ca0f26b..ca0d1e3f07 100644 Binary files a/src/renderer/src/assets/images/models/hailuo.png and b/src/renderer/src/assets/images/models/hailuo.png differ diff --git a/src/renderer/src/assets/images/models/hailuo_dark.png b/src/renderer/src/assets/images/models/hailuo_dark.png index b783bb6c62..8330a88375 100644 Binary files a/src/renderer/src/assets/images/models/hailuo_dark.png and b/src/renderer/src/assets/images/models/hailuo_dark.png differ diff --git a/src/renderer/src/assets/images/models/tokenflux.png b/src/renderer/src/assets/images/models/tokenflux.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/models/tokenflux.png differ diff --git a/src/renderer/src/assets/images/models/tokenflux_dark.png b/src/renderer/src/assets/images/models/tokenflux_dark.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/models/tokenflux_dark.png differ diff --git a/src/renderer/src/assets/images/paintings/ic_ImageUp.svg b/src/renderer/src/assets/images/paintings/ic_ImageUp.svg new file mode 100644 index 0000000000..cbd8974dd0 --- /dev/null +++ b/src/renderer/src/assets/images/paintings/ic_ImageUp.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp b/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp new file mode 100644 index 0000000000..6d18ed8dbe Binary files /dev/null and b/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp differ diff --git a/src/renderer/src/assets/images/providers/aihubmix.png b/src/renderer/src/assets/images/providers/aihubmix.png new file mode 100644 index 0000000000..1fa0b5e513 Binary files /dev/null and b/src/renderer/src/assets/images/providers/aihubmix.png differ diff --git a/src/renderer/src/assets/images/providers/burncloud.png b/src/renderer/src/assets/images/providers/burncloud.png new file mode 100644 index 0000000000..22888bff25 Binary files /dev/null and b/src/renderer/src/assets/images/providers/burncloud.png differ diff --git a/src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp b/src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp new file mode 100644 index 0000000000..baad605a00 Binary files /dev/null and b/src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp differ diff --git a/src/renderer/src/assets/images/providers/dmxapi-logo.webp b/src/renderer/src/assets/images/providers/dmxapi-logo.webp new file mode 100644 index 0000000000..422774d2e5 Binary files /dev/null and b/src/renderer/src/assets/images/providers/dmxapi-logo.webp differ diff --git a/src/renderer/src/assets/images/providers/qiniu.webp b/src/renderer/src/assets/images/providers/qiniu.webp index d3b908df13..692fe46887 100644 Binary files a/src/renderer/src/assets/images/providers/qiniu.webp and b/src/renderer/src/assets/images/providers/qiniu.webp differ diff --git a/src/renderer/src/assets/images/providers/qwenlm.png b/src/renderer/src/assets/images/providers/qwenlm.png deleted file mode 100644 index d207a28997..0000000000 Binary files a/src/renderer/src/assets/images/providers/qwenlm.png and /dev/null differ diff --git a/src/renderer/src/assets/images/providers/tokenflux.png b/src/renderer/src/assets/images/providers/tokenflux.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/providers/tokenflux.png differ diff --git a/src/renderer/src/assets/images/search/bocha.webp b/src/renderer/src/assets/images/search/bocha.webp new file mode 100644 index 0000000000..518e8023a7 Binary files /dev/null and b/src/renderer/src/assets/images/search/bocha.webp differ diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 3bbaa516b9..d4788439e5 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -17,7 +17,7 @@ } .ant-tabs-tab-btn { - outline: none; + outline: none !important; } .ant-segmented-group { @@ -206,8 +206,14 @@ .ant-collapse { border: 1px solid var(--color-border); + .ant-color-picker & { + border: none; + } } .ant-collapse-content { border-top: 1px solid var(--color-border) !important; + .ant-color-picker & { + border-top: none !important; + } } diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss new file mode 100644 index 0000000000..6100e1d0ee --- /dev/null +++ b/src/renderer/src/assets/styles/color.scss @@ -0,0 +1,139 @@ +:root { + --color-white: #ffffff; + --color-white-soft: rgba(255, 255, 255, 0.8); + --color-white-mute: rgba(255, 255, 255, 0.94); + + --color-black: #181818; + --color-black-soft: #222222; + --color-black-mute: #333333; + + --color-gray-1: #515c67; + --color-gray-2: #414853; + --color-gray-3: #32363f; + + --color-text-1: rgba(255, 255, 245, 0.9); + --color-text-2: rgba(235, 235, 245, 0.6); + --color-text-3: rgba(235, 235, 245, 0.38); + + --color-background: var(--color-black); + --color-background-soft: var(--color-black-soft); + --color-background-mute: var(--color-black-mute); + --color-background-opacity: rgba(34, 34, 34, 0.7); + --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu + + --color-primary: #00b96b; + --color-primary-soft: #00b96b99; + --color-primary-mute: #00b96b33; + + --color-text: var(--color-text-1); + --color-text-secondary: rgba(235, 235, 245, 0.7); + --color-icon: #ffffff99; + --color-icon-white: #ffffff; + --color-border: #ffffff19; + --color-border-soft: #ffffff10; + --color-border-mute: #ffffff05; + --color-error: #f44336; + --color-link: #338cff; + --color-code-background: #323232; + --color-hover: rgba(40, 40, 40, 1); + --color-active: rgba(55, 55, 55, 1); + --color-frame-border: #333; + --color-group-background: var(--color-background-soft); + + --color-reference: #404040; + --color-reference-text: #ffffff; + --color-reference-background: #0b0e12; + + --color-list-item: #222; + --color-list-item-hover: #1e1e1e; + + --modal-background: #1f1f1f; + + --color-highlight: rgba(0, 0, 0, 1); + --color-background-highlight: rgba(255, 255, 0, 0.9); + --color-background-highlight-accent: rgba(255, 150, 50, 0.9); + + --navbar-background-mac: rgba(20, 20, 20, 0.55); + --navbar-background: #1f1f1f; + + --navbar-height: 40px; + --sidebar-width: 50px; + --status-bar-height: 40px; + --input-bar-height: 100px; + + --assistants-width: 275px; + --topic-list-width: 275px; + --settings-width: 250px; + --scrollbar-width: 5px; + + --chat-background: #111111; + --chat-background-user: #28b561; + --chat-background-assistant: #2c2c2c; + --chat-text-user: var(--color-black); + + --list-item-border-radius: 20px; +} + +[theme-mode='light'] { + --color-white: #ffffff; + --color-white-soft: rgba(0, 0, 0, 0.04); + --color-white-mute: #eee; + + --color-black: #1b1b1f; + --color-black-soft: #262626; + --color-black-mute: #363636; + + --color-gray-1: #8e8e93; + --color-gray-2: #aeaeb2; + --color-gray-3: #c7c7cc; + + --color-text-1: rgba(0, 0, 0, 1); + --color-text-2: rgba(0, 0, 0, 0.6); + --color-text-3: rgba(0, 0, 0, 0.38); + + --color-background: var(--color-white); + --color-background-soft: var(--color-white-soft); + --color-background-mute: var(--color-white-mute); + --color-background-opacity: rgba(235, 235, 235, 0.7); + --inner-glow-opacity: 0.1; + + --color-primary: #00b96b; + --color-primary-soft: #00b96b99; + --color-primary-mute: #00b96b33; + + --color-text: var(--color-text-1); + --color-text-secondary: rgba(0, 0, 0, 0.75); + --color-icon: #00000099; + --color-icon-white: #000000; + --color-border: #00000019; + --color-border-soft: #00000010; + --color-border-mute: #00000005; + --color-error: #f44336; + --color-link: #1677ff; + --color-code-background: #e3e3e3; + --color-hover: var(--color-white-mute); + --color-active: var(--color-white-soft); + --color-frame-border: #ddd; + --color-group-background: var(--color-white); + + --color-reference: #cfe1ff; + --color-reference-text: #000000; + --color-reference-background: #f1f7ff; + + --color-list-item: #eee; + --color-list-item-hover: #f5f5f5; + + --modal-background: var(--color-white); + + --color-highlight: initial; + --color-background-highlight: rgba(255, 255, 0, 0.5); + --color-background-highlight-accent: rgba(255, 150, 50, 0.5); + + --navbar-background-mac: rgba(255, 255, 255, 0.55); + --navbar-background: rgba(244, 244, 244); + + --chat-background: #f3f3f3; + --chat-background-user: #95ec69; + --chat-background-assistant: #ffffff; + --chat-text-user: var(--color-text); +} diff --git a/src/renderer/src/assets/styles/container.scss b/src/renderer/src/assets/styles/container.scss index 8be4027981..ab5e8a7de8 100644 --- a/src/renderer/src/assets/styles/container.scss +++ b/src/renderer/src/assets/styles/container.scss @@ -4,3 +4,13 @@ border-top-left-radius: 10px; border-left: 0.5px solid var(--color-border); } + +.group-container { + .context-menu-container { + width: 100%; + } +} + +.context-menu-container { + max-width: 100%; +} diff --git a/src/renderer/src/assets/styles/font.scss b/src/renderer/src/assets/styles/font.scss new file mode 100644 index 0000000000..9d2d139b53 --- /dev/null +++ b/src/renderer/src/assets/styles/font.scss @@ -0,0 +1,12 @@ +:root { + --font-family: + Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans', + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + + --font-family-serif: + serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans', + 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + --code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace; +} diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 9721f36126..91778848bb 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -1,3 +1,5 @@ +@use './color.scss'; +@use './font.scss'; @use './markdown.scss'; @use './ant.scss'; @use './scrollbar.scss'; @@ -6,132 +8,6 @@ @import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/ubuntu/ubuntu.css'; -:root { - --color-white: #ffffff; - --color-white-soft: rgba(255, 255, 255, 0.8); - --color-white-mute: rgba(255, 255, 255, 0.94); - - --color-black: #181818; - --color-black-soft: #222222; - --color-black-mute: #333333; - - --color-gray-1: #515c67; - --color-gray-2: #414853; - --color-gray-3: #32363f; - - --color-text-1: rgba(255, 255, 245, 0.9); - --color-text-2: rgba(235, 235, 245, 0.6); - --color-text-3: rgba(235, 235, 245, 0.38); - - --color-background: var(--color-black); - --color-background-soft: var(--color-black-soft); - --color-background-mute: var(--color-black-mute); - --color-background-opacity: rgba(34, 34, 34, 0.7); - --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu - - --color-primary: #00b96b; - --color-primary-soft: #00b96b99; - --color-primary-mute: #00b96b33; - - --color-text: var(--color-text-1); - --color-icon: #ffffff99; - --color-icon-white: #ffffff; - --color-border: #ffffff19; - --color-border-soft: #ffffff10; - --color-border-mute: #ffffff05; - --color-error: #f44336; - --color-link: #338cff; - --color-code-background: #323232; - --color-hover: rgba(40, 40, 40, 1); - --color-active: rgba(55, 55, 55, 1); - --color-frame-border: #333; - --color-group-background: var(--color-background-soft); - - --color-reference: #404040; - --color-reference-text: #ffffff; - --color-reference-background: #0b0e12; - - --navbar-background-mac: rgba(20, 20, 20, 0.55); - --navbar-background: #1f1f1f; - - --navbar-height: 40px; - --sidebar-width: 50px; - --status-bar-height: 40px; - --input-bar-height: 100px; - - --assistants-width: 275px; - --topic-list-width: 275px; - --settings-width: 250px; - - --chat-background: #111111; - --chat-background-user: #28b561; - --chat-background-assistant: #2c2c2c; - --chat-text-user: var(--color-black); - - --list-item-border-radius: 16px; -} - -body { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -body[theme-mode='light'] { - --color-white: #ffffff; - --color-white-soft: rgba(0, 0, 0, 0.04); - --color-white-mute: #eee; - - --color-black: #1b1b1f; - --color-black-soft: #262626; - --color-black-mute: #363636; - - --color-gray-1: #8e8e93; - --color-gray-2: #aeaeb2; - --color-gray-3: #c7c7cc; - - --color-text-1: rgba(0, 0, 0, 1); - --color-text-2: rgba(0, 0, 0, 0.6); - --color-text-3: rgba(0, 0, 0, 0.38); - - --color-background: var(--color-white); - --color-background-soft: var(--color-white-soft); - --color-background-mute: var(--color-white-mute); - --color-background-opacity: rgba(235, 235, 235, 0.7); - --inner-glow-opacity: 0.1; - - --color-primary: #00b96b; - --color-primary-soft: #00b96b99; - --color-primary-mute: #00b96b33; - - --color-text: var(--color-text-1); - --color-icon: #00000099; - --color-icon-white: #000000; - --color-border: #00000019; - --color-border-soft: #00000010; - --color-border-mute: #00000005; - --color-error: #f44336; - --color-link: #1677ff; - --color-code-background: #e3e3e3; - --color-hover: var(--color-white-mute); - --color-active: var(--color-white-soft); - --color-frame-border: #ddd; - --color-group-background: var(--color-white); - - --color-reference: #cfe1ff; - --color-reference-text: #000000; - --color-reference-background: #f1f7ff; - - --navbar-background-mac: rgba(255, 255, 255, 0.55); - --navbar-background: rgba(244, 244, 244); - - --chat-background: #f3f3f3; - --chat-background-user: #95ec69; - --chat-background-assistant: #ffffff; - --chat-text-user: var(--color-text); -} - *, *::before, *::after { @@ -148,8 +24,18 @@ body[theme-mode='light'] { -webkit-tap-highlight-color: transparent; } -ul { - list-style: none; +html, +body, +#root { + height: 100%; + width: 100%; + margin: 0; +} + +#root { + display: flex; + flex-direction: row; + flex: 1; } body { @@ -159,13 +45,17 @@ body { font-size: 14px; line-height: 1.6; overflow: hidden; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-family: var(--font-family); text-rendering: optimizeLegibility; + transition: background-color 0.3s linear; + -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - transition: background-color 0.3s linear; + + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } input, @@ -186,20 +76,8 @@ a { -webkit-user-drag: none; } -html, -body, -#root { - height: 100%; - width: 100%; - margin: 0; -} - -#root { - width: 100%; - height: 100%; - display: flex; - flex-direction: row; - flex: 1; +ul { + list-style: none; } .loader { @@ -226,6 +104,7 @@ body, display: -webkit-box !important; -webkit-line-clamp: 1; -webkit-box-orient: vertical; + line-clamp: 1; overflow: hidden; text-overflow: ellipsis; white-space: normal; @@ -268,11 +147,16 @@ body, background-color: var(--color-white-soft); } } + .group-grid-container.horizontal, + .group-grid-container.grid { + .message-content-container-assistant { + padding: 0; + } + } .group-message-wrapper { background-color: var(--color-background); .message-content-container { width: 100%; - border: 1px solid var(--color-background-mute); } } .group-menu-bar { @@ -286,3 +170,12 @@ body, .lucide { color: var(--color-icon); } + +span.highlight { + background-color: var(--color-background-highlight); + color: var(--color-highlight); +} + +span.highlight.selected { + background-color: var(--color-background-highlight-accent); +} diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index e24569b0f2..569fa9f1dd 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -20,10 +20,8 @@ h5, h6 { margin: 1em 0 1em 0; - font-weight: 800; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-weight: bold; + font-family: var(--font-family); } h1 { @@ -117,7 +115,7 @@ } code { - font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-family: var(--code-font-family); } pre { @@ -125,7 +123,9 @@ overflow-x: auto; font-family: 'Fira Code', 'Courier New', Courier, monospace; background-color: var(--color-background-mute); - &:has(> .mermaid) { + &:has(.mermaid), + &:has(.plantuml-preview), + &:has(.svg-preview) { background-color: transparent; } &:not(pre pre) { @@ -153,7 +153,7 @@ padding-left: 1em; color: var(--color-text-light); border-left: 4px solid var(--color-border); - font-family: Georgia, 'Times New Roman', Times, serif; + font-family: var(--font-family); } table { @@ -171,9 +171,7 @@ th { background-color: var(--color-background-mute); font-weight: bold; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-family: var(--font-family); } img { @@ -301,6 +299,35 @@ emoji-picker { overflow-x: auto; overflow-y: hidden; } + mjx-container { overflow-x: auto; } + +/* CodeMirror 相关样式 */ +.cm-editor { + border-radius: 5px; + + &.cm-focused { + outline: none; + } + + .cm-scroller { + font-family: var(--code-font-family); + border-radius: 5px; + + .cm-gutters { + line-height: 1.6; + } + + .cm-content { + line-height: 1.6; + padding-left: 0.25em; + } + + .cm-lineWrapping * { + word-wrap: break-word; + white-space: pre-wrap; + } + } +} diff --git a/src/renderer/src/assets/styles/scrollbar.scss b/src/renderer/src/assets/styles/scrollbar.scss index f25039f556..818c082b7e 100644 --- a/src/renderer/src/assets/styles/scrollbar.scss +++ b/src/renderer/src/assets/styles/scrollbar.scss @@ -1,15 +1,11 @@ :root { --color-scrollbar-thumb: rgba(255, 255, 255, 0.15); --color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2); - --color-scrollbar-thumb-right: rgba(255, 255, 255, 0.18); - --color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.25); } body[theme-mode='light'] { --color-scrollbar-thumb: rgba(0, 0, 0, 0.15); --color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2); - --color-scrollbar-thumb-right: rgba(0, 0, 0, 0.18); - --color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.25); } /* 全局初始化滚动条样式 */ @@ -18,7 +14,8 @@ body[theme-mode='light'] { height: 6px; } -::-webkit-scrollbar-track { +::-webkit-scrollbar-track, +::-webkit-scrollbar-corner { background: transparent; } @@ -30,7 +27,7 @@ body[theme-mode='light'] { } } -pre::-webkit-scrollbar-thumb { +pre:not(.shiki)::-webkit-scrollbar-thumb { border-radius: 0; background: rgba(0, 0, 0, 0.08); &:hover { diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss new file mode 100644 index 0000000000..dfbb6bbd59 --- /dev/null +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -0,0 +1,26 @@ +@use './font.scss'; + +html { + font-family: var(--font-family); +} + +:root { + --color-selection-toolbar-background: rgba(20, 20, 20, 0.95); + --color-selection-toolbar-border: rgba(55, 55, 55, 0.5); + --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); + + --color-selection-toolbar-text: rgba(255, 255, 245, 0.9); + --color-selection-toolbar-hover-bg: #222222; + + --color-primary: #00b96b; + --color-error: #f44336; +} + +[theme-mode='light'] { + --color-selection-toolbar-background: rgba(245, 245, 245, 0.95); + --color-selection-toolbar-border: rgba(200, 200, 200, 0.5); + --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); + + --color-selection-toolbar-text: rgba(0, 0, 0, 1); + --color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04); +} diff --git a/src/renderer/src/components/Alert/OpenAIAlert.tsx b/src/renderer/src/components/Alert/OpenAIAlert.tsx new file mode 100644 index 0000000000..455ab62987 --- /dev/null +++ b/src/renderer/src/components/Alert/OpenAIAlert.tsx @@ -0,0 +1,32 @@ +import { Alert } from 'antd' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const LOCALSTORAGE_KEY = 'openai_alert_closed' + +const OpenAIAlert = () => { + const { t } = useTranslation() + const [visible, setVisible] = useState(false) + + useEffect(() => { + const closed = localStorage.getItem(LOCALSTORAGE_KEY) + setVisible(!closed) + }, []) + + if (!visible) return null + + return ( + { + localStorage.setItem(LOCALSTORAGE_KEY, '1') + setVisible(false) + }} + type="warning" + /> + ) +} + +export default OpenAIAlert diff --git a/src/renderer/src/components/Avatar/EmojiAvatar.tsx b/src/renderer/src/components/Avatar/EmojiAvatar.tsx new file mode 100644 index 0000000000..553869698a --- /dev/null +++ b/src/renderer/src/components/Avatar/EmojiAvatar.tsx @@ -0,0 +1,52 @@ +import React, { memo } from 'react' +import styled from 'styled-components' + +interface EmojiAvatarProps { + children: string + size?: number + fontSize?: number + onClick?: React.MouseEventHandler + className?: string + style?: React.CSSProperties +} + +const EmojiAvatar = ({ + ref, + children, + size = 31, + fontSize, + onClick, + className, + style +}: EmojiAvatarProps & { ref?: React.RefObject }) => ( + + {children} + +) + +EmojiAvatar.displayName = 'EmojiAvatar' + +const StyledEmojiAvatar = styled.div<{ $size: number; $fontSize: number }>` + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-background-soft); + border: 0.5px solid var(--color-border); + border-radius: 20%; + cursor: pointer; + width: ${(props) => props.$size}px; + height: ${(props) => props.$size}px; + font-size: ${(props) => props.$fontSize}px; + transition: opacity 0.3s ease; + &:hover { + opacity: 0.8; + } +` + +export default memo(EmojiAvatar) diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx new file mode 100644 index 0000000000..c0ebcd9a7e --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -0,0 +1,303 @@ +import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { uuid } from '@renderer/utils' +import { getReactStyleFromToken } from '@renderer/utils/shiki' +import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ThemedToken } from 'shiki/core' +import styled from 'styled-components' + +interface CodePreviewProps { + children: string + language: string + setTools?: (value: React.SetStateAction) => void +} + +/** + * Shiki 流式代码高亮组件 + * + * - 通过 shiki tokenizer 处理流式响应 + * - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过 + */ +const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { + const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() + const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle() + const [isExpanded, setIsExpanded] = useState(!codeCollapsible) + const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) + const [tokenLines, setTokenLines] = useState([]) + const codeContentRef = useRef(null) + const prevCodeLengthRef = useRef(0) + const safeCodeStringRef = useRef(children) + const highlightQueueRef = useRef>(Promise.resolve()) + const callerId = useRef(`${Date.now()}-${uuid()}`).current + const shikiThemeRef = useRef(activeShikiTheme) + + const { t } = useTranslation() + + const { registerTool, removeTool } = useCodeTool(setTools) + + // 展开/折叠工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.expand, + icon: isExpanded ? : , + tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), + visible: () => { + const scrollHeight = codeContentRef.current?.scrollHeight + return codeCollapsible && (scrollHeight ?? 0) > 350 + }, + onClick: () => setIsExpanded((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.expand.id) + }, [codeCollapsible, isExpanded, registerTool, removeTool, t]) + + // 自动换行工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.wrap, + icon: isUnwrapped ? : , + tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), + visible: () => codeWrappable, + onClick: () => setIsUnwrapped((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.wrap.id) + }, [codeWrappable, isUnwrapped, registerTool, removeTool, t]) + + // 更新展开状态 + useEffect(() => { + setIsExpanded(!codeCollapsible) + }, [codeCollapsible]) + + // 更新换行状态 + useEffect(() => { + setIsUnwrapped(!codeWrappable) + }, [codeWrappable]) + + // 处理尾部空白字符 + const safeCodeString = useMemo(() => { + return typeof children === 'string' ? children.trimEnd() : '' + }, [children]) + + const highlightCode = useCallback(async () => { + if (!safeCodeString) return + + if (prevCodeLengthRef.current === safeCodeString.length) return + + // 捕获当前状态 + const startPos = prevCodeLengthRef.current + const endPos = safeCodeString.length + + // 添加到处理队列,确保按顺序处理 + highlightQueueRef.current = highlightQueueRef.current.then(async () => { + // FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮 + if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) { + cleanupTokenizers(callerId) + prevCodeLengthRef.current = 0 + safeCodeStringRef.current = '' + + const result = await highlightCodeChunk(safeCodeString, language, callerId) + setTokenLines(result.lines) + + prevCodeLengthRef.current = safeCodeString.length + safeCodeStringRef.current = safeCodeString + + return + } + + // 跳过 race condition,延迟到后续任务 + if (prevCodeLengthRef.current !== startPos) { + return + } + + const incrementalCode = safeCodeString.slice(startPos, endPos) + const result = await highlightCodeChunk(incrementalCode, language, callerId) + setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines]) + prevCodeLengthRef.current = endPos + safeCodeStringRef.current = safeCodeString + }) + }, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString]) + + // 主题变化时强制重新高亮 + useEffect(() => { + if (shikiThemeRef.current !== activeShikiTheme) { + prevCodeLengthRef.current++ + shikiThemeRef.current = activeShikiTheme + } + }, [activeShikiTheme]) + + // 组件卸载时清理资源 + useEffect(() => { + return () => cleanupTokenizers(callerId) + }, [callerId, cleanupTokenizers]) + + // 处理第二次开始的代码高亮 + useEffect(() => { + if (prevCodeLengthRef.current > 0) { + setTimeout(highlightCode, 0) + } + }, [highlightCode]) + + // 视口检测逻辑,只处理第一次代码高亮 + useEffect(() => { + const codeElement = codeContentRef.current + if (!codeElement || prevCodeLengthRef.current > 0) return + + let isMounted = true + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && isMounted) { + setTimeout(highlightCode, 0) + observer.disconnect() + } + }) + + observer.observe(codeElement) + + return () => { + isMounted = false + observer.disconnect() + } + }, [highlightCode]) + + const hasHighlightedCode = useMemo(() => { + return tokenLines.length > 0 + }, [tokenLines.length]) + + return ( + + {hasHighlightedCode ? ( + + ) : ( + {children} + )} + + ) +} + +/** + * 渲染 Shiki 高亮后的 tokens + * + * 独立出来,方便将来做 virtual list + */ +const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo( + ({ language, tokenLines }) => { + const { getShikiPreProperties } = useCodeStyle() + const rendererRef = useRef(null) + + // 设置 pre 标签属性 + useEffect(() => { + getShikiPreProperties(language).then((properties) => { + const pre = rendererRef.current + if (pre) { + pre.className = properties.class + pre.style.cssText = properties.style + pre.tabIndex = properties.tabindex + } + }) + }, [language, getShikiPreProperties]) + + return ( +
+        
+          {tokenLines.map((lineTokens, lineIndex) => (
+            
+              {lineTokens.map((token, tokenIndex) => (
+                
+                  {token.content}
+                
+              ))}
+            
+          ))}
+        
+      
+ ) + } +) + +const ContentContainer = styled.div<{ + $lineNumbers: boolean + $wrap: boolean + $fadeIn: boolean +}>` + position: relative; + overflow: auto; + border: 0.5px solid transparent; + border-radius: 5px; + margin-top: 0; + + .shiki { + padding: 1em; + + code { + display: flex; + flex-direction: column; + + .line { + display: block; + min-height: 1.3rem; + padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')}; + + * { + overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; + white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; + } + } + } + } + + ${(props) => + props.$lineNumbers && + ` + code { + counter-reset: step; + counter-increment: step 0; + position: relative; + } + + code .line::before { + content: counter(step); + counter-increment: step; + width: 1rem; + position: absolute; + left: 0; + text-align: right; + opacity: 0.35; + } + `} + + @keyframes contentFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')}; +` + +const CodePlaceholder = styled.div` + display: block; + opacity: 0.1; + white-space: pre-wrap; + word-break: break-all; + overflow-x: hidden; + min-height: 1.3rem; +` + +CodePreview.displayName = 'CodePreview' + +export default memo(CodePreview) diff --git a/src/renderer/src/pages/home/Markdown/Artifacts.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx similarity index 80% rename from src/renderer/src/pages/home/Markdown/Artifacts.tsx rename to src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx index 746eb170c5..0dbb0aabb2 100644 --- a/src/renderer/src/pages/home/Markdown/Artifacts.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx @@ -1,4 +1,4 @@ -import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons' +import { ExpandOutlined, LinkOutlined } from '@ant-design/icons' import { AppLogo } from '@renderer/config/env' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { extractTitle } from '@renderer/utils/formats' @@ -13,7 +13,6 @@ interface Props { const Artifacts: FC = ({ html }) => { const { t } = useTranslation() - const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') const { openMinapp } = useMinappPopup() /** @@ -23,6 +22,7 @@ const Artifacts: FC = ({ html }) => { const path = await window.api.file.create('artifacts-preview.html') await window.api.file.write(path, html) const filePath = `file://${path}` + const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') openMinapp({ id: 'artifacts-preview', name: title, @@ -46,13 +46,6 @@ const Artifacts: FC = ({ html }) => { } } - /** - * 下载文件 - */ - const onDownload = () => { - window.api.file.save(`${title}.html`, html) - } - return ( - - ) } diff --git a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx new file mode 100644 index 0000000000..0928df8d68 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx @@ -0,0 +1,121 @@ +import { nanoid } from '@reduxjs/toolkit' +import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' +import { useMermaid } from '@renderer/hooks/useMermaid' +import { Flex, Spin } from 'antd' +import { debounce } from 'lodash' +import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import styled from 'styled-components' + +interface Props { + children: string + setTools?: (value: React.SetStateAction) => void +} + +/** 预览 Mermaid 图表 + * 通过防抖渲染提供比较统一的体验,减少闪烁。 + * FIXME: 等将来容易判断代码块结束位置时再重构。 + */ +const MermaidPreview: React.FC = ({ children, setTools }) => { + const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid() + const mermaidRef = useRef(null) + const diagramId = useRef(`mermaid-${nanoid(6)}`).current + const [error, setError] = useState(null) + const [isRendering, setIsRendering] = useState(false) + + // 使用通用图像工具 + const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, { + imgSelector: 'svg', + prefix: 'mermaid', + enableWheelZoom: true + }) + + // 使用工具栏 + usePreviewTools({ + setTools, + handleZoom, + handleCopyImage, + handleDownload + }) + + // 实际的渲染函数 + const renderMermaid = useCallback( + async (content: string) => { + if (!content || !mermaidRef.current) return + + try { + setIsRendering(true) + + // 验证语法,提前抛出异常 + await mermaid.parse(content) + + const { svg } = await mermaid.render(diagramId, content, mermaidRef.current) + + // 避免不可见时产生 undefined 和 NaN + const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)') + mermaidRef.current.innerHTML = fixedSvg + + // 渲染成功,清除错误记录 + setError(null) + } catch (error) { + setError((error as Error).message) + } finally { + setIsRendering(false) + } + }, + [diagramId, mermaid] + ) + + // debounce 渲染 + const debouncedRender = useMemo( + () => + debounce((content: string) => { + startTransition(() => renderMermaid(content)) + }, 300), + [renderMermaid] + ) + + // 触发渲染 + useEffect(() => { + if (isLoadingMermaid) return + + if (children) { + setIsRendering(true) + debouncedRender(children) + } else { + debouncedRender.cancel() + setIsRendering(false) + } + + return () => { + debouncedRender.cancel() + } + }, [children, isLoadingMermaid, debouncedRender]) + + const isLoading = isLoadingMermaid || isRendering + + return ( + }> + + {(mermaidError || error) && {mermaidError || error}} + + + + ) +} + +const StyledMermaid = styled.div` + overflow: auto; +` + +const StyledError = styled.div` + overflow: auto; + padding: 16px; + color: #ff4d4f; + border: 1px solid #ff4d4f; + border-radius: 4px; + word-wrap: break-word; + white-space: pre-wrap; +` + +export default memo(MermaidPreview) diff --git a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx new file mode 100644 index 0000000000..35ef90e12e --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx @@ -0,0 +1,195 @@ +import { LoadingOutlined } from '@ant-design/icons' +import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { Spin } from 'antd' +import pako from 'pako' +import React, { memo, useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const PlantUMLServer = 'https://www.plantuml.com/plantuml' +function encode64(data: Uint8Array) { + let r = '' + for (let i = 0; i < data.length; i += 3) { + if (i + 2 === data.length) { + r += append3bytes(data[i], data[i + 1], 0) + } else if (i + 1 === data.length) { + r += append3bytes(data[i], 0, 0) + } else { + r += append3bytes(data[i], data[i + 1], data[i + 2]) + } + } + return r +} + +function encode6bit(b: number) { + if (b < 10) { + return String.fromCharCode(48 + b) + } + b -= 10 + if (b < 26) { + return String.fromCharCode(65 + b) + } + b -= 26 + if (b < 26) { + return String.fromCharCode(97 + b) + } + b -= 26 + if (b === 0) { + return '-' + } + if (b === 1) { + return '_' + } + return '?' +} + +function append3bytes(b1: number, b2: number, b3: number) { + const c1 = b1 >> 2 + const c2 = ((b1 & 0x3) << 4) | (b2 >> 4) + const c3 = ((b2 & 0xf) << 2) | (b3 >> 6) + const c4 = b3 & 0x3f + let r = '' + r += encode6bit(c1 & 0x3f) + r += encode6bit(c2 & 0x3f) + r += encode6bit(c3 & 0x3f) + r += encode6bit(c4 & 0x3f) + return r +} +/** + * https://plantuml.com/zh/code-javascript-synchronous + * To use PlantUML image generation, a text diagram description have to be : + 1. Encoded in UTF-8 + 2. Compressed using Deflate algorithm + 3. Reencoded in ASCII using a transformation _close_ to base64 + */ +function encodeDiagram(diagram: string): string { + const utf8text = new TextEncoder().encode(diagram) + const compressed = pako.deflateRaw(utf8text) + return encode64(compressed) +} + +async function downloadUrl(url: string, filename: string) { + const response = await fetch(url) + if (!response.ok) { + window.message.warning({ content: response.statusText, duration: 1.5 }) + return + } + const blob = await response.blob() + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) +} + +type PlantUMLServerImageProps = { + format: 'png' | 'svg' + diagram: string + onClick?: React.MouseEventHandler + className?: string +} + +function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) { + const encodedDiagram = encodeDiagram(diagram) + if (isDark) { + return `${PlantUMLServer}/d${format}/${encodedDiagram}` + } + return `${PlantUMLServer}/${format}/${encodedDiagram}` +} + +const PlantUMLServerImage: React.FC = ({ format, diagram, onClick, className }) => { + const [loading, setLoading] = useState(true) + // FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景 + const url = getPlantUMLImageUrl(format, diagram, false) + return ( + + + }> + { + setLoading(false) + }} + onError={(e) => { + setLoading(false) + const target = e.target as HTMLImageElement + target.style.opacity = '0.5' + target.style.filter = 'blur(2px)' + }} + /> + + + ) +} + +interface PlantUMLProps { + children: string + setTools?: (value: React.SetStateAction) => void +} + +const PlantUmlPreview: React.FC = ({ children, setTools }) => { + const { t } = useTranslation() + const containerRef = useRef(null) + + const encodedDiagram = encodeDiagram(children) + + // 自定义 PlantUML 下载方法 + const customDownload = useCallback( + (format: 'svg' | 'png') => { + const timestamp = Date.now() + const url = `${PlantUMLServer}/${format}/${encodedDiagram}` + const filename = `plantuml-diagram-${timestamp}.${format}` + downloadUrl(url, filename).catch(() => { + window.message.error(t('code_block.download.failed.network')) + }) + }, + [encodedDiagram, t] + ) + + // 使用通用图像工具,提供自定义下载方法 + const { handleZoom, handleCopyImage } = usePreviewToolHandlers(containerRef, { + imgSelector: '.plantuml-preview img', + prefix: 'plantuml-diagram', + enableWheelZoom: true, + customDownloader: customDownload + }) + + // 使用工具栏 + usePreviewTools({ + setTools, + handleZoom, + handleCopyImage, + handleDownload: customDownload + }) + + return ( +
+ +
+ ) +} + +const StyledPlantUML = styled.div` + max-height: calc(80vh - 100px); + text-align: left; + overflow-y: auto; + background-color: white; + img { + max-width: 100%; + height: auto; + min-height: 100px; + transition: transform 0.2s ease; + } +` + +export default memo(PlantUmlPreview) diff --git a/src/renderer/src/components/CodeBlockView/StatusBar.tsx b/src/renderer/src/components/CodeBlockView/StatusBar.tsx new file mode 100644 index 0000000000..7e4c5e9e04 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/StatusBar.tsx @@ -0,0 +1,22 @@ +import { FC, memo } from 'react' +import styled from 'styled-components' + +interface Props { + children: string +} + +const StatusBar: FC = ({ children }) => { + return {children} +} + +const Container = styled.div` + margin: 10px; + display: flex; + flex-direction: row; + gap: 8px; + padding-bottom: 10px; + overflow-y: auto; + text-wrap: wrap; +` + +export default memo(StatusBar) diff --git a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx new file mode 100644 index 0000000000..9180aef297 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx @@ -0,0 +1,64 @@ +import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { memo, useEffect, useRef } from 'react' + +interface Props { + children: string + setTools?: (value: React.SetStateAction) => void +} + +/** + * 使用 Shadow DOM 渲染 SVG + */ +const SvgPreview: React.FC = ({ children, setTools }) => { + const svgContainerRef = useRef(null) + + useEffect(() => { + const container = svgContainerRef.current + if (!container) return + + const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' }) + + // 添加基础样式 + const style = document.createElement('style') + style.textContent = ` + :host { + padding: 1em; + background-color: white; + overflow: auto; + border: 0.5px solid var(--color-code-background); + border-top-left-radius: 0; + border-top-right-radius: 0; + display: block; + } + svg { + max-width: 100%; + height: auto; + } + ` + + // 清空并重新添加内容 + shadowRoot.innerHTML = '' + shadowRoot.appendChild(style) + + const svgContainer = document.createElement('div') + svgContainer.innerHTML = children + shadowRoot.appendChild(svgContainer) + }, [children]) + + // 使用通用图像工具 + const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, { + imgSelector: 'svg', + prefix: 'svg-image' + }) + + // 使用工具栏 + usePreviewTools({ + setTools, + handleCopyImage, + handleDownload + }) + + return
+} + +export default memo(SvgPreview) diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx new file mode 100644 index 0000000000..944e6f66e3 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -0,0 +1,295 @@ +import { LoadingOutlined } from '@ant-design/icons' +import CodeEditor from '@renderer/components/CodeEditor' +import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' +import { useSettings } from '@renderer/hooks/useSettings' +import { pyodideService } from '@renderer/services/PyodideService' +import { extractTitle } from '@renderer/utils/formats' +import { isValidPlantUML } from '@renderer/utils/markdown' +import dayjs from 'dayjs' +import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react' +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import CodePreview from './CodePreview' +import HtmlArtifacts from './HtmlArtifacts' +import MermaidPreview from './MermaidPreview' +import PlantUmlPreview from './PlantUmlPreview' +import StatusBar from './StatusBar' +import SvgPreview from './SvgPreview' + +type ViewMode = 'source' | 'special' | 'split' + +interface Props { + children: string + language: string + onSave?: (newContent: string) => void +} + +/** + * 代码块视图 + * + * 视图类型: + * - preview: 预览视图,其中非源代码的是特殊视图 + * - edit: 编辑视图 + * + * 视图模式: + * - source: 源代码视图模式 + * - special: 特殊视图模式(Mermaid、PlantUML、SVG) + * - split: 分屏模式(源代码和特殊视图并排显示) + * + * 顶部 sticky 工具栏: + * - quick 工具 + * - core 工具 + */ +const CodeBlockView: React.FC = ({ children, language, onSave }) => { + const { t } = useTranslation() + const { codeEditor, codeExecution } = useSettings() + const [viewMode, setViewMode] = useState('special') + const [isRunning, setIsRunning] = useState(false) + const [output, setOutput] = useState('') + + const [tools, setTools] = useState([]) + const { registerTool, removeTool } = useCodeTool(setTools) + + const isExecutable = useMemo(() => { + return codeExecution.enabled && language === 'python' + }, [codeExecution.enabled, language]) + + const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language]) + + const isInSpecialView = useMemo(() => { + return hasSpecialView && viewMode === 'special' + }, [hasSpecialView, viewMode]) + + const handleCopySource = useCallback(() => { + navigator.clipboard.writeText(children) + window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' }) + }, [children, t]) + + const handleDownloadSource = useCallback(() => { + let fileName = '' + + // 尝试提取标题 + if (language === 'html' && children.includes('')) { + const title = extractTitle(children) + if (title) { + fileName = `${title}.html` + } + } + + // 默认使用日期格式命名 + if (!fileName) { + fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}` + } + + window.api.file.save(fileName, children) + }, [children, language]) + + const handleRunScript = useCallback(() => { + setIsRunning(true) + setOutput('') + + pyodideService + .runScript(children, {}, codeExecution.timeoutMinutes * 60000) + .then((formattedOutput) => { + setOutput(formattedOutput) + }) + .catch((error) => { + console.error('Unexpected error:', error) + setOutput(`Unexpected error: ${error.message || 'Unknown error'}`) + }) + .finally(() => { + setIsRunning(false) + }) + }, [children, codeExecution.timeoutMinutes]) + + useEffect(() => { + // 复制按钮 + registerTool({ + ...TOOL_SPECS.copy, + icon: , + tooltip: t('code_block.copy.source'), + onClick: handleCopySource + }) + + // 下载按钮 + registerTool({ + ...TOOL_SPECS.download, + icon: , + tooltip: t('code_block.download.source'), + onClick: handleDownloadSource + }) + return () => { + removeTool(TOOL_SPECS.copy.id) + removeTool(TOOL_SPECS.download.id) + } + }, [handleCopySource, handleDownloadSource, registerTool, removeTool, t]) + + // 特殊视图的编辑按钮,在分屏模式下不可用 + useEffect(() => { + if (!hasSpecialView || viewMode === 'split') return + + const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source'] + + if (codeEditor.enabled) { + registerTool({ + ...viewSourceToolSpec, + icon: viewMode === 'source' ? : , + tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.edit'), + onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source') + }) + } else { + registerTool({ + ...viewSourceToolSpec, + icon: viewMode === 'source' ? : , + tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.preview.source'), + onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source') + }) + } + + return () => removeTool(viewSourceToolSpec.id) + }, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t]) + + // 特殊视图的分屏按钮 + useEffect(() => { + if (!hasSpecialView) return + + registerTool({ + ...TOOL_SPECS['split-view'], + icon: viewMode === 'split' ? : , + tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split'), + onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split') + }) + + return () => removeTool(TOOL_SPECS['split-view'].id) + }, [hasSpecialView, viewMode, registerTool, removeTool, t]) + + // 运行按钮 + useEffect(() => { + if (!isExecutable) return + + registerTool({ + ...TOOL_SPECS.run, + icon: isRunning ? : , + tooltip: t('code_block.run'), + onClick: () => !isRunning && handleRunScript() + }) + + return () => isExecutable && removeTool(TOOL_SPECS.run.id) + }, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t]) + + // 源代码视图组件 + const sourceView = useMemo(() => { + if (codeEditor.enabled) { + return ( + + ) + } else { + return ( + + {children} + + ) + } + }, [children, codeEditor.enabled, language, onSave, setTools]) + + // 特殊视图组件映射 + const specialView = useMemo(() => { + if (language === 'mermaid') { + return {children} + } else if (language === 'plantuml' && isValidPlantUML(children)) { + return {children} + } else if (language === 'svg') { + return {children} + } + return null + }, [children, language]) + + const renderHeader = useMemo(() => { + const langTag = '<' + language.toUpperCase() + '>' + return {isInSpecialView ? '' : langTag} + }, [isInSpecialView, language]) + + // 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图 + const renderContent = useMemo(() => { + const showSpecialView = specialView && ['special', 'split'].includes(viewMode) + const showSourceView = !specialView || viewMode !== 'special' + + return ( + + {showSpecialView && specialView} + {showSourceView && sourceView} + + ) + }, [specialView, sourceView, viewMode]) + + const renderArtifacts = useMemo(() => { + if (language === 'html') { + return + } + return null + }, [children, language]) + + return ( + + {renderHeader} + + {renderContent} + {renderArtifacts} + {isExecutable && output && {output}} + + ) +} + +const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` + position: relative; + width: 100%; + + .code-toolbar { + background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; + border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')}; + opacity: 0; + transition: opacity 0.2s ease; + transform: translateZ(0); + will-change: opacity; + &.show { + opacity: 1; + } + } + &:hover { + .code-toolbar { + opacity: 1; + } + } +` + +const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` + display: flex; + align-items: center; + color: var(--color-text); + font-size: 14px; + font-weight: bold; + padding: 0 10px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')}; + height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')}; +` + +const SplitViewWrapper = styled.div` + display: flex; + + > * { + flex: 1 1 auto; + width: 100%; + } +` + +export default memo(CodeBlockView) diff --git a/src/renderer/src/components/CodeEditor/hook.ts b/src/renderer/src/components/CodeEditor/hook.ts new file mode 100644 index 0000000000..c5bbab2d0d --- /dev/null +++ b/src/renderer/src/components/CodeEditor/hook.ts @@ -0,0 +1,65 @@ +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { Extension } from '@uiw/react-codemirror' +import { useEffect, useState } from 'react' + +let linterPromise: Promise | null = null +function importLintPackage() { + if (!linterPromise) { + linterPromise = import('@codemirror/lint').then((mod) => mod.linter) + } + return linterPromise +} + +// 语言对应的 linter 加载器 +const linterLoaders: Record Promise> = { + json: async () => { + const [linter, jsonParseLinter] = await Promise.all([ + importLintPackage(), + import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter) + ]) + return linter(jsonParseLinter()) + } +} + +export const useLanguageExtensions = (language: string, lint?: boolean) => { + const { languageMap } = useCodeStyle() + const [extensions, setExtensions] = useState([]) + + // 加载语言 + useEffect(() => { + let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + + // 如果语言名包含 `-`,转换为驼峰命名法 + if (normalizedLang.includes('-')) { + normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) + } + + import('@uiw/codemirror-extensions-langs') + .then(({ loadLanguage }) => { + const extension = loadLanguage(normalizedLang as any) + if (extension) { + setExtensions((prev) => [...prev, extension]) + } + }) + .catch((error) => { + console.debug(`Failed to load language: ${normalizedLang}`, error) + }) + }, [language, languageMap]) + + useEffect(() => { + if (!lint) return + + const loader = linterLoaders[language] + if (loader) { + loader() + .then((extension) => { + setExtensions((prev) => [...prev, extension]) + }) + .catch((error) => { + console.error(`Failed to load linter for ${language}`, error) + }) + } + }, [language, lint]) + + return extensions +} diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx new file mode 100644 index 0000000000..c10fc5e8ab --- /dev/null +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -0,0 +1,274 @@ +import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror' +import diff from 'fast-diff' +import { + ChevronsDownUp, + ChevronsUpDown, + Save as SaveIcon, + Text as UnWrapIcon, + WrapText as WrapIcon +} from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' + +import { useLanguageExtensions } from './hook' + +// 标记非用户编辑的变更 +const External = Annotation.define() + +interface Props { + value: string + placeholder?: string | HTMLElement + language: string + onSave?: (newContent: string) => void + onChange?: (newContent: string) => void + setTools?: (value: React.SetStateAction) => void + minHeight?: string + maxHeight?: string + /** 用于覆写编辑器的某些设置 */ + options?: { + stream?: boolean // 用于流式响应场景,默认 false + lint?: boolean + collapsible?: boolean + wrappable?: boolean + keymap?: boolean + } & BasicSetupOptions + /** 用于追加 extensions */ + extensions?: Extension[] + /** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */ + style?: React.CSSProperties +} + +/** + * 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。 + * + * 目前必须和 CodeToolbar 配合使用。 + */ +const CodeEditor = ({ + value, + placeholder, + language, + onSave, + onChange, + setTools, + minHeight, + maxHeight, + options, + extensions, + style +}: Props) => { + const { + fontSize, + codeShowLineNumbers: _lineNumbers, + codeCollapsible: _collapsible, + codeWrappable: _wrappable, + codeEditor + } = useSettings() + const collapsible = useMemo(() => options?.collapsible ?? _collapsible, [options?.collapsible, _collapsible]) + const wrappable = useMemo(() => options?.wrappable ?? _wrappable, [options?.wrappable, _wrappable]) + const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap]) + + // 合并 codeEditor 和 options 的 basicSetup,options 优先 + const customBasicSetup = useMemo(() => { + return { + lineNumbers: _lineNumbers, + ...(codeEditor as BasicSetupOptions), + ...(options as BasicSetupOptions) + } + }, [codeEditor, _lineNumbers, options]) + + const { activeCmTheme } = useCodeStyle() + const [isExpanded, setIsExpanded] = useState(!collapsible) + const [isUnwrapped, setIsUnwrapped] = useState(!wrappable) + const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? '')) + const [editorReady, setEditorReady] = useState(false) + const editorViewRef = useRef(null) + const { t } = useTranslation() + + const langExtensions = useLanguageExtensions(language, options?.lint) + + const { registerTool, removeTool } = useCodeTool(setTools) + + // 展开/折叠工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.expand, + icon: isExpanded ? : , + tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), + visible: () => { + const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight + return collapsible && (scrollHeight ?? 0) > 350 + }, + onClick: () => setIsExpanded((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.expand.id) + }, [collapsible, isExpanded, registerTool, removeTool, t, editorReady]) + + // 自动换行工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.wrap, + icon: isUnwrapped ? : , + tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), + visible: () => wrappable, + onClick: () => setIsUnwrapped((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.wrap.id) + }, [wrappable, isUnwrapped, registerTool, removeTool, t]) + + const handleSave = useCallback(() => { + const currentDoc = editorViewRef.current?.state.doc.toString() ?? '' + onSave?.(currentDoc) + }, [onSave]) + + // 保存按钮 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.save, + icon: , + tooltip: t('code_block.edit.save'), + onClick: handleSave + }) + + return () => removeTool(TOOL_SPECS.save.id) + }, [handleSave, registerTool, removeTool, t]) + + // 流式响应过程中计算 changes 来更新 EditorView + // 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理) + useEffect(() => { + if (!editorViewRef.current) return + + const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '') + const currentDoc = editorViewRef.current.state.doc.toString() + + const changes = prepareCodeChanges(currentDoc, newContent) + + if (changes && changes.length > 0) { + editorViewRef.current.dispatch({ + changes, + annotations: [External.of(true)] + }) + } + }, [options?.stream, value]) + + useEffect(() => { + setIsExpanded(!collapsible) + }, [collapsible]) + + useEffect(() => { + setIsUnwrapped(!wrappable) + }, [wrappable]) + + // 保存功能的快捷键 + const saveKeymap = useMemo(() => { + return keymap.of([ + { + key: 'Mod-s', + run: () => { + handleSave() + return true + }, + preventDefault: true + } + ]) + }, [handleSave]) + + const customExtensions = useMemo(() => { + return [ + ...(extensions ?? []), + ...langExtensions, + ...(isUnwrapped ? [] : [EditorView.lineWrapping]), + ...(enableKeymap ? [saveKeymap] : []) + ] + }, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap]) + + return ( + { + editorViewRef.current = view + setEditorReady(true) + }} + onChange={(value, viewUpdate) => { + if (onChange && viewUpdate.docChanged) onChange(value) + }} + basicSetup={{ + dropCursor: true, + allowMultipleSelections: true, + indentOnInput: true, + bracketMatching: true, + closeBrackets: true, + rectangularSelection: true, + crosshairCursor: true, + highlightActiveLineGutter: false, + highlightSelectionMatches: true, + closeBracketsKeymap: enableKeymap, + searchKeymap: enableKeymap, + foldKeymap: enableKeymap, + completionKeymap: enableKeymap, + lintKeymap: enableKeymap, + ...customBasicSetup // override basicSetup + }} + style={{ + ...style, + fontSize: `${fontSize - 1}px`, + border: '0.5px solid transparent', + marginTop: 0 + }} + /> + ) +} + +CodeEditor.displayName = 'CodeEditor' + +/** + * 使用 fast-diff 计算代码变更,再转换为 CodeMirror 的 changes。 + * 可以处理所有类型的变更,不过流式响应过程中多是插入操作。 + * @param oldCode 旧的代码内容 + * @param newCode 新的代码内容 + * @returns 用于 EditorView.dispatch 的 changes 数组 + */ +function prepareCodeChanges(oldCode: string, newCode: string) { + const diffResult = diff(oldCode, newCode) + + const changes: { from: number; to: number; insert: string }[] = [] + let offset = 0 + + // operation: 1=插入, -1=删除, 0=相等 + for (const [operation, text] of diffResult) { + if (operation === 1) { + changes.push({ + from: offset, + to: offset, + insert: text + }) + } else if (operation === -1) { + changes.push({ + from: offset, + to: offset + text.length, + insert: '' + }) + offset += text.length + } else { + offset += text.length + } + } + + return changes +} + +export default memo(CodeEditor) diff --git a/src/renderer/src/components/CodeToolbar/constants.ts b/src/renderer/src/components/CodeToolbar/constants.ts new file mode 100644 index 0000000000..4eeec0fa15 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/constants.ts @@ -0,0 +1,76 @@ +import { CodeToolSpec } from './types' + +export const TOOL_SPECS: Record = { + // Core tools + copy: { + id: 'copy', + type: 'core', + order: 11 + }, + download: { + id: 'download', + type: 'core', + order: 10 + }, + edit: { + id: 'edit', + type: 'core', + order: 12 + }, + 'view-source': { + id: 'view-source', + type: 'core', + order: 12 + }, + save: { + id: 'save', + type: 'core', + order: 13 + }, + expand: { + id: 'expand', + type: 'core', + order: 20 + }, + // Quick tools + 'split-view': { + id: 'split-view', + type: 'quick', + order: 10 + }, + run: { + id: 'run', + type: 'quick', + order: 11 + }, + wrap: { + id: 'wrap', + type: 'quick', + order: 20 + }, + 'copy-image': { + id: 'copy-image', + type: 'quick', + order: 30 + }, + 'download-svg': { + id: 'download-svg', + type: 'quick', + order: 31 + }, + 'download-png': { + id: 'download-png', + type: 'quick', + order: 32 + }, + 'zoom-in': { + id: 'zoom-in', + type: 'quick', + order: 40 + }, + 'zoom-out': { + id: 'zoom-out', + type: 'quick', + order: 41 + } +} diff --git a/src/renderer/src/components/CodeToolbar/hook.ts b/src/renderer/src/components/CodeToolbar/hook.ts new file mode 100644 index 0000000000..5b5d6b338f --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/hook.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react' + +import { CodeTool } from './types' + +export const useCodeTool = (setTools?: (value: React.SetStateAction) => void) => { + // 注册工具,如果已存在同ID工具则替换 + const registerTool = useCallback( + (tool: CodeTool) => { + setTools?.((prev) => { + const filtered = prev.filter((t) => t.id !== tool.id) + return [...filtered, tool].sort((a, b) => b.order - a.order) + }) + }, + [setTools] + ) + + // 移除工具 + const removeTool = useCallback( + (id: string) => { + setTools?.((prev) => prev.filter((tool) => tool.id !== id)) + }, + [setTools] + ) + + return { registerTool, removeTool } +} diff --git a/src/renderer/src/components/CodeToolbar/index.ts b/src/renderer/src/components/CodeToolbar/index.ts new file mode 100644 index 0000000000..96434b97e9 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/index.ts @@ -0,0 +1,5 @@ +export * from './constants' +export * from './hook' +export * from './toolbar' +export * from './types' +export * from './usePreviewTools' diff --git a/src/renderer/src/components/CodeToolbar/toolbar.tsx b/src/renderer/src/components/CodeToolbar/toolbar.tsx new file mode 100644 index 0000000000..cd615afcb4 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/toolbar.tsx @@ -0,0 +1,115 @@ +import { HStack } from '@renderer/components/Layout' +import { Tooltip } from 'antd' +import { EllipsisVertical } from 'lucide-react' +import React, { memo, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { CodeTool } from './types' + +interface CodeToolButtonProps { + tool: CodeTool +} + +const CodeToolButton: React.FC = memo(({ tool }) => { + return ( + + tool.onClick()}>{tool.icon} + + ) +}) + +export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) => { + const [showQuickTools, setShowQuickTools] = useState(false) + const { t } = useTranslation() + + // 根据条件显示工具 + const visibleTools = tools.filter((tool) => !tool.visible || tool.visible()) + + // 按类型分组 + const coreTools = visibleTools.filter((tool) => tool.type === 'core') + const quickTools = visibleTools.filter((tool) => tool.type === 'quick') + + // 点击了 more 按钮或者只有一个快捷工具时 + const quickToolButtons = useMemo(() => { + if (quickTools.length === 1 || (quickTools.length > 1 && showQuickTools)) { + return quickTools.map((tool) => ) + } + + return null + }, [quickTools, showQuickTools]) + + if (visibleTools.length === 0) { + return null + } + + return ( + + + {/* 有多个快捷工具时通过 more 按钮展示 */} + {quickToolButtons} + {quickTools.length > 1 && ( + + setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}> + + + + )} + + {/* 始终显示核心工具 */} + {coreTools.map((tool) => ( + + ))} + + + ) +}) + +const StickyWrapper = styled.div` + position: sticky; + top: 28px; + z-index: 10; +` + +const ToolbarWrapper = styled(HStack)` + position: absolute; + align-items: center; + bottom: 0.3rem; + right: 0.5rem; + height: 24px; + gap: 4px; +` + +const ToolWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; + color: var(--color-text-3); + + &:hover { + background-color: var(--color-background-soft); + .icon { + color: var(--color-text-1); + } + } + + &.active { + color: var(--color-primary); + .icon { + color: var(--color-primary); + } + } + + /* For Lucide icons */ + .icon { + width: 14px; + height: 14px; + color: var(--color-text-3); + } +` diff --git a/src/renderer/src/components/CodeToolbar/types.ts b/src/renderer/src/components/CodeToolbar/types.ts new file mode 100644 index 0000000000..d1181650fe --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/types.ts @@ -0,0 +1,25 @@ +/** + * 代码块工具基本信息 + */ +export interface CodeToolSpec { + id: string + type: 'core' | 'quick' + order: number +} + +/** + * 代码块工具定义接口 + * @param id 唯一标识符 + * @param type 工具类型 + * @param icon 按钮图标 + * @param tooltip 提示文本 + * @param condition 显示条件 + * @param onClick 点击动作 + * @param order 显示顺序,越小越靠右 + */ +export interface CodeTool extends CodeToolSpec { + icon: React.ReactNode + tooltip: string + visible?: () => boolean + onClick: () => void +} diff --git a/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx new file mode 100644 index 0000000000..c9fb904fc7 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx @@ -0,0 +1,360 @@ +import { download } from '@renderer/utils/download' +import { FileImage, ZoomIn, ZoomOut } from 'lucide-react' +import { RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons' +import { TOOL_SPECS } from './constants' +import { useCodeTool } from './hook' +import { CodeTool } from './types' + +// 预编译正则表达式用于查询位置 +const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/ + +/** + * 使用图像处理工具的自定义Hook + * 提供图像缩放、复制和下载功能 + */ +export const usePreviewToolHandlers = ( + containerRef: RefObject, + options: { + prefix: string + imgSelector: string + enableWheelZoom?: boolean + customDownloader?: (format: 'svg' | 'png') => void + } +) => { + const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态 + const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态 + const { imgSelector, prefix, customDownloader, enableWheelZoom } = options + const { t } = useTranslation() + + // 创建选择器函数 + const getImgElement = useCallback(() => { + if (!containerRef.current) return null + + // 优先尝试从 Shadow DOM 中查找 + const shadowRoot = containerRef.current.shadowRoot + if (shadowRoot) { + return shadowRoot.querySelector(imgSelector) as SVGElement | null + } + + // 降级到常规 DOM 查找 + return containerRef.current.querySelector(imgSelector) as SVGElement | null + }, [containerRef, imgSelector]) + + // 查询当前位置 + const getCurrentPosition = useCallback(() => { + const imgElement = getImgElement() + if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y } + + const transform = imgElement.style.transform + if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y } + + const match = transform.match(TRANSFORM_REGEX) + if (match && match.length >= 3) { + return { + x: parseFloat(match[1]), + y: parseFloat(match[2]) + } + } + + return { x: transformRef.current.x, y: transformRef.current.y } + }, [getImgElement]) + + // 平移缩放变换 + const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => { + if (!element) return + element.style.transformOrigin = 'top left' + element.style.transform = `translate(${x}px, ${y}px) scale(${scale})` + }, []) + + // 拖拽平移支持 + useEffect(() => { + const container = containerRef.current + if (!container) return + + let isDragging = false + const startPos = { x: 0, y: 0 } + const startOffset = { x: 0, y: 0 } + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return // 只响应左键 + + // 更新当前实际位置 + const position = getCurrentPosition() + transformRef.current.x = position.x + transformRef.current.y = position.y + + isDragging = true + startPos.x = e.clientX + startPos.y = e.clientY + startOffset.x = position.x + startOffset.y = position.y + + container.style.cursor = 'grabbing' + e.preventDefault() + } + + const onMouseMove = (e: MouseEvent) => { + if (!isDragging) return + + const dx = e.clientX - startPos.x + const dy = e.clientY - startPos.y + const newX = startOffset.x + dx + const newY = startOffset.y + dy + + const imgElement = getImgElement() + applyTransform(imgElement, newX, newY, transformRef.current.scale) + + e.preventDefault() + } + + const stopDrag = () => { + if (!isDragging) return + + // 更新位置但不立即触发状态变更 + const position = getCurrentPosition() + transformRef.current.x = position.x + transformRef.current.y = position.y + + // 只触发一次渲染以保持组件状态同步 + setRenderTrigger((prev) => prev + 1) + + isDragging = false + container.style.cursor = 'default' + } + + // 绑定到document以确保拖拽可以在鼠标离开容器后继续 + container.addEventListener('mousedown', onMouseDown) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', stopDrag) + + return () => { + container.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', stopDrag) + } + }, [containerRef, getCurrentPosition, getImgElement, applyTransform]) + + // 缩放处理函数 + const handleZoom = useCallback( + (delta: number) => { + const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta)) + transformRef.current.scale = newScale + + const imgElement = getImgElement() + applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale) + + // 触发重渲染以保持组件状态同步 + setRenderTrigger((prev) => prev + 1) + }, + [getImgElement, applyTransform] + ) + + // 滚轮缩放支持 + useEffect(() => { + if (!enableWheelZoom || !containerRef.current) return + + const container = containerRef.current + + const handleWheel = (e: WheelEvent) => { + if ((e.ctrlKey || e.metaKey) && e.target) { + // 确认事件发生在容器内部 + if (container.contains(e.target as Node)) { + const delta = e.deltaY < 0 ? 0.1 : -0.1 + handleZoom(delta) + } + } + } + + container.addEventListener('wheel', handleWheel, { passive: true }) + return () => container.removeEventListener('wheel', handleWheel) + }, [containerRef, handleZoom, enableWheelZoom]) + + // 复制图像处理函数 + const handleCopyImage = useCallback(async () => { + try { + const imgElement = getImgElement() + if (!imgElement) return + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.crossOrigin = 'anonymous' + + const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || [] + const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width + const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height + + const svgData = new XMLSerializer().serializeToString(imgElement) + const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` + + img.onload = async () => { + const scale = 3 + canvas.width = width * scale + canvas.height = height * scale + + if (ctx) { + ctx.scale(scale, scale) + ctx.drawImage(img, 0, 0, width, height) + const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')) + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + window.message.success(t('message.copy.success')) + } + } + img.src = svgBase64 + } catch (error) { + console.error('Copy failed:', error) + window.message.error(t('message.copy.failed')) + } + }, [getImgElement, t]) + + // 下载处理函数 + const handleDownload = useCallback( + (format: 'svg' | 'png') => { + // 如果有自定义下载器,使用自定义实现 + if (customDownloader) { + customDownloader(format) + return + } + + try { + const imgElement = getImgElement() + if (!imgElement) return + + const timestamp = Date.now() + + if (format === 'svg') { + const svgData = new XMLSerializer().serializeToString(imgElement) + const blob = new Blob([svgData], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + download(url, `${prefix}-${timestamp}.svg`) + URL.revokeObjectURL(url) + } else if (format === 'png') { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.crossOrigin = 'anonymous' + + const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || [] + const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width + const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height + + const svgData = new XMLSerializer().serializeToString(imgElement) + const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` + + img.onload = () => { + const scale = 3 + canvas.width = width * scale + canvas.height = height * scale + + if (ctx) { + ctx.scale(scale, scale) + ctx.drawImage(img, 0, 0, width, height) + } + + canvas.toBlob((blob) => { + if (blob) { + const pngUrl = URL.createObjectURL(blob) + download(pngUrl, `${prefix}-${timestamp}.png`) + URL.revokeObjectURL(pngUrl) + } + }, 'image/png') + } + img.src = svgBase64 + } + } catch (error) { + console.error('Download failed:', error) + } + }, + [getImgElement, prefix, customDownloader] + ) + + return { + scale: transformRef.current.scale, + handleZoom, + handleCopyImage, + handleDownload, + renderTrigger // 导出渲染触发器,万一要用 + } +} + +export interface PreviewToolsOptions { + setTools?: (value: React.SetStateAction) => void + handleZoom?: (delta: number) => void + handleCopyImage?: () => Promise + handleDownload?: (format: 'svg' | 'png') => void +} + +/** + * 提供预览组件通用工具栏功能的自定义Hook + */ +export const usePreviewTools = ({ setTools, handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => { + const { t } = useTranslation() + const { registerTool, removeTool } = useCodeTool(setTools) + + useEffect(() => { + // 根据提供的功能有选择性地注册工具 + if (handleZoom) { + // 放大工具 + registerTool({ + ...TOOL_SPECS['zoom-in'], + icon: , + tooltip: t('code_block.preview.zoom_in'), + onClick: () => handleZoom(0.1) + }) + + // 缩小工具 + registerTool({ + ...TOOL_SPECS['zoom-out'], + icon: , + tooltip: t('code_block.preview.zoom_out'), + onClick: () => handleZoom(-0.1) + }) + } + + if (handleCopyImage) { + // 复制图片工具 + registerTool({ + ...TOOL_SPECS['copy-image'], + icon: , + tooltip: t('code_block.preview.copy.image'), + onClick: handleCopyImage + }) + } + + if (handleDownload) { + // 下载 SVG 工具 + registerTool({ + ...TOOL_SPECS['download-svg'], + icon: , + tooltip: t('code_block.download.svg'), + onClick: () => handleDownload('svg') + }) + + // 下载 PNG 工具 + registerTool({ + ...TOOL_SPECS['download-png'], + icon: , + tooltip: t('code_block.download.png'), + onClick: () => handleDownload('png') + }) + } + + // 清理函数 + return () => { + if (handleZoom) { + removeTool(TOOL_SPECS['zoom-in'].id) + removeTool(TOOL_SPECS['zoom-out'].id) + } + if (handleCopyImage) { + removeTool(TOOL_SPECS['copy-image'].id) + } + if (handleDownload) { + removeTool(TOOL_SPECS['download-svg'].id) + removeTool(TOOL_SPECS['download-png'].id) + } + } + }, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t]) +} diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx new file mode 100644 index 0000000000..08a1fd415a --- /dev/null +++ b/src/renderer/src/components/ContentSearch.tsx @@ -0,0 +1,718 @@ +import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar' +import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' +import { Tooltip } from 'antd' +import { debounce } from 'lodash' +import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react' +import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const HIGHLIGHT_CLASS = 'highlight' +const HIGHLIGHT_SELECT_CLASS = 'selected' + +interface Props { + children?: React.ReactNode + searchTarget: React.RefObject | React.RefObject | HTMLElement + /** + * 过滤`node`,`node`只会是`Node.TEXT_NODE`类型的文本节点 + * + * 返回`true`表示该`node`会被搜索 + */ + filter: (node: Node) => boolean + includeUser?: boolean + onIncludeUserChange?: (value: boolean) => void +} + +enum SearchCompletedState { + NotSearched, + FirstSearched +} + +enum SearchTargetIndex { + Next, + Prev +} + +export interface ContentSearchRef { + disable(): void + enable(initialText?: string): void + // 搜索下一个并定位 + searchNext(): void + // 搜索上一个并定位 + searchPrev(): void + // 搜索并定位 + search(): void + // 搜索但不定位,或者说是更新 + silentSearch(): void + focus(): void +} + +interface MatchInfo { + index: number + length: number + text: string +} + +const escapeRegExp = (string: string): string => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +} + +const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => { + if (!elementList || elementList.length === 0) { + return null + } + let closestElementIndex: number | null = null + let minVerticalDistance = Infinity + const windowCenterY = window.innerHeight / 2 + for (let i = 0; i < elementList.length; i++) { + const element = elementList[i] + if (!(element instanceof HTMLElement)) { + continue + } + const rect = element.getBoundingClientRect() + if (rect.bottom < 0 || rect.top > window.innerHeight) { + continue + } + const elementCenterY = rect.top + rect.height / 2 + const verticalDistance = Math.abs(elementCenterY - windowCenterY) + if (verticalDistance < minVerticalDistance) { + minVerticalDistance = verticalDistance + closestElementIndex = i + } + } + return closestElementIndex +} + +const highlightText = ( + textNode: Node, + searchText: string, + highlightClass: string, + isCaseSensitive: boolean, + isWholeWord: boolean +): HTMLSpanElement[] | null => { + const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement + if (textNodeParentNode) { + if (textNodeParentNode.classList.contains(highlightClass)) { + return null + } + } + if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) { + return null + } + + const textContent = textNode.textContent + const escapedSearchText = escapeRegExp(searchText) + + // 检查搜索文本是否仅包含拉丁字母 + const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText) + + // 只有当搜索文本仅包含拉丁字母时才应用大小写敏感 + const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi' + const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText + const regex = new RegExp(regexPattern, regexFlags) + + let match + const matches: MatchInfo[] = [] + while ((match = regex.exec(textContent)) !== null) { + if (typeof match.index === 'number' && typeof match[0] === 'string') { + matches.push({ index: match.index, length: match[0].length, text: match[0] }) + } else { + console.error('Unexpected match format:', match) + } + } + + if (matches.length === 0) { + return null + } + + const parentNode = textNode.parentNode + if (!parentNode) { + return null + } + + const fragment = document.createDocumentFragment() + let currentIndex = 0 + const highlightTextSet = new Set() + + matches.forEach(({ index, length, text }) => { + if (index > currentIndex) { + fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index))) + } + const highlightSpan = document.createElement('span') + highlightSpan.className = highlightClass + highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive + fragment.appendChild(highlightSpan) + highlightTextSet.add(highlightSpan) + currentIndex = index + length + }) + + if (currentIndex < textContent.length) { + fragment.appendChild(document.createTextNode(textContent.substring(currentIndex))) + } + + parentNode.replaceChild(fragment, textNode) + return [...highlightTextSet] +} + +const mergeAdjacentTextNodes = (node: HTMLElement) => { + const children = Array.from(node.childNodes) + const groups: Array = [] + let currentTextGroup: { text: string; nodes: Node[] } | null = null + + for (const child of children) { + if (child.nodeType === Node.TEXT_NODE) { + if (currentTextGroup === null) { + currentTextGroup = { + text: child.textContent ?? '', + nodes: [child] + } + } else { + currentTextGroup.text += child.textContent + currentTextGroup.nodes.push(child) + } + } else { + if (currentTextGroup !== null) { + groups.push(currentTextGroup!) + currentTextGroup = null + } + groups.push(child) + } + } + + if (currentTextGroup !== null) { + groups.push(currentTextGroup) + } + + const newChildren = groups.map((group) => { + if (group instanceof Node) { + return group + } else { + return document.createTextNode(group.text) + } + }) + + node.replaceChildren(...newChildren) +} + +// eslint-disable-next-line @eslint-react/no-forward-ref +export const ContentSearch = React.forwardRef( + ({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => { + const target: HTMLElement | null = (() => { + if (searchTarget instanceof HTMLElement) { + return searchTarget + } else { + return (searchTarget.current as HTMLElement) ?? null + } + })() + const containerRef = React.useRef(null) + const searchInputRef = React.useRef(null) + const [searchResultIndex, setSearchResultIndex] = useState(0) + const [totalCount, setTotalCount] = useState(0) + const [enableContentSearch, setEnableContentSearch] = useState(false) + const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched) + const [isCaseSensitive, setIsCaseSensitive] = useState(false) + const [isWholeWord, setIsWholeWord] = useState(false) + const [shouldScroll, setShouldScroll] = useState(false) + const highlightTextSet = useState(new Set())[0] + const prevSearchText = useRef('') + const { t } = useTranslation() + + const locateByIndex = (index: number, shouldScroll = true) => { + if (target) { + const highlightTextNodes = [...highlightTextSet] as HTMLElement[] + highlightTextNodes.sort((a, b) => { + const { top: aTop } = a.getBoundingClientRect() + const { top: bTop } = b.getBoundingClientRect() + return aTop - bTop + }) + for (const node of highlightTextNodes) { + node.classList.remove(HIGHLIGHT_SELECT_CLASS) + } + setSearchResultIndex(index) + if (highlightTextNodes.length > 0) { + const highlightTextNode = highlightTextNodes[index] ?? null + if (highlightTextNode) { + highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS) + if (shouldScroll) { + highlightTextNode.scrollIntoView({ + behavior: 'smooth', + block: 'center' + // inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码 + }) + } + } + } + } + } + + const restoreHighlight = () => { + const highlightTextParentNodeSet = new Set() + // Make a copy because the set might be modified during iteration indirectly + const nodesToRestore = [...highlightTextSet] + for (const highlightTextNode of nodesToRestore) { + if (highlightTextNode.textContent) { + const textNode = document.createTextNode(highlightTextNode.textContent) + const node = highlightTextNode as HTMLElement + if (node.parentNode) { + highlightTextParentNodeSet.add(node.parentNode as HTMLElement) + node.replaceWith(textNode) // This removes the node from the DOM + } + } + } + highlightTextSet.clear() // Clear the original set after processing + for (const parentNode of highlightTextParentNodeSet) { + mergeAdjacentTextNodes(parentNode) + } + // highlightTextSet.clear() // Already cleared + } + + const search = (searchTargetIndex?: SearchTargetIndex): number | null => { + const searchText = searchInputRef.current?.value.trim() ?? null + if (target && searchText !== null && searchText !== '') { + restoreHighlight() + const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT) + let textNode: Node | null + const textNodeSet: Set = new Set() + while ((textNode = iter.nextNode())) { + if (filter(textNode)) { + textNodeSet.add(textNode) + } + } + + const highlightTextSetTemp = new Set() + for (const node of textNodeSet) { + const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord) + if (list) { + list.forEach((node) => highlightTextSetTemp.add(node)) + } + } + const highlightTextList = [...highlightTextSetTemp] + setTotalCount(highlightTextList.length) + highlightTextSetTemp.forEach((node) => highlightTextSet.add(node)) + const changeIndex = () => { + let index: number + switch (searchTargetIndex) { + case SearchTargetIndex.Next: + { + index = (searchResultIndex + 1) % highlightTextList.length + } + break + case SearchTargetIndex.Prev: + { + index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length + } + break + default: { + index = searchResultIndex + } + } + return Math.max(index, 0) + } + + const targetIndex = (() => { + switch (searchCompleted) { + case SearchCompletedState.NotSearched: { + setSearchCompleted(SearchCompletedState.FirstSearched) + const index = findWindowVerticalCenterElementIndex(highlightTextList) + if (index !== null) { + setSearchResultIndex(index) + return index + } else { + setSearchResultIndex(0) + return 0 + } + } + case SearchCompletedState.FirstSearched: { + return changeIndex() + } + default: { + return null + } + } + })() + + if (targetIndex === null) { + return null + } else { + const totalCount = highlightTextSet.size + if (targetIndex >= totalCount) { + return totalCount - 1 + } else { + return targetIndex + } + } + } else { + return null + } + } + + const _searchHandlerDebounce = debounce(() => { + implementation.search() + }, 300) + const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce]) + const userInputHandler = (event: React.ChangeEvent) => { + const value = event.target.value.trim() + if (value.length === 0) { + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } else { + // 用户输入时允许滚动 + setShouldScroll(true) + searchHandler() + } + prevSearchText.current = value + } + + const keyDownHandler = (event: React.KeyboardEvent) => { + const { code, key, shiftKey } = event + if (key === 'Process') { + return + } + + switch (code) { + case 'Enter': + { + if (shiftKey) { + implementation.searchPrev() + } else { + implementation.searchNext() + } + event.preventDefault() + } + break + case 'Escape': + { + implementation.disable() + } + break + } + } + + const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus()) + + const userOutlinedButtonOnClick = () => { + if (onIncludeUserChange) { + onIncludeUserChange(!includeUser) + } + searchInputFocus() + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + const implementation = { + disable() { + setEnableContentSearch(false) + restoreHighlight() + setShouldScroll(false) + }, + enable(initialText?: string) { + setEnableContentSearch(true) + setShouldScroll(false) // Default to false, search itself might set it to true + if (searchInputRef.current) { + const inputEl = searchInputRef.current + if (initialText && initialText.trim().length > 0) { + inputEl.value = initialText + // Trigger search after setting initial text + // Need to make sure search() uses the new value + // and also to focus and select + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + setShouldScroll(true) + const targetIndex = search() + if (targetIndex !== null) { + locateByIndex(targetIndex, true) // Ensure scrolling + } else { + // If search returns null (e.g., empty input or no matches with initial text), clear state + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } + }) + } else { + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + }) + // Only search if there's existing text and no new initialText + if (inputEl.value.trim()) { + const targetIndex = search() + if (targetIndex !== null) { + setSearchResultIndex(targetIndex) + // locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text + } + } + } + } + }, + searchNext() { + if (enableContentSearch) { + const targetIndex = search(SearchTargetIndex.Next) + if (targetIndex !== null) { + locateByIndex(targetIndex) + } + } + }, + searchPrev() { + if (enableContentSearch) { + const targetIndex = search(SearchTargetIndex.Prev) + if (targetIndex !== null) { + locateByIndex(targetIndex) + } + } + }, + resetSearchState() { + if (enableContentSearch) { + setSearchCompleted(SearchCompletedState.NotSearched) + // Maybe also reset index? Depends on desired behavior + // setSearchResultIndex(0); + } + }, + search() { + if (enableContentSearch) { + const targetIndex = search() + if (targetIndex !== null) { + locateByIndex(targetIndex, shouldScroll) + } else { + // If search returns null (e.g., empty input), clear state + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } + } + }, + silentSearch() { + if (enableContentSearch) { + const targetIndex = search() + if (targetIndex !== null) { + // 只更新索引,不触发滚动 + locateByIndex(targetIndex, false) + } + } + }, + focus() { + searchInputFocus() + } + } + + useImperativeHandle(ref, () => ({ + disable() { + implementation.disable() + }, + enable(initialText?: string) { + implementation.enable(initialText) + }, + searchNext() { + implementation.searchNext() + }, + searchPrev() { + implementation.searchPrev() + }, + search() { + implementation.search() + }, + silentSearch() { + implementation.silentSearch() + }, + focus() { + implementation.focus() + } + })) + + // Re-run search when options change and search is active + useEffect(() => { + if (enableContentSearch && searchInputRef.current?.value.trim()) { + implementation.search() + } + }, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency + + const prevButtonOnClick = () => { + implementation.searchPrev() + searchInputFocus() + } + + const nextButtonOnClick = () => { + implementation.searchNext() + searchInputFocus() + } + + const closeButtonOnClick = () => { + implementation.disable() + } + + const caseSensitiveButtonOnClick = () => { + setIsCaseSensitive(!isCaseSensitive) + searchInputFocus() + } + + const wholeWordButtonOnClick = () => { + setIsWholeWord(!isWholeWord) + searchInputFocus() + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {searchCompleted !== SearchCompletedState.NotSearched ? ( + totalCount > 0 ? ( + <> + {searchResultIndex + 1} + / + {totalCount} + + ) : ( + {t('common.no_results')} + ) + ) : ( + 0/0 + )} + + + + + + + + + + + + + + + + + ) + } +) + +ContentSearch.displayName = 'ContentSearch' + +const Container = styled.div` + display: flex; + flex-direction: row; + z-index: 2; +` + +const SearchBarContainer = styled.div` + border: 1px solid var(--color-primary); + border-radius: 10px; + transition: all 0.2s ease; + position: fixed; + top: 15px; + left: 20px; + right: 20px; + margin-bottom: 5px; + padding: 5px 15px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-background); + flex: 1 1 auto; /* Take up input's previous space */ +` + +const Placeholder = styled.div` + width: 5px; +` + +const InputWrapper = styled.div` + display: flex; + align-items: center; + flex: 1 1 auto; /* Take up input's previous space */ +` + +const Input = styled.input` + border: none; + color: var(--color-text); + background-color: transparent; + outline: none; + width: 100%; + padding: 0 5px; /* Adjust padding, wrapper will handle spacing */ + flex: 1; /* Allow input to grow */ + font-size: 14px; + font-family: Ubuntu; +` + +const ToolBar = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: tpx; +` + +const Separator = styled.div` + width: 1px; + height: 1.5em; + background-color: var(--color-border); + margin-left: 2px; + margin-right: 2px; + flex: 0 0 auto; +` + +const SearchResults = styled.div` + display: flex; + justify-content: center; + width: 80px; + margin: 0 2px; + flex: 0 0 auto; + color: var(--color-text-1); + font-size: 14px; + font-family: Ubuntu; +` + +const SearchResultsPlaceholder = styled.span` + color: var(--color-text-1); + opacity: 0.5; +` + +const NoResults = styled.span` + color: var(--color-text-1); +` + +const SearchResultCount = styled.span` + color: var(--color-text); +` + +const SearchResultSeparator = styled.span` + color: var(--color-text); + margin: 0 4px; +` + +const SearchResultTotalCount = styled.span` + color: var(--color-text); +` diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx new file mode 100644 index 0000000000..61d51f3701 --- /dev/null +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -0,0 +1,94 @@ +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { Dropdown } from 'antd' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ContextMenuProps { + children: React.ReactNode + onContextMenu?: (e: React.MouseEvent) => void +} + +const ContextMenu: React.FC = ({ children, onContextMenu }) => { + const { t } = useTranslation() + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) + const [selectedQuoteText, setSelectedQuoteText] = useState('') + const [selectedText, setSelectedText] = useState('') + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const _selectedText = window.getSelection()?.toString() + if (_selectedText) { + const quotedText = + _selectedText + .split('\n') + .map((line) => `> ${line}`) + .join('\n') + '\n-------------' + setSelectedQuoteText(quotedText) + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setSelectedText(_selectedText) + } + onContextMenu?.(e) + }, + [onContextMenu] + ) + + useEffect(() => { + const handleClick = () => { + setContextMenuPosition(null) + } + document.addEventListener('click', handleClick) + return () => { + document.removeEventListener('click', handleClick) + } + }, []) + + // 获取右键菜单项 + const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [ + { + key: 'copy', + label: t('common.copy'), + onClick: () => { + if (selectedText) { + navigator.clipboard + .writeText(selectedText) + .then(() => { + window.message.success({ content: t('message.copied'), key: 'copy-message' }) + }) + .catch(() => { + window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) + }) + } + } + }, + { + key: 'quote', + label: t('chat.message.quote'), + onClick: () => { + if (selectedQuoteText) { + EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText) + } + } + } + ] + + return ( + + {contextMenuPosition && ( + +
+ + )} + {children} + + ) +} + +const ContextContainer = styled.div`` + +export default ContextMenu diff --git a/src/renderer/src/components/CopyButton.tsx b/src/renderer/src/components/CopyButton.tsx new file mode 100644 index 0000000000..bdc34a0675 --- /dev/null +++ b/src/renderer/src/components/CopyButton.tsx @@ -0,0 +1,83 @@ +import { Tooltip } from 'antd' +import { Copy } from 'lucide-react' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface CopyButtonProps { + tooltip?: string + textToCopy: string + label?: string + color?: string + hoverColor?: string + size?: number +} + +interface ButtonContainerProps { + $color: string + $hoverColor: string +} + +const CopyButton: FC = ({ + tooltip, + textToCopy, + label, + color = 'var(--color-text-2)', + hoverColor = 'var(--color-primary)', + size = 14 +}) => { + const { t } = useTranslation() + + const handleCopy = () => { + navigator.clipboard + .writeText(textToCopy) + .then(() => { + window.message?.success(t('message.copy.success')) + }) + .catch(() => { + window.message?.error(t('message.copy.failed')) + }) + } + + const button = ( + + + {label && {label}} + + ) + + if (tooltip) { + return {button} + } + + return button +} + +const ButtonContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + cursor: pointer; + color: ${(props) => props.$color}; + transition: color 0.2s; + + .copy-icon { + color: ${(props) => props.$color}; + transition: color 0.2s; + } + + &:hover { + color: ${(props) => props.$hoverColor}; + + .copy-icon { + color: ${(props) => props.$hoverColor}; + } + } +` + +const RightText = styled.span<{ size: number }>` + font-size: ${(props) => props.size}px; +` + +export default CopyButton diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index c02f45c60c..9c94084d70 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,6 +1,6 @@ import { Collapse } from 'antd' import { merge } from 'lodash' -import { FC, memo } from 'react' +import { FC, memo, useMemo, useState } from 'react' interface CustomCollapseProps { label: React.ReactNode @@ -28,28 +28,46 @@ const CustomCollapse: FC = ({ style, styles }) => { + const [activeKeys, setActiveKeys] = useState(activeKey || defaultActiveKey) + const defaultCollapseStyle = { width: '100%', background: 'transparent', border: '0.5px solid var(--color-border)' } + const defaultCollpaseHeaderStyle = { + padding: '3px 16px', + alignItems: 'center', + justifyContent: 'space-between', + background: 'var(--color-background-soft)' + } + + const getHeaderStyle = () => { + return activeKeys && activeKeys.length > 0 + ? { + ...defaultCollpaseHeaderStyle, + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px' + } + : { + ...defaultCollpaseHeaderStyle, + borderRadius: '8px' + } + } + const defaultCollapseItemStyles = { - header: { - padding: '8px 16px', - alignItems: 'center', - justifyContent: 'space-between', - background: 'var(--color-background-soft)', - borderTopLeftRadius: '8px', - borderTopRightRadius: '8px' - }, + header: getHeaderStyle(), body: { borderTop: 'none' } } const collapseStyle = merge({}, defaultCollapseStyle, style) - const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles) + const collapseItemStyles = useMemo(() => { + return merge({}, defaultCollapseItemStyles, styles) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeKeys]) return ( = ({ activeKey={activeKey} destroyInactivePanel={destroyInactivePanel} collapsible={collapsible} + onChange={setActiveKeys} items={[ { styles: collapseItemStyles, diff --git a/src/renderer/src/components/CustomTag.tsx b/src/renderer/src/components/CustomTag.tsx index 72f637ad05..c875ba01a4 100644 --- a/src/renderer/src/components/CustomTag.tsx +++ b/src/renderer/src/components/CustomTag.tsx @@ -1,6 +1,6 @@ import { CloseOutlined } from '@ant-design/icons' import { Tooltip } from 'antd' -import { FC } from 'react' +import { FC, memo, useMemo } from 'react' import styled from 'styled-components' interface CustomTagProps { @@ -14,17 +14,26 @@ interface CustomTagProps { } const CustomTag: FC = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => { - return ( - + const tagContent = useMemo( + () => ( {icon && icon} {children} {closable && } + ), + [children, closable, color, icon, onClose, size] + ) + + return tooltip ? ( + + {tagContent} + ) : ( + tagContent ) } -export default CustomTag +export default memo(CustomTag) const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>` display: inline-flex; diff --git a/src/renderer/src/components/EmojiIcon.tsx b/src/renderer/src/components/EmojiIcon.tsx index 29249c8892..2e9600e399 100644 --- a/src/renderer/src/components/EmojiIcon.tsx +++ b/src/renderer/src/components/EmojiIcon.tsx @@ -1,4 +1,3 @@ -import { getLeadingEmoji } from '@renderer/utils' import { FC } from 'react' import styled from 'styled-components' @@ -8,12 +7,10 @@ interface EmojiIconProps { } const EmojiIcon: FC = ({ emoji, className }) => { - const _emoji = getLeadingEmoji(emoji || '⭐️') || '⭐️' - return ( - {_emoji} - {_emoji} + {emoji || '⭐️'} + {emoji} ) } diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index c9345c7253..eb8a90dbde 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -13,7 +13,7 @@ const EmojiPicker: FC = ({ onEmojiClick }) => { if (ref.current) { ref.current.addEventListener('emoji-click', (event: any) => { event.stopPropagation() - onEmojiClick(event.detail.emoji.unicode) + onEmojiClick(event.detail.unicode || event.detail.emoji.unicode) }) } }, [onEmojiClick]) diff --git a/src/renderer/src/components/ExpandableText.tsx b/src/renderer/src/components/ExpandableText.tsx new file mode 100644 index 0000000000..5df32bb9c6 --- /dev/null +++ b/src/renderer/src/components/ExpandableText.tsx @@ -0,0 +1,51 @@ +import { Button } from 'antd' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ExpandableTextProps { + text: string + style?: React.CSSProperties +} + +const ExpandableText = ({ + ref, + text, + style +}: ExpandableTextProps & { ref?: React.RefObject | null }) => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + const toggleExpand = useCallback(() => { + setIsExpanded((prev) => !prev) + }, []) + + const button = useMemo(() => { + return ( + + ) + }, [isExpanded, t, toggleExpand]) + + return ( + + {text} + {button} + + ) +} + +const Container = styled.div<{ $expanded?: boolean }>` + display: flex; + flex-direction: ${(props) => (props.$expanded ? 'column' : 'row')}; +` + +const TextContainer = styled.div<{ $expanded?: boolean }>` + overflow: hidden; + text-overflow: ${(props) => (props.$expanded ? 'unset' : 'ellipsis')}; + white-space: ${(props) => (props.$expanded ? 'normal' : 'nowrap')}; + line-height: ${(props) => (props.$expanded ? 'unset' : '30px')}; +` + +export default memo(ExpandableText) diff --git a/src/renderer/src/components/Icons/DownloadIcons.tsx b/src/renderer/src/components/Icons/DownloadIcons.tsx new file mode 100644 index 0000000000..55c6f00f1a --- /dev/null +++ b/src/renderer/src/components/Icons/DownloadIcons.tsx @@ -0,0 +1,68 @@ +import { SVGProps } from 'react' + +// 基础下载图标 +export const DownloadIcon = (props: SVGProps) => ( + + + + + +) + +// 带有文件类型的下载图标基础组件 +const DownloadTypeIconBase = ({ type, ...props }: SVGProps & { type: string }) => ( + + + {type} + + + + + +) + +// JPG 文件下载图标 +export const DownloadJpgIcon = (props: SVGProps) => + +// PNG 文件下载图标 +export const DownloadPngIcon = (props: SVGProps) => + +// SVG 文件下载图标 +export const DownloadSvgIcon = (props: SVGProps) => diff --git a/src/renderer/src/components/Icons/FallbackFavicon.tsx b/src/renderer/src/components/Icons/FallbackFavicon.tsx index f6c8369f35..df45673215 100644 --- a/src/renderer/src/components/Icons/FallbackFavicon.tsx +++ b/src/renderer/src/components/Icons/FallbackFavicon.tsx @@ -1,3 +1,4 @@ +import Logger from '@renderer/config/logger' import { useEffect, useState } from 'react' import styled from 'styled-components' @@ -120,7 +121,7 @@ const FallbackFavicon: React.FC = ({ hostname, alt }) => { setFaviconState({ status: 'loaded', src: url }) }) .catch((error) => { - console.log('All favicon requests failed:', error) + Logger.log('All favicon requests failed:', error) setFaviconState({ status: 'loaded', src: faviconUrls[0] }) }) diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index e6521a97c7..d58eab7ee5 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -11,3 +11,58 @@ export const StreamlineGoodHealthAndWellBeing = (props: SVGProps) ) } + +export function MdiLightbulbOffOutline(props: SVGProps) { + return ( + + {/* Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE */} + + + ) +} + +export function MdiLightbulbAutoOutline(props: SVGProps) { + return ( + + {/* Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE */} + + + ) +} + +export function MdiLightbulbOn10(props: SVGProps) { + return ( + + {/* Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE */} + + + ) +} + +export function MdiLightbulbOn50(props: SVGProps) { + return ( + + {/* Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE */} + + + ) +} + +export function MdiLightbulbOn90(props: SVGProps) { + return ( + + {/* Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE */} + + + ) +} diff --git a/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx b/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx new file mode 100644 index 0000000000..9e39d6a770 --- /dev/null +++ b/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx @@ -0,0 +1,20 @@ +import { SVGProps } from 'react' + +export function SvgSpinners180Ring(props: SVGProps) { + return ( + + {/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */} + + + + + ) +} +export default SvgSpinners180Ring diff --git a/src/renderer/src/components/Layout/index.ts b/src/renderer/src/components/Layout/index.ts index 2e5a0b608a..6ebc5788c5 100644 --- a/src/renderer/src/components/Layout/index.ts +++ b/src/renderer/src/components/Layout/index.ts @@ -149,15 +149,6 @@ export const BaseTypography = styled(Box)<{ text-align: ${(props) => props.textAlign || 'left'}; ` -export const TypographyNormal = styled(BaseTypography)` - font-family: 'Ubuntu'; -` - -export const TypographyBold = styled(BaseTypography)` - font-family: 'Ubuntu'; - font-weight: bold; -` - export const Container = styled.main` display: flex; flex-direction: column; diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx index b3d4132845..b93f86b8f6 100644 --- a/src/renderer/src/components/ListItem/index.tsx +++ b/src/renderer/src/components/ListItem/index.tsx @@ -35,7 +35,6 @@ const ListItemContainer = styled.div` flex-direction: column; justify-content: space-between; position: relative; - font-family: Ubuntu; cursor: pointer; border: 1px solid transparent; diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 7e14f7fb71..532d1bf956 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -1,4 +1,6 @@ import { + ArrowLeftOutlined, + ArrowRightOutlined, CloseOutlined, CodeOutlined, CopyOutlined, @@ -8,7 +10,7 @@ import { PushpinOutlined, ReloadOutlined } from '@ant-design/icons' -import { isMac, isWindows } from '@renderer/config/constant' +import { isLinux, isMac, isWindows } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useBridge } from '@renderer/hooks/useBridge' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' @@ -242,6 +244,22 @@ const MinappPopupContainer: React.FC = () => { dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal)) } + /** navigate back in webview history */ + const handleGoBack = (appid: string) => { + const webview = webviewRefs.current.get(appid) + if (webview && webview.canGoBack()) { + webview.goBack() + } + } + + /** navigate forward in webview history */ + const handleGoForward = (appid: string) => { + const webview = webviewRefs.current.get(appid) + if (webview && webview.canGoForward()) { + webview.goForward() + } + } + /** Title bar of the popup */ const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => { if (!appInfo) return null @@ -286,7 +304,17 @@ const MinappPopupContainer: React.FC = () => { )} - + + + + + + +
+ }> + { + if (e.ctrlKey || e.metaKey) { + handleConfirmDelete(topic, e) + } else if (deletingTopicId === topic.id) { + handleConfirmDelete(topic, e) + } else { + handleDeleteClick(topic.id, e) + } + }}> + {deletingTopicId === topic.id ? ( + + ) : ( + + )} + + + )} + {topic.pinned && ( + + + + )} + {topicPrompt && ( {fullTopicPrompt} @@ -407,37 +480,6 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic {showTopicTime && ( {dayjs(topic.createdAt).format('MM/DD HH:mm')} )} - {topic.pinned && } - {isActive && !topic.pinned && ( - -
- {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} -
-
- }> - { - if (e.ctrlKey || e.metaKey) { - handleConfirmDelete(topic, e) - } else if (deletingTopicId === topic.id) { - handleConfirmDelete(topic, e) - } else { - handleDeleteClick(topic.id, e) - } - }}> - {deletingTopicId === topic.id ? ( - - ) : ( - - )} - - - )} ) }} @@ -457,15 +499,12 @@ const Container = styled(Scrollbar)` const TopicListItem = styled.div` padding: 7px 12px; border-radius: var(--list-item-border-radius); - font-family: Ubuntu; font-size: 13px; display: flex; flex-direction: column; justify-content: space-between; position: relative; - font-family: Ubuntu; cursor: pointer; - border: 0.5px solid transparent; position: relative; width: calc(var(--assistants-width) - 20px); .menu { @@ -473,15 +512,10 @@ const TopicListItem = styled.div` color: var(--color-text-3); } &:hover { - background-color: var(--color-background-soft); - .name { - } + background-color: var(--color-list-item-hover); } &.active { - background-color: var(--color-background-soft); - border: 0.5px solid var(--color-border); - .name { - } + background-color: var(--color-list-item); .menu { opacity: 1; &:hover { @@ -491,6 +525,14 @@ const TopicListItem = styled.div` } ` +const TopicNameContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + justify-content: space-between; +` + const TopicName = styled.div` display: -webkit-box; -webkit-line-clamp: 1; @@ -534,11 +576,8 @@ const MenuButton = styled.div` flex-direction: row; justify-content: center; align-items: center; - min-width: 22px; - min-height: 22px; - position: absolute; - right: 8px; - top: 6px; + min-width: 20px; + min-height: 20px; .anticon { font-size: 12px; } diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx new file mode 100644 index 0000000000..3d7b920f89 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -0,0 +1,440 @@ +import { + CheckOutlined, + DeleteOutlined, + EditOutlined, + MinusCircleOutlined, + PlusOutlined, + SaveOutlined, + SmileOutlined, + SortAscendingOutlined, + SortDescendingOutlined +} from '@ant-design/icons' +import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import EmojiIcon from '@renderer/components/EmojiIcon' +import CopyIcon from '@renderer/components/Icons/CopyIcon' +import PromptPopup from '@renderer/components/Popups/PromptPopup' +import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' +import { useSettings } from '@renderer/hooks/useSettings' +import { useTags } from '@renderer/hooks/useTags' +import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' +import { getDefaultModel, getDefaultTopic } from '@renderer/services/AssistantService' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { Assistant, AssistantsSortType } from '@renderer/types' +import { getLeadingEmoji, uuid } from '@renderer/utils' +import { hasTopicPendingRequests } from '@renderer/utils/queue' +import { Dropdown, MenuProps } from 'antd' +import { omit } from 'lodash' +import { AlignJustify, Plus, Settings2, Tag, Tags } from 'lucide-react' +import { FC, memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import * as tinyPinyin from 'tiny-pinyin' + +import AssistantTagsPopup from './AssistantTagsPopup' + +interface AssistantItemProps { + assistant: Assistant + isActive: boolean + sortBy: AssistantsSortType + onSwitch: (assistant: Assistant) => void + onDelete: (assistant: Assistant) => void + onCreateDefaultAssistant: () => void + addAgent: (agent: any) => void + addAssistant: (assistant: Assistant) => void + onTagClick?: (tag: string) => void + handleSortByChange?: (sortType: AssistantsSortType) => void +} + +const AssistantItem: FC = ({ + assistant, + isActive, + sortBy, + onSwitch, + onDelete, + addAgent, + addAssistant, + handleSortByChange +}) => { + const { t } = useTranslation() + const { allTags } = useTags() + const { removeAllTopics } = useAssistant(assistant.id) + const { clickAssistantToShowTopic, topicPosition, assistantIconType, setAssistantIconType } = useSettings() + const defaultModel = getDefaultModel() + const { assistants, updateAssistants } = useAssistants() + + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (isActive) { + setIsPending(false) + return + } + + const hasPending = assistant.topics.some((topic) => hasTopicPendingRequests(topic.id)) + setIsPending(hasPending) + }, [isActive, assistant.topics]) + + const sortByPinyinAsc = useCallback(() => { + updateAssistants(sortAssistantsByPinyin(assistants, true)) + }, [assistants, updateAssistants]) + + const sortByPinyinDesc = useCallback(() => { + updateAssistants(sortAssistantsByPinyin(assistants, false)) + }, [assistants, updateAssistants]) + + const menuItems = useMemo( + () => + getMenuItems({ + assistant, + t, + allTags, + assistants, + updateAssistants, + addAgent, + addAssistant, + onSwitch, + onDelete, + removeAllTopics, + setAssistantIconType, + sortBy, + handleSortByChange, + sortByPinyinAsc, + sortByPinyinDesc + }), + [ + assistant, + t, + allTags, + assistants, + updateAssistants, + addAgent, + addAssistant, + onSwitch, + onDelete, + removeAllTopics, + setAssistantIconType, + sortBy, + handleSortByChange, + sortByPinyinAsc, + sortByPinyinDesc + ] + ) + + const handleSwitch = useCallback(async () => { + if (clickAssistantToShowTopic) { + if (topicPosition === 'left') { + EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR) + } + onSwitch(assistant) + } else { + startTransition(() => { + onSwitch(assistant) + }) + } + }, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition]) + + const assistantName = useMemo(() => assistant.name || t('chat.default.name'), [assistant.name, t]) + const fullAssistantName = useMemo( + () => (assistant.emoji ? `${assistant.emoji} ${assistantName}` : assistantName), + [assistant.emoji, assistantName] + ) + + return ( + + + + {assistantIconType === 'model' ? ( + + ) : ( + assistantIconType === 'emoji' && ( + + ) + )} + {assistantName} + + {isActive && ( + EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}> + {assistant.topics.length} + + )} + + + ) +} + +// 提取排序相关的工具函数 +const sortAssistantsByPinyin = (assistants: Assistant[], isAscending: boolean) => { + return [...assistants].sort((a, b) => { + const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true) + const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true) + return isAscending ? pinyinA.localeCompare(pinyinB) : pinyinB.localeCompare(pinyinA) + }) +} + +// 提取标签相关的操作函数 +const handleTagOperation = ( + tag: string, + assistant: Assistant, + assistants: Assistant[], + updateAssistants: (assistants: Assistant[]) => void +) => { + if (assistant.tags?.includes(tag)) { + return + } + updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tag] } : a))) +} + +// 提取创建菜单项的函数 +const createTagMenuItems = ( + allTags: string[], + assistant: Assistant, + assistants: Assistant[], + updateAssistants: (assistants: Assistant[]) => void, + t: (key: string) => string +): MenuProps['items'] => { + const items: MenuProps['items'] = [ + ...allTags.map((tag) => ({ + label: tag, + icon: assistant.tags?.includes(tag) ? : , + key: `all-tag-${tag}`, + onClick: () => handleTagOperation(tag, assistant, assistants, updateAssistants) + })) + ] + + if (allTags.length > 0) { + items.push({ type: 'divider' }) + } + + items.push({ + label: t('assistants.tags.add'), + key: 'new-tag', + icon: , + onClick: async () => { + const tagName = await PromptPopup.show({ + title: t('assistants.tags.add'), + message: '' + }) + + if (tagName && tagName.trim()) { + updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tagName.trim()] } : a))) + } + } + }) + + if (allTags.length > 0) { + items.push({ + label: t('assistants.tags.manage'), + key: 'manage-tags', + icon: , + onClick: () => { + AssistantTagsPopup.show({ title: t('assistants.tags.manage') }) + } + }) + } + + return items +} + +// 提取创建菜单配置的函数 +function getMenuItems({ + assistant, + t, + allTags, + assistants, + updateAssistants, + addAgent, + addAssistant, + onSwitch, + onDelete, + removeAllTopics, + setAssistantIconType, + sortBy, + handleSortByChange, + sortByPinyinAsc, + sortByPinyinDesc +}): MenuProps['items'] { + return [ + { + label: t('assistants.edit.title'), + key: 'edit', + icon: , + onClick: () => AssistantSettingsPopup.show({ assistant }) + }, + { + label: t('assistants.copy.title'), + key: 'duplicate', + icon: , + onClick: async () => { + const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic(assistant.id)] } + addAssistant(_assistant) + onSwitch(_assistant) + } + }, + { + label: t('assistants.clear.title'), + key: 'clear', + icon: , + onClick: () => { + window.modal.confirm({ + title: t('assistants.clear.title'), + content: t('assistants.clear.content'), + centered: true, + okButtonProps: { danger: true }, + onOk: removeAllTopics + }) + } + }, + { + label: t('assistants.save.title'), + key: 'save-to-agent', + icon: , + onClick: async () => { + const agent = omit(assistant, ['model', 'emoji']) + agent.id = uuid() + agent.type = 'agent' + addAgent(agent) + window.message.success({ + content: t('assistants.save.success'), + key: 'save-to-agent' + }) + } + }, + { + label: t('assistants.icon.type'), + key: 'icon-type', + icon: , + children: [ + { + label: t('settings.assistant.icon.type.model'), + key: 'model', + onClick: () => setAssistantIconType('model') + }, + { + label: t('settings.assistant.icon.type.emoji'), + key: 'emoji', + onClick: () => setAssistantIconType('emoji') + }, + { + label: t('settings.assistant.icon.type.none'), + key: 'none', + onClick: () => setAssistantIconType('none') + } + ] + }, + { + type: 'divider' + }, + { + label: t('assistants.tags.manage'), + key: 'all-tags', + icon: , + children: createTagMenuItems(allTags, assistant, assistants, updateAssistants, t) + }, + { + label: sortBy === 'list' ? t('assistants.list.showByTags') : t('assistants.list.showByList'), + key: 'switch-view', + icon: sortBy === 'list' ? : , + onClick: () => { + sortBy === 'list' ? handleSortByChange?.('tags') : handleSortByChange?.('list') + } + }, + { + label: t('common.sort.pinyin.asc'), + key: 'sort-asc', + icon: , + onClick: sortByPinyinAsc + }, + { + label: t('common.sort.pinyin.desc'), + key: 'sort-desc', + icon: , + onClick: sortByPinyinDesc + }, + { + type: 'divider' + }, + { + label: t('common.delete'), + key: 'delete', + icon: , + danger: true, + onClick: () => { + window.modal.confirm({ + title: t('assistants.delete.title'), + content: t('assistants.delete.content'), + centered: true, + okButtonProps: { danger: true }, + onOk: () => onDelete(assistant) + }) + } + } + ] +} + +const Container = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0 8px; + height: 37px; + position: relative; + border-radius: var(--list-item-border-radius); + border: 0.5px solid transparent; + width: calc(var(--assistants-width) - 20px); + cursor: pointer; + &:hover { + background-color: var(--color-list-item-hover); + } + &.active { + background-color: var(--color-list-item); + } +` + +const AssistantNameRow = styled.div` + color: var(--color-text); + font-size: 13px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +` + +const AssistantName = styled.div` + font-size: 13px; +` + +const MenuButton = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + min-width: 22px; + height: 22px; + min-height: 22px; + border-radius: 11px; + position: absolute; + background-color: var(--color-background); + right: 9px; + top: 6px; + padding: 0 5px; + border: 0.5px solid var(--color-border); +` + +const TopicCount = styled.div` + color: var(--color-text); + font-size: 10px; + border-radius: 10px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +` + +export default memo(AssistantItem) diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantTagsPopup.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantTagsPopup.tsx new file mode 100644 index 0000000000..978aca0212 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/AssistantTagsPopup.tsx @@ -0,0 +1,123 @@ +import { Box } from '@renderer/components/Layout' +import { TopView } from '@renderer/components/TopView' +import { useAssistants } from '@renderer/hooks/useAssistant' +import { useTags } from '@renderer/hooks/useTags' +import { Button, Empty, Modal } from 'antd' +import { isEmpty } from 'lodash' +import { Trash } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ShowParams { + title: string +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ title, resolve }) => { + const [open, setOpen] = useState(true) + const { allTags, getAssistantsByTag } = useTags() + const { assistants, updateAssistants } = useAssistants() + const { t } = useTranslation() + + const onOk = () => { + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + const onDelete = (removedTag: string) => { + window.modal.confirm({ + title: t('assistants.tags.deleteConfirm'), + centered: true, + onOk: () => { + const relatedAssistants = getAssistantsByTag(removedTag) + if (!isEmpty(relatedAssistants)) { + updateAssistants( + assistants.map((assistant) => { + const findedAssitant = relatedAssistants.find((_assistant) => _assistant.id === assistant.id) + return findedAssitant ? { ...findedAssitant, tags: [] } : assistant + }) + ) + } + } + }) + } + + AssistantTagsPopup.hide = onCancel + + return ( + + + {allTags.map((tag) => ( + + {tag} +