Compare commits
2 Commits
main
...
build/wind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d74a2330e | ||
|
|
8fd57d0271 |
86
.github/dependabot.yml
vendored
Normal file
86
.github/dependabot.yml
vendored
Normal file
@@ -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"
|
||||
2
.github/workflows/issue-management.yml
vendored
2
.github/workflows/issue-management.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/nightly-build.yml
vendored
2
.github/workflows/nightly-build.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/pr-ci.yml
vendored
1
.github/workflows/pr-ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
|
||||
|
||||
71
.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch
vendored
Normal file
71
.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch
vendored
Normal file
@@ -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",
|
||||
@@ -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<NpmNodeModulesCollector | PnpmNodeModulesCollector>;
|
||||
+export declare function getCollectorByPackageManager(rootDir: string): Promise<PnpmNodeModulesCollector | NpmNodeModulesCollector>;
|
||||
export declare function getNodeModules(rootDir: string): Promise<NodeModuleInfo[]>;
|
||||
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<DC extends PlatformSpecificBuildO
|
||||
getElectronSrcDir(dist: string): string;
|
||||
getElectronDestinationDir(appOutDir: string): string;
|
||||
getResourcesDir(appOutDir: string): string;
|
||||
+ getMacOsElectronFrameworkResourcesDir(appOutDir: string): string;
|
||||
getMacOsResourcesDir(appOutDir: string): string;
|
||||
private checkFileInPackage;
|
||||
private sanityCheckPackage;
|
||||
diff --git a/out/platformPackager.js b/out/platformPackager.js
|
||||
index 6f799ce0d1cdb5f0b18a9c8187b2db84b3567aa9..879248e6c6786d3473e1a80e3930d3a8d0190aab 100644
|
||||
--- a/out/platformPackager.js
|
||||
+++ b/out/platformPackager.js
|
||||
@@ -465,12 +465,13 @@ class PlatformPackager {
|
||||
if (this.platform === index_1.Platform.MAC) {
|
||||
return this.getMacOsResourcesDir(appOutDir);
|
||||
}
|
||||
- else if ((0, Framework_1.isElectronBased)(this.info.framework)) {
|
||||
+ if ((0, Framework_1.isElectronBased)(this.info.framework)) {
|
||||
return path.join(appOutDir, "resources");
|
||||
}
|
||||
- else {
|
||||
- return appOutDir;
|
||||
- }
|
||||
+ return appOutDir;
|
||||
+ }
|
||||
+ getMacOsElectronFrameworkResourcesDir(appOutDir) {
|
||||
+ return path.join(appOutDir, `${this.appInfo.productFilename}.app`, "Contents", "Frameworks", "Electron Framework.framework", "Resources");
|
||||
}
|
||||
getMacOsResourcesDir(appOutDir) {
|
||||
return path.join(appOutDir, `${this.appInfo.productFilename}.app`, "Contents", "Resources");
|
||||
159
.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch
vendored
Normal file
159
.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
diff --git a/out/macPackager.js b/out/macPackager.js
|
||||
index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644
|
||||
--- a/out/macPackager.js
|
||||
+++ b/out/macPackager.js
|
||||
@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager {
|
||||
}
|
||||
appPlist.CFBundleName = appInfo.productName;
|
||||
appPlist.CFBundleDisplayName = appInfo.productName;
|
||||
- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion;
|
||||
if (minimumSystemVersion != null) {
|
||||
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
|
||||
}
|
||||
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
|
||||
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
|
||||
--- a/out/publish/updateInfoBuilder.js
|
||||
+++ b/out/publish/updateInfoBuilder.js
|
||||
@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) {
|
||||
const customUpdateInfo = event.updateInfo;
|
||||
const url = path.basename(event.file);
|
||||
const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file));
|
||||
+ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion;
|
||||
const files = [{ url, sha512 }];
|
||||
const result = {
|
||||
// @ts-ignore
|
||||
@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) {
|
||||
path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
|
||||
// @ts-ignore
|
||||
sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
|
||||
+ minimumSystemVersion,
|
||||
...releaseInfo,
|
||||
};
|
||||
if (customUpdateInfo != null) {
|
||||
+ if (customUpdateInfo.minimumSystemVersion) {
|
||||
+ delete customUpdateInfo.minimumSystemVersion;
|
||||
+ }
|
||||
// file info or nsis web installer packages info
|
||||
Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo);
|
||||
}
|
||||
diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js
|
||||
index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644
|
||||
--- a/out/targets/ArchiveTarget.js
|
||||
+++ b/out/targets/ArchiveTarget.js
|
||||
@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
|
||||
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ }
|
||||
await packager.info.emitArtifactBuildCompleted({
|
||||
updateInfo,
|
||||
file: artifactPath,
|
||||
diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js
|
||||
index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644
|
||||
--- a/out/targets/nsis/NsisTarget.js
|
||||
+++ b/out/targets/nsis/NsisTarget.js
|
||||
@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target {
|
||||
if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) {
|
||||
updateInfo.isAdminRightsRequired = true;
|
||||
}
|
||||
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
|
||||
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ }
|
||||
await packager.info.emitArtifactBuildCompleted({
|
||||
file: installerPath,
|
||||
updateInfo,
|
||||
diff --git a/scheme.json b/scheme.json
|
||||
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43ebd0fa8b61 100644
|
||||
--- a/scheme.json
|
||||
+++ b/scheme.json
|
||||
@@ -1975,6 +1975,13 @@
|
||||
],
|
||||
"description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing."
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"packageCategory": {
|
||||
"description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place",
|
||||
"type": [
|
||||
@@ -2327,6 +2334,13 @@
|
||||
"MacConfiguration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
+ "LSMinimumSystemVersion": {
|
||||
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"additionalArguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2737,7 +2751,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumSystemVersion": {
|
||||
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
@@ -2959,6 +2973,13 @@
|
||||
"MasConfiguration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
+ "LSMinimumSystemVersion": {
|
||||
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"additionalArguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3369,7 +3390,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumSystemVersion": {
|
||||
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
@@ -6507,6 +6528,13 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"protocols": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -7376,6 +7404,13 @@
|
||||
],
|
||||
"description": "MAS (Mac Application Store) development options (`mas-dev` target)."
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"msi": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1,51 +0,0 @@
|
||||
diff --git a/out/MacUpdater.js b/out/MacUpdater.js
|
||||
index 8f18dc5416c91835ded4e47f2358fba680c129ac..a3fb43c2450dc3484bf099b5ea79a362a3b372cc 100644
|
||||
--- a/out/MacUpdater.js
|
||||
+++ b/out/MacUpdater.js
|
||||
@@ -74,7 +74,7 @@ class MacUpdater extends AppUpdater_1.AppUpdater {
|
||||
else {
|
||||
files = files.filter(file => !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;
|
||||
39
.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch
vendored
39
.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch
vendored
@@ -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);
|
||||
}
|
||||
279
.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
vendored
Normal file
279
.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
vendored
Normal file
@@ -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
|
||||
934
.yarn/releases/yarn-4.6.0.cjs
vendored
934
.yarn/releases/yarn-4.6.0.cjs
vendored
File diff suppressed because one or more lines are too long
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
78
README.md
78
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
|
||||
<https://docs.cherry-ai.com>
|
||||
|
||||
# 🌠 Screenshot
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 Key Features
|
||||
|
||||
@@ -65,28 +67,50 @@ https://docs.cherry-ai.com
|
||||
- 📝 Complete Markdown Rendering
|
||||
- 🤲 Easy Content Sharing
|
||||
|
||||
# 📝 TODO
|
||||
# 📝 Roadmap
|
||||
|
||||
- [x] Quick popup (read clipboard, quick question, explain, translate, summarize)
|
||||
- [x] Comparison of multi-model answers
|
||||
- [x] Support login using SSO provided by service providers
|
||||
- [x] All models support networking
|
||||
- [x] Launch of the first official version
|
||||
- [x] Bug fixes and improvements (In progress...)
|
||||
- [ ] Plugin functionality (JavaScript)
|
||||
- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base)
|
||||
- [ ] iOS & Android client
|
||||
- [ ] AI notes
|
||||
- [ ] Voice input and output (AI call)
|
||||
- [ ] Data backup supports custom backup content
|
||||
We're actively working on the following features and improvements:
|
||||
|
||||
1. 🎯 **Core Features**
|
||||
|
||||
- Selection Assistant - Smart content selection enhancement
|
||||
- Deep Research - Advanced research capabilities
|
||||
- Memory System - Global context awareness
|
||||
- Document Preprocessing - Improved document handling
|
||||
- MCP Marketplace - Model Context Protocol ecosystem
|
||||
|
||||
2. 🗂 **Knowledge Management**
|
||||
|
||||
- Notes and Collections
|
||||
- Dynamic Canvas visualization
|
||||
- OCR capabilities
|
||||
- TTS (Text-to-Speech) support
|
||||
|
||||
3. 📱 **Platform Support**
|
||||
|
||||
- HarmonyOS Edition (PC)
|
||||
- Android App (Phase 1)
|
||||
- iOS App (Phase 1)
|
||||
- Multi-Window support
|
||||
- Window Pinning functionality
|
||||
|
||||
4. 🔌 **Advanced Features**
|
||||
|
||||
- Plugin System
|
||||
- ASR (Automatic Speech Recognition)
|
||||
- Assistant and Topic Interaction Refactoring
|
||||
|
||||
Track our progress and contribute on our [project board](https://github.com/orgs/CherryHQ/projects/7).
|
||||
|
||||
Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/CherryHQ/cherry-studio/discussions) to share your ideas and feedback!
|
||||
|
||||
# 🌈 Theme
|
||||
|
||||
- Theme Gallery: https://cherrycss.com
|
||||
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- Claude dynamic-style: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- Maple Neon Theme: https://github.com/BoningtonChen/CherryStudio_themes
|
||||
- Theme Gallery: <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>
|
||||
|
||||
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
|
||||
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
@@ -144,7 +172,7 @@ Thank you for your support and contributions!
|
||||
|
||||
# ✉️ Contact
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
<yinsenho@cherry-ai.com>
|
||||
|
||||
# ⭐️ Star History
|
||||
|
||||
|
||||
@@ -6,17 +6,19 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 <br>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 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
|
||||
|
||||
# 🌠 スクリーンショット
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 主な機能
|
||||
|
||||
@@ -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 を歓迎します
|
||||
|
||||
# 🖥️ 開発
|
||||
|
||||
参考[開発ドキュメント](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 検索結果から特定のサイトを非表示にします
|
||||
|
||||
# 🚀 コントリビューター
|
||||
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# コミュニティ
|
||||
# 🌐 コミュニティ
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# スポンサー
|
||||
# ☕ スポンサー
|
||||
|
||||
[Buy Me a Coffee](sponsor.md)
|
||||
[開発者を支援する](sponsor.md)
|
||||
|
||||
# 📃 ライセンス
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br>
|
||||
</p>
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
@@ -18,15 +19,25 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
|
||||
|
||||
# GitCode✖️Cherry Studio【新源力】贡献挑战赛
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitcode.com/CherryHQ/cherry-studio/discussion/2">
|
||||
<img src="https://raw.gitcode.com/user-images/assets/5007375/8d8d7559-1141-4691-b90f-d154558c6896/cherry-studio-gitcode.jpg" width="100%" alt="banner" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 📖 使用教程
|
||||
|
||||
https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 界面
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 主要特性
|
||||
|
||||
@@ -66,28 +77,50 @@ https://docs.cherry-ai.com
|
||||
- 📝 完整的 Markdown 渲染
|
||||
- 🤲 便捷的内容分享功能
|
||||
|
||||
# 📝 待辦事項
|
||||
# 📝 开发计划
|
||||
|
||||
- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结)
|
||||
- [x] 多模型回答对比
|
||||
- [x] 支持使用服务供应商提供的 SSO 进行登入
|
||||
- [x] 全部模型支持连网(开发中...)
|
||||
- [x] 推出第一个正式版
|
||||
- [x] 错误修复和改进(开发中...)
|
||||
- [ ] 插件功能(JavaScript)
|
||||
- [ ] 浏览器插件(划词翻译、总结、新增至知识库)
|
||||
- [ ] iOS & Android 客户端
|
||||
- [ ] AI 笔记
|
||||
- [ ] 语音输入输出(AI 通话)
|
||||
- [ ] 数据备份支持自定义备份内容
|
||||
我们正在积极开发以下功能和改进:
|
||||
|
||||
1. 🎯 **核心功能**
|
||||
|
||||
- 选择助手 - 智能内容选择增强
|
||||
- 深度研究 - 高级研究能力
|
||||
- 全局记忆 - 全局上下文感知
|
||||
- 文档预处理 - 改进文档处理能力
|
||||
- MCP 市场 - 模型上下文协议生态系统
|
||||
|
||||
2. 🗂 **知识管理**
|
||||
|
||||
- 笔记与收藏功能
|
||||
- 动态画布可视化
|
||||
- OCR 光学字符识别
|
||||
- TTS 文本转语音支持
|
||||
|
||||
3. 📱 **平台支持**
|
||||
|
||||
- 鸿蒙版本 (PC)
|
||||
- Android 应用(第一期)
|
||||
- iOS 应用(第一期)
|
||||
- 多窗口支持
|
||||
- 窗口置顶功能
|
||||
|
||||
4. 🔌 **高级特性**
|
||||
|
||||
- 插件系统
|
||||
- ASR 语音识别
|
||||
- 助手与话题交互重构
|
||||
|
||||
在我们的[项目面板](https://github.com/orgs/CherryHQ/projects/7)上跟踪进展并参与贡献。
|
||||
|
||||
想要影响开发计划?欢迎加入我们的 [GitHub 讨论区](https://github.com/CherryHQ/cherry-studio/discussions) 分享您的想法和反馈!
|
||||
|
||||
# 🌈 主题
|
||||
|
||||
- 主题库:https://cherrycss.com
|
||||
- Aero 主题:https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- 仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- 霓虹枫叶字体主题: https://github.com/BoningtonChen/CherryStudio_themes
|
||||
- PaperMaterial 主题:https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- 仿 Claude 主题:https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- 霓虹枫叶主题:https://github.com/BoningtonChen/CherryStudio_themes
|
||||
|
||||
欢迎 PR 更多主题
|
||||
|
||||
@@ -95,37 +128,43 @@ https://docs.cherry-ai.com
|
||||
|
||||
参考[开发文档](dev.md)
|
||||
|
||||
参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio)
|
||||
|
||||
参考[分支策略](branching-strategy-zh.md)了解贡献指南
|
||||
|
||||
# 🤝 贡献
|
||||
|
||||
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
|
||||
|
||||
1. **贡献代码**:开发新功能或优化现有代码。
|
||||
2. **修复错误**:提交您发现的错误修复。
|
||||
3. **维护问题**:帮助管理 GitHub 问题。
|
||||
4. **产品设计**:参与设计讨论。
|
||||
5. **撰写文档**:改进用户手册和指南。
|
||||
6. **社区参与**:加入讨论并帮助用户。
|
||||
7. **推广使用**:宣传 Cherry Studio。
|
||||
1. **贡献代码**:开发新功能或优化现有代码
|
||||
2. **修复错误**:提交您发现的错误修复
|
||||
3. **维护问题**:帮助管理 GitHub 问题
|
||||
4. **产品设计**:参与设计讨论
|
||||
5. **撰写文档**:改进用户手册和指南
|
||||
6. **社区参与**:加入讨论并帮助用户
|
||||
7. **推广使用**:宣传 Cherry Studio
|
||||
|
||||
## 入门
|
||||
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器。
|
||||
2. **创建分支**:为您的更改创建分支。
|
||||
3. **提交更改**:提交并推送您的更改。
|
||||
4. **打开 Pull Request**:描述您的更改和原因。
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器
|
||||
2. **创建分支**:为您的更改创建分支
|
||||
3. **提交更改**:提交并推送您的更改
|
||||
4. **打开 Pull Request**:描述您的更改和原因
|
||||
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)。
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)
|
||||
|
||||
感谢您的支持和贡献!
|
||||
|
||||
## 相关项目
|
||||
# 🔗 相关项目
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api):LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。
|
||||
|
||||
- [ublacklist](https://github.com/iorate/ublacklist):屏蔽特定网站在 Google 搜索结果中显示
|
||||
|
||||
# 🚀 贡献者
|
||||
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
@@ -135,7 +174,7 @@ https://docs.cherry-ai.com
|
||||
|
||||
# ☕ 赞助
|
||||
|
||||
[微信赞赏码](sponsor.md)
|
||||
[赞助开发者](sponsor.md)
|
||||
|
||||
# 📃 许可证
|
||||
|
||||
|
||||
71
docs/branching-strategy-en.md
Normal file
71
docs/branching-strategy-en.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 🌿 Branching Strategy
|
||||
|
||||
Cherry Studio implements a structured branching strategy to maintain code quality and streamline the development process.
|
||||
|
||||
## Main Branches
|
||||
|
||||
- `main`: Main development branch
|
||||
|
||||
- Contains the latest development code
|
||||
- Direct commits are not allowed - changes must come through pull requests
|
||||
- Code may contain features in development and might not be fully stable
|
||||
|
||||
- `release/*`: Release branches
|
||||
- Created from `main` branch
|
||||
- Contains stable code ready for release
|
||||
- Only accepts documentation updates and bug fixes
|
||||
- Thoroughly tested before production deployment
|
||||
|
||||
## Contributing Branches
|
||||
|
||||
When contributing to Cherry Studio, please follow these guidelines:
|
||||
|
||||
1. **Feature Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `feature/issue-number-brief-description`
|
||||
- Submit PR back to `main`
|
||||
|
||||
2. **Bug Fix Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `fix/issue-number-brief-description`
|
||||
- Submit PR back to `main`
|
||||
|
||||
3. **Documentation Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `docs/brief-description`
|
||||
- Submit PR back to `main`
|
||||
|
||||
4. **Hotfix Branches:**
|
||||
|
||||
- Create from `main` branch
|
||||
- Naming format: `hotfix/issue-number-brief-description`
|
||||
- Submit PR to both `main` and relevant `release` branches
|
||||
|
||||
5. **Release Branches:**
|
||||
- Create from `main` branch
|
||||
- Naming format: `release/version-number`
|
||||
- Used for final preparation work before version release
|
||||
- Only accepts bug fixes and documentation updates
|
||||
- After testing and preparation, merge back to `main` and tag with version
|
||||
|
||||
## Workflow Diagram
|
||||
|
||||

|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- All PRs should be submitted to the `main` branch unless fixing a critical production issue
|
||||
- Ensure your branch is up to date with the latest `main` changes before submitting
|
||||
- Include relevant issue numbers in your PR description
|
||||
- Make sure all tests pass and code meets our quality standards
|
||||
- Add before/after screenshots if you add a new feature or modify a UI component
|
||||
|
||||
## Version Tag Management
|
||||
|
||||
- Major releases: v1.0.0, v2.0.0, etc.
|
||||
- Feature releases: v1.1.0, v1.2.0, etc.
|
||||
- Patch releases: v1.0.1, v1.0.2, etc.
|
||||
- Hotfix releases: v1.0.1-hotfix, etc.
|
||||
71
docs/branching-strategy-zh.md
Normal file
71
docs/branching-strategy-zh.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 🌿 分支策略
|
||||
|
||||
Cherry Studio 采用结构化的分支策略来维护代码质量并简化开发流程。
|
||||
|
||||
## 主要分支
|
||||
|
||||
- `main`:主开发分支
|
||||
|
||||
- 包含最新的开发代码
|
||||
- 禁止直接提交 - 所有更改必须通过拉取请求(Pull Request)
|
||||
- 此分支上的代码可能包含正在开发的功能,不一定完全稳定
|
||||
|
||||
- `release/*`:发布分支
|
||||
- 从 `main` 分支创建
|
||||
- 包含准备发布的稳定代码
|
||||
- 只接受文档更新和 bug 修复
|
||||
- 经过完整测试后可以发布到生产环境
|
||||
|
||||
## 贡献分支
|
||||
|
||||
在为 Cherry Studio 贡献代码时,请遵循以下准则:
|
||||
|
||||
1. **功能开发分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`feature/issue-number-brief-description`
|
||||
- 完成后提交 PR 到 `main` 分支
|
||||
|
||||
2. **Bug 修复分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`fix/issue-number-brief-description`
|
||||
- 完成后提交 PR 到 `main` 分支
|
||||
|
||||
3. **文档更新分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`docs/brief-description`
|
||||
- 完成后提交 PR 到 `main` 分支
|
||||
|
||||
4. **紧急修复分支:**
|
||||
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`hotfix/issue-number-brief-description`
|
||||
- 完成后需要同时合并到 `main` 和相关的 `release` 分支
|
||||
|
||||
5. **发布分支:**
|
||||
- 从 `main` 分支创建
|
||||
- 命名格式:`release/version-number`
|
||||
- 用于版本发布前的最终准备工作
|
||||
- 只允许合并 bug 修复和文档更新
|
||||
- 完成测试和准备工作后,将代码合并回 `main` 分支并打上版本标签
|
||||
|
||||
## 工作流程
|
||||
|
||||

|
||||
|
||||
## 拉取请求(PR)指南
|
||||
|
||||
- 除非是修复生产环境的关键问题,否则所有 PR 都应该提交到 `main` 分支
|
||||
- 提交 PR 前确保你的分支已经同步了最新的 `main` 分支内容
|
||||
- 在 PR 描述中包含相关的 issue 编号
|
||||
- 确保所有测试通过,且代码符合我们的质量标准
|
||||
- 如果你添加了新功能或修改了 UI 组件,请附上更改前后的截图
|
||||
|
||||
## 版本标签管理
|
||||
|
||||
- 主要版本发布:v1.0.0、v2.0.0 等
|
||||
- 功能更新发布:v1.1.0、v1.2.0 等
|
||||
- 补丁修复发布:v1.0.1、v1.0.2 等
|
||||
- 紧急修复发布:v1.0.1-hotfix 等
|
||||
@@ -37,6 +37,14 @@ yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Debug
|
||||
|
||||
```bash
|
||||
yarn debug
|
||||
```
|
||||
|
||||
Then input chrome://inspect in browser
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
|
||||
3
docs/technical/Message.md
Normal file
3
docs/technical/Message.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 消息的生命周期
|
||||
|
||||

|
||||
127
docs/technical/how-to-use-messageBlock.md
Normal file
127
docs/technical/how-to-use-messageBlock.md
Normal file
@@ -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> })`**:
|
||||
|
||||
- 更新一个已存在的 `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` 负责**触发状态变更**的异步流程,这对于维护清晰的应用架构至关重要。
|
||||
105
docs/technical/how-to-use-messageThunk.md
Normal file
105
docs/technical/how-to-use-messageThunk.md
Normal file
@@ -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 的职责和它如何影响消息及块的状态至关重要。
|
||||
156
docs/technical/how-to-use-useMessageOperations.md
Normal file
156
docs/technical/how-to-use-useMessageOperations.md
Normal file
@@ -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 (
|
||||
<div>
|
||||
{/* Component UI */}
|
||||
<button onClick={() => handleDelete('some-message-id')}>Delete Message</button>
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
`useMessageOperations(topic)` Hook 返回一个包含以下函数和值的对象:
|
||||
|
||||
- **`deleteMessage(id: string)`**:
|
||||
|
||||
- 删除指定 `id` 的单个消息。
|
||||
- 内部调用 `deleteSingleMessageThunk`。
|
||||
|
||||
- **`deleteGroupMessages(askId: string)`**:
|
||||
|
||||
- 删除与指定 `askId` 相关联的一组消息(通常是用户提问及其所有助手回答)。
|
||||
- 内部调用 `deleteMessageGroupThunk`。
|
||||
|
||||
- **`editMessage(messageId: string, updates: Partial<Message>)`**:
|
||||
|
||||
- 更新指定 `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` 结合使用,方便地在组件中获取消息数据、加载状态,并执行相关操作。
|
||||
BIN
docs/technical/message-lifecycle.png
Normal file
BIN
docs/technical/message-lifecycle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 563 KiB |
@@ -38,24 +38,21 @@ files:
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
- '**/*.{node,dll,metal,exp,lib}'
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
artifactName: ${productName}-${version}-portable.${ext}
|
||||
target:
|
||||
- target: nsis
|
||||
- target: portable
|
||||
nsis:
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
artifactName: ${productName}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
include: build/nsis-installer.nsh
|
||||
buildUniversalInstaller: false
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-${arch}-portable.${ext}
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
@@ -67,11 +64,20 @@ mac:
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
linux:
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: AppImage
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
desktop:
|
||||
@@ -80,18 +86,19 @@ linux:
|
||||
mimeTypes:
|
||||
- x-scheme-handler/cherrystudio
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://releases.cherry-ai.com
|
||||
# provider: generic
|
||||
# url: https://cherrystudio.ocool.online
|
||||
provider: github
|
||||
repo: cherry-studio
|
||||
owner: CherryHQ
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
新增对 grok-2-image 和 gpt-4o-image 图像支持
|
||||
支持 Windows 便携版使用 data 目录存储数据
|
||||
MCP 界面改版,新增描述信息显示
|
||||
Mermaid 渲染逻辑优化
|
||||
支持关闭公示渲染
|
||||
修复 OpenAI 类型渲染错误
|
||||
新增划词助手
|
||||
助手支持分组
|
||||
支持主题颜色切换
|
||||
划词助手支持应用过滤
|
||||
翻译模块功能改进
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import fs from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
@@ -9,8 +10,7 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
plugins: [externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@cherrystudio/embedjs',
|
||||
'@cherrystudio/embedjs-openai',
|
||||
@@ -25,9 +25,7 @@ export default defineConfig({
|
||||
'p-queue',
|
||||
'webdav'
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('main')
|
||||
],
|
||||
}), ...visualizerPlugin('main')],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
@@ -37,7 +35,27 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client']
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
|
||||
plugins: [
|
||||
{
|
||||
name: 'inject-windows7-polyfill',
|
||||
generateBundle(_, bundle) {
|
||||
// 遍历所有生成的文件
|
||||
for (const fileName in bundle) {
|
||||
const chunk = bundle[fileName]
|
||||
if (
|
||||
chunk.type === 'chunk' &&
|
||||
chunk.isEntry &&
|
||||
chunk.fileName.includes('index.js') // 匹配主进程入口文件
|
||||
) {
|
||||
const code = fs.readFileSync('src/main/polyfill/windows7-patch.js', 'utf-8')
|
||||
// 在文件末尾插入自定义代码
|
||||
chunk.code = code + '\r\n' + chunk.code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -47,9 +65,16 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@shared': resolve('packages/shared')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
define: {
|
||||
// 使用方法 (Windows CMD): set CUSTOM_APP_NAME=AppName && yarn run dev
|
||||
'process.env.CUSTOM_APP_NAME': JSON.stringify(process.env.CUSTOM_APP_NAME)
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
plugins: [
|
||||
@@ -73,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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
133
package.json
133
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,12 +20,14 @@
|
||||
"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",
|
||||
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
|
||||
"build:win7": "xcopy \"src\\patch\\windows7\\\" \"node_modules\\\" /E /Y && dotenv npm run build && electron-builder --win --x64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
|
||||
@@ -37,70 +39,78 @@
|
||||
"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",
|
||||
"bufferutil": "^4.0.9",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"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",
|
||||
"node-fetch": "2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"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": {
|
||||
@@ -108,26 +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",
|
||||
@@ -138,45 +151,53 @@
|
||||
"@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",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "31.7.6",
|
||||
"electron-builder": "26.0.13",
|
||||
"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",
|
||||
@@ -187,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",
|
||||
@@ -196,30 +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",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Cherry Studio Artifacts
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
packages/database/.gitignore
vendored
3
packages/database/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
# Cherry Studio Database
|
||||
|
||||
Cherry Studio 依赖的数据文件由这个数据库来生成,数据库文件请联系开发者获取
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
42
playwright.config.ts
Normal file
42
playwright.config.ts
Normal file
@@ -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,
|
||||
// },
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
// 递归遍历翻译
|
||||
|
||||
19
scripts/win-sign.js
Normal file
19
scripts/win-sign.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
import { isWindows7 } from './utils/runtime'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
@@ -12,12 +13,12 @@ export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
color: 'rgba(0,0,0,0)',
|
||||
color: isWindows7() ? '#1c1c1c' : 'rgba(0,0,0,0)',
|
||||
symbolColor: '#ffffff'
|
||||
}
|
||||
|
||||
export const titleBarOverlayLight = {
|
||||
height: 40,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
color: isWindows7() ? '#f4f4f4' : 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000000'
|
||||
}
|
||||
|
||||
57
src/main/configs/SelectionConfig.ts
Normal file
57
src/main/configs/SelectionConfig.ts
Normal file
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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<number> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
110
src/main/ipc.ts
110
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()
|
||||
}
|
||||
|
||||
@@ -237,8 +237,7 @@ async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiRespo
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const poisResponse = (await response.json()) as BravePoiResponse
|
||||
return poisResponse
|
||||
return (await response.json()) as BravePoiResponse
|
||||
}
|
||||
|
||||
async function getDescriptionsData(apiKey: string, ids: string[]): Promise<BraveDescription> {
|
||||
@@ -257,8 +256,7 @@ async function getDescriptionsData(apiKey: string, ids: string[]): Promise<Brave
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
|
||||
}
|
||||
|
||||
const descriptionsData = (await response.json()) as BraveDescription
|
||||
return descriptionsData
|
||||
return (await response.json()) as BraveDescription
|
||||
}
|
||||
|
||||
function formatLocalResults(poisData: BravePoiResponse, descData: BraveDescription): string {
|
||||
|
||||
@@ -56,7 +56,6 @@ class DifyKnowledgeServer {
|
||||
private config: DifyKnowledgeServerConfig
|
||||
|
||||
constructor(difyKey: string, args: string[]) {
|
||||
console.log('DifyKnowledgeServer args', args)
|
||||
if (args.length === 0) {
|
||||
throw new Error('DifyKnowledgeServer requires at least one argument')
|
||||
}
|
||||
@@ -113,8 +112,6 @@ class DifyKnowledgeServer {
|
||||
const errorDetails = JSON.stringify(parsed.error.format(), null, 2)
|
||||
throw new Error(`无效的参数:\n${errorDetails}`)
|
||||
}
|
||||
|
||||
console.log('DifyKnowledgeServer search_knowledge parsed', parsed.data)
|
||||
return await this.performSearchKnowledge(
|
||||
parsed.data.id,
|
||||
parsed.data.query,
|
||||
|
||||
@@ -209,20 +209,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -183,7 +183,6 @@ async function searchFiles(
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip invalid paths during search
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
|
||||
36
src/main/polyfill/windows7-patch.js
Normal file
36
src/main/polyfill/windows7-patch.js
Normal file
@@ -0,0 +1,36 @@
|
||||
console.info('inject polyfill win7')
|
||||
|
||||
// fix for node_modules\@libsql\isomorphic-fetch\node.cjs
|
||||
if (!globalThis.fetch) {
|
||||
globalThis.fetch = require('node-fetch')
|
||||
}
|
||||
|
||||
if (!globalThis.Request) {
|
||||
const { Request, Headers } = require('node-fetch')
|
||||
globalThis.Request = Request
|
||||
globalThis.Headers = Headers
|
||||
}
|
||||
// fix for node_modules/undici/lib/web/fetch/webidl.js
|
||||
if (!globalThis.Blob) {
|
||||
const { Blob } = require('blob-polyfill')
|
||||
globalThis.Blob = Blob
|
||||
}
|
||||
|
||||
// fix for node_modules/undici/lib/web/fetch/webidl.js
|
||||
if (!globalThis.ReadableStream) {
|
||||
const { ReadableStream, TransformStream } = require('web-streams-polyfill')
|
||||
globalThis.ReadableStream = ReadableStream
|
||||
globalThis.TransformStream = TransformStream
|
||||
console.log('ReadableStream', ReadableStream)
|
||||
}
|
||||
|
||||
if (!globalThis.DOMException) {
|
||||
globalThis.DOMException = require('domexception')
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
const { Crypto } = require('@peculiar/webcrypto')
|
||||
globalThis.crypto = new Crypto()
|
||||
}
|
||||
|
||||
console.info('inject polyfill win7 ok')
|
||||
@@ -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
|
||||
|
||||
@@ -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<ExtractChunkData[]> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
}
|
||||
@@ -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<ExtractChunkData[]> => {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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<ExtractChunkData[]> {
|
||||
return this.sdk.rerank(query, searchResults)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ExtractChunkData[]> => {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ExtractChunkData[]> => {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
@@ -104,6 +105,9 @@ class BackupManager {
|
||||
|
||||
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||
|
||||
Logger.log('[BackupManager IPC] ', skipBackupFile)
|
||||
|
||||
if (!skipBackupFile) {
|
||||
// 复制 Data 目录到临时目录
|
||||
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
||||
const tempDataDir = path.join(this.tempDir, 'Data')
|
||||
@@ -121,6 +125,10 @@ class BackupManager {
|
||||
|
||||
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 不提供总条目数信息
|
||||
const zip = new StreamZip.async({ file: backupPath })
|
||||
onProgress({ stage: 'extracting', progress: 15, total: 100 })
|
||||
}
|
||||
})
|
||||
onProgress({ stage: 'extracting', progress: 25, 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,6 +255,10 @@ class BackupManager {
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
|
||||
const dataExists = await fs.pathExists(sourcePath)
|
||||
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
|
||||
|
||||
if (dataExists && dataFiles.length > 0) {
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
@@ -265,6 +272,9 @@ class BackupManager {
|
||||
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), {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T>(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<boolean>(ConfigKeys.SelectionAssistantEnabled, true)
|
||||
}
|
||||
|
||||
setSelectionAssistantEnabled(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: trigger mode (selected, ctrlkey)
|
||||
getSelectionAssistantTriggerMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantTriggerMode, 'selected')
|
||||
}
|
||||
|
||||
setSelectionAssistantTriggerMode(value: string) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: if action window position follow toolbar
|
||||
getSelectionAssistantFollowToolbar(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantFollowToolbar, true)
|
||||
}
|
||||
|
||||
setSelectionAssistantFollowToolbar(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantRemeberWinSize(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantRemeberWinSize, false)
|
||||
}
|
||||
|
||||
setSelectionAssistantRemeberWinSize(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantFilterMode, 'default')
|
||||
}
|
||||
|
||||
setSelectionAssistantFilterMode(value: string) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterList(): string[] {
|
||||
return this.get<string[]>(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<T>(key: string, defaultValue?: T) {
|
||||
|
||||
77
src/main/services/ContextMenu.ts
Normal file
77
src/main/services/ContextMenu.ts
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,17 @@ class FileStorage {
|
||||
}
|
||||
|
||||
private initStorageDir = (): void => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private getFileHash = async (filePath: string): Promise<string> => {
|
||||
@@ -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<FileType> => {
|
||||
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)}`
|
||||
@@ -315,7 +373,7 @@ class FileStorage {
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<string | null> => {
|
||||
): Promise<string | null | undefined> => {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
@@ -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<FileType> => {
|
||||
public downloadFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
url: string,
|
||||
isUseContentType?: boolean
|
||||
): Promise<FileType> => {
|
||||
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<void> => {
|
||||
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
|
||||
|
||||
@@ -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<File> {
|
||||
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<File | undefined> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const cachedResponse = CacheService.get<any>(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>, 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<File[]> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@ class KnowledgeService {
|
||||
{ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }
|
||||
): Promise<void> => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<T extends unknown[], R> = (...args: T) => Promise<R>
|
||||
@@ -70,17 +70,7 @@ function withCache<T extends unknown[], R>(
|
||||
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = 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<string, Promise<Client>> = 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<Client> {
|
||||
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,11 +121,15 @@ 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 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: {} })
|
||||
|
||||
@@ -162,7 +173,23 @@ class McpService {
|
||||
} else if (server.type === 'sse') {
|
||||
const options: SSEClientTransportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => fetch(url, { ...init, headers: server.headers || {} })
|
||||
fetch: async (url, init) => {
|
||||
const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
},
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
@@ -183,7 +210,7 @@ class McpService {
|
||||
// add -x to args if args exist
|
||||
if (args && args.length > 0) {
|
||||
if (!args.includes('-y')) {
|
||||
!args.includes('-y') && args.unshift('-y')
|
||||
args.unshift('-y')
|
||||
}
|
||||
if (!args.includes('x')) {
|
||||
args.unshift('x')
|
||||
@@ -215,13 +242,18 @@ class McpService {
|
||||
|
||||
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()
|
||||
|
||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||
if (cmd.includes('bun')) {
|
||||
this.removeProxyEnv(loginShellEnv)
|
||||
}
|
||||
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: await this.getEnhancedPath(process.env.PATH || ''),
|
||||
...loginShellEnv,
|
||||
...server.env
|
||||
},
|
||||
stderr: 'pipe'
|
||||
@@ -285,7 +317,10 @@ class McpService {
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error: Error | any) {
|
||||
if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
|
||||
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 {
|
||||
@@ -299,9 +334,19 @@ class McpService {
|
||||
Logger.info(`[MCP] Activated server: ${server.name}`)
|
||||
return client
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
||||
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 pending promise
|
||||
this.pendingClients.set(serverKey, initPromise)
|
||||
|
||||
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<boolean> {
|
||||
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<MCPTool[]> {
|
||||
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<MCPPrompt[]> {
|
||||
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<MCPResource[]> {
|
||||
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
|
||||
}
|
||||
if (fs.existsSync(pwshPath)) {
|
||||
return pwshPath
|
||||
}
|
||||
return 'powershell.exe'
|
||||
}
|
||||
|
||||
private getSystemPath = memoize(async (): Promise<string> => {
|
||||
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<string> {
|
||||
let systemPath = ''
|
||||
private getLoginShellEnv = memoize(async (): Promise<Record<string, string>> => {
|
||||
try {
|
||||
systemPath = await this.getSystemPath()
|
||||
} catch (error) {
|
||||
Logger.error('[MCP] Failed to get system PATH:', error)
|
||||
}
|
||||
// 将原始 PATH 按分隔符分割成数组
|
||||
const loginEnv = await getLoginShellEnvironment()
|
||||
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)
|
||||
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 {}
|
||||
}
|
||||
})
|
||||
|
||||
// 转换回字符串
|
||||
return Array.from(existingPaths).join(pathSeparator)
|
||||
private removeProxyEnv(env: Record<string, string>) {
|
||||
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()
|
||||
|
||||
31
src/main/services/NotificationService.ts
Normal file
31
src/main/services/NotificationService.ts
Normal file
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ProxyConfig as _ProxyConfig, session } from 'electron'
|
||||
import { socksDispatcher } from 'fetch-socks'
|
||||
// import { ProxyConfig as _ProxyConfig, session } from 'electron'
|
||||
import { session } from 'electron'
|
||||
|
||||
declare type _ProxyConfig = any
|
||||
|
||||
// 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'
|
||||
|
||||
@@ -121,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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
57
src/main/services/RemoteStorage.ts
Normal file
57
src/main/services/RemoteStorage.ts
Normal file
@@ -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<string, string> | 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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1234
src/main/services/SelectionService.ts
Normal file
1234
src/main/services/SelectionService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<BrowserWindow, { onFocusHandler: () => 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 => {
|
||||
|
||||
116
src/main/services/StoreSyncService.ts
Normal file
116
src/main/services/StoreSyncService.ts
Normal file
@@ -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()
|
||||
48
src/main/services/ThemeService.ts
Normal file
48
src/main/services/ThemeService.ts
Normal file
@@ -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()
|
||||
@@ -5,16 +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'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,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
|
||||
|
||||
@@ -64,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('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() {
|
||||
@@ -94,13 +101,6 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
public restartTray() {
|
||||
if (configManager.getTray()) {
|
||||
this.destroyTray()
|
||||
this.createTray()
|
||||
}
|
||||
}
|
||||
|
||||
private destroyTray() {
|
||||
if (this.tray) {
|
||||
this.tray.destroy()
|
||||
@@ -108,8 +108,16 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
private watchTrayChanges() {
|
||||
configManager.subscribe<boolean>('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() {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
// 获取 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()
|
||||
|
||||
@@ -44,7 +44,7 @@ export class CallBackServer {
|
||||
Logger.error('OAuth callback server error:', error)
|
||||
})
|
||||
|
||||
const runningServer = new Promise<http.Server>((resolve, reject) => {
|
||||
return new Promise<http.Server>((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<http.Server> {
|
||||
|
||||
120
src/main/services/mcp/shell-env.ts
Normal file
120
src/main/services/mcp/shell-env.ts
Normal file
@@ -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<Object>} A promise that resolves with an object containing
|
||||
* the environment variables, or rejects with an error.
|
||||
*/
|
||||
function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
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<string, string> = {}
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user