Compare commits
193 Commits
v1.3.12
...
feat/cherr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba76af300 | ||
|
|
f2c52dfe89 | ||
|
|
880d325028 | ||
|
|
2eb421a1de | ||
|
|
4a0924ce15 | ||
|
|
b7eef3b753 | ||
|
|
d3f5887980 | ||
|
|
8db2059605 | ||
|
|
d11b98dfbb | ||
|
|
38330c4c81 | ||
|
|
b762cfd60b | ||
|
|
278397f7c8 | ||
|
|
c6d5faff73 | ||
|
|
9cac8fba56 | ||
|
|
b7d9949832 | ||
|
|
b4665509ab | ||
|
|
21e88b02ea | ||
|
|
10caef2c4c | ||
|
|
6ea1bcc7d1 | ||
|
|
06a60c4871 | ||
|
|
684367bf7c | ||
|
|
75b9e2f408 | ||
|
|
e13b136484 | ||
|
|
9c5fa57936 | ||
|
|
7e201522d0 | ||
|
|
df35f25502 | ||
|
|
f9e557763e | ||
|
|
eafd814caf | ||
|
|
b84f7bf596 | ||
|
|
c1d753b7fe | ||
|
|
3350f58422 | ||
|
|
8c617872e0 | ||
|
|
a333c635cb | ||
|
|
a244057b3a | ||
|
|
79d7ffcbad | ||
|
|
2d985c1f91 | ||
|
|
5879ccbeb2 | ||
|
|
7887f4867d | ||
|
|
c38a6cdfbf | ||
|
|
ea7766db44 | ||
|
|
a5012ce49e | ||
|
|
d3da4f4623 | ||
|
|
7f12c2f8b8 | ||
|
|
9ba2dea148 | ||
|
|
653bfa1f17 | ||
|
|
fa00b5b173 | ||
|
|
70fb6393b6 | ||
|
|
5b379666f4 | ||
|
|
3cb34d30a9 | ||
|
|
d47c93b4d8 | ||
|
|
bc5cc4bf02 | ||
|
|
8efa7d25f8 | ||
|
|
59195fec1a | ||
|
|
14e6a80049 | ||
|
|
67ab36e0ea | ||
|
|
dfc32967ed | ||
|
|
aa3c376def | ||
|
|
61c58caf78 | ||
|
|
b402cdf7ff | ||
|
|
d80513d011 | ||
|
|
4bcfbf785f | ||
|
|
b722dab56b | ||
|
|
6165e4a47f | ||
|
|
b829abed2d | ||
|
|
36f56ba9aa | ||
|
|
022b11cf6c | ||
|
|
8d6662cb48 | ||
|
|
a59a45f109 | ||
|
|
6337561f65 | ||
|
|
fbbc94028d | ||
|
|
93d955c4b9 | ||
|
|
1c71e6d474 | ||
|
|
b2d10b7a6b | ||
|
|
1215bcb046 | ||
|
|
9195a0324e | ||
|
|
acbec213e8 | ||
|
|
e2a08e31e8 | ||
|
|
e479ee3dbc | ||
|
|
f6462ef998 | ||
|
|
dcdf49a5ce | ||
|
|
74f72fa5b6 | ||
|
|
36f33fed75 | ||
|
|
eb7c05fd4c | ||
|
|
cb746fd722 | ||
|
|
0449bc359a | ||
|
|
d3e51ffb1c | ||
|
|
77eb70626c | ||
|
|
345c4f096e | ||
|
|
a4aab3fd4e | ||
|
|
ecf770e183 | ||
|
|
d58911ac60 | ||
|
|
bb0a35b920 | ||
|
|
403649f2ea | ||
|
|
958f8387d0 | ||
|
|
9c89676030 | ||
|
|
34ec018840 | ||
|
|
1be103a249 | ||
|
|
f83f8bb789 | ||
|
|
cc2810b117 | ||
|
|
be1dae7ef0 | ||
|
|
446d26d8dc | ||
|
|
7724b49ec4 | ||
|
|
ecbd283779 | ||
|
|
389f750d7b | ||
|
|
23eaae80c8 | ||
|
|
8f8c2f852e | ||
|
|
13f7269e36 | ||
|
|
0cd62a07fb | ||
|
|
20b55693cb | ||
|
|
74cccf2c09 | ||
|
|
54d20aa99b | ||
|
|
2c8086f078 | ||
|
|
ea061a3ba6 | ||
|
|
28a6ba1b5d | ||
|
|
8b793a9ca9 | ||
|
|
fe1cf5d605 | ||
|
|
f0335b5aaa | ||
|
|
6c394ec375 | ||
|
|
9f49ce6dc9 | ||
|
|
0df331cf8a | ||
|
|
a5a04e1df7 | ||
|
|
170d1a3a9c | ||
|
|
ce941b6532 | ||
|
|
c5fc7df258 | ||
|
|
30844b8e21 | ||
|
|
99b00cedb4 | ||
|
|
63242384d6 | ||
|
|
e83d31a232 | ||
|
|
65c7b720de | ||
|
|
77ecfbac9f | ||
|
|
1a090a7c51 | ||
|
|
a88bf104df | ||
|
|
c9caa5f46b | ||
|
|
96ae5df1f1 | ||
|
|
6048f42740 | ||
|
|
5b199aa736 | ||
|
|
a6bb58bb45 | ||
|
|
a78db10798 | ||
|
|
479b3ccfb7 | ||
|
|
f916002a71 | ||
|
|
c5208eeaef | ||
|
|
2e8cbdc4aa | ||
|
|
77b0dfc8d3 | ||
|
|
c5c5681cfd | ||
|
|
808afa053f | ||
|
|
cb75d01fd3 | ||
|
|
3ae7bbf304 | ||
|
|
fc3d536433 | ||
|
|
36abf3f099 | ||
|
|
3d7fd5a30c | ||
|
|
f83d9fc03c | ||
|
|
94e6ba759e | ||
|
|
c8c30f327b | ||
|
|
72fae1af25 | ||
|
|
98f8bacdc8 | ||
|
|
06f6da725d | ||
|
|
d24eabb97c | ||
|
|
eca3f1d71e | ||
|
|
87d178773a | ||
|
|
02cb005668 | ||
|
|
cf1d5c098f | ||
|
|
65273b055c | ||
|
|
f171839830 | ||
|
|
8f9a5642f2 | ||
|
|
e906d5db25 | ||
|
|
80c09a07dc | ||
|
|
af6145600a | ||
|
|
42bda59392 | ||
|
|
e73f6505e9 | ||
|
|
332aa45618 | ||
|
|
253075e332 | ||
|
|
737b8f02b1 | ||
|
|
2a996e2c9a | ||
|
|
c77d627077 | ||
|
|
11daf93094 | ||
|
|
44b07ee35d | ||
|
|
b24de23219 | ||
|
|
431e2aaa13 | ||
|
|
9896c75a2e | ||
|
|
94cec70737 | ||
|
|
2ba4e51e93 | ||
|
|
665a62080b | ||
|
|
a05a7e45cc | ||
|
|
f8e9216270 | ||
|
|
475c1e38df | ||
|
|
80289f1dc3 | ||
|
|
ef16558947 | ||
|
|
c799f15fcc | ||
|
|
802402e922 | ||
|
|
37482bca7b | ||
|
|
184713dba8 | ||
|
|
0a0956cfc4 | ||
|
|
0a0bbad77f |
2
.github/workflows/nightly-build.yml
vendored
2
.github/workflows/nightly-build.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
ref: main
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@@ -113,5 +113,40 @@ 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 }}
|
||||
|
||||
dispatch-docs-update:
|
||||
needs: release
|
||||
if: success() && github.repository == 'CherryHQ/cherry-studio' # 确保所有构建成功且在主仓库中运行
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get release tag
|
||||
id: get-tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if tag is pre-release
|
||||
id: check-tag
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${{ steps.get-tag.outputs.tag }}"
|
||||
if [[ "$TAG" == *"rc"* || "$TAG" == *"pre-release"* ]]; then
|
||||
echo "is_pre_release=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_pre_release=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Dispatch update-download-version workflow to cherry-studio-docs
|
||||
if: steps.check-tag.outputs.is_pre_release == 'false'
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
|
||||
repository: CherryHQ/cherry-studio-docs
|
||||
event-type: update-download-version
|
||||
client-payload: '{"version": "${{ steps.get-tag.outputs.tag }}"}'
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -45,10 +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
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"printWidth": 120,
|
||||
"trailingComma": "none",
|
||||
"endOfLine": "lf",
|
||||
"bracketSameLine": true
|
||||
"bracketSameLine": true,
|
||||
"tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css",
|
||||
"tailwindFunctions": ["clsx"],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
}
|
||||
|
||||
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",
|
||||
85
.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch
vendored
85
.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch
vendored
@@ -1,85 +0,0 @@
|
||||
diff --git a/core.js b/core.js
|
||||
index 862d66101f441fb4f47dfc8cff5e2d39e1f5a11e..6464bebbf696c39d35f0368f061ea4236225c162 100644
|
||||
--- a/core.js
|
||||
+++ b/core.js
|
||||
@@ -159,7 +159,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 05dbc6cfde51589a2b100d4e4b5b3c1a33b32b89..789fbb4985eb952a0349b779fa83b1a068af6e7e 100644
|
||||
--- a/core.mjs
|
||||
+++ b/core.mjs
|
||||
@@ -152,7 +152,7 @@ export class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
...this.authHeaders(opts),
|
||||
};
|
||||
}
|
||||
diff --git a/error.mjs b/error.mjs
|
||||
index 7d19f5578040afa004bc887aab1725e8703d2bac..59ec725b6142299a62798ac4bdedb63ba7d9932c 100644
|
||||
--- a/error.mjs
|
||||
+++ b/error.mjs
|
||||
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: castToError(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/resources/embeddings.js b/resources/embeddings.js
|
||||
index aae578404cb2d09a39ac33fc416f1c215c45eecd..25c54b05bdae64d5c3b36fbb30dc7c8221b14034 100644
|
||||
--- a/resources/embeddings.js
|
||||
+++ b/resources/embeddings.js
|
||||
@@ -36,6 +36,9 @@ class Embeddings extends resource_1.APIResource {
|
||||
// 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) {
|
||||
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
|
||||
}
|
||||
@@ -47,7 +50,7 @@ class Embeddings extends resource_1.APIResource {
|
||||
...options,
|
||||
});
|
||||
// if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
|
||||
return response;
|
||||
}
|
||||
// in this stage, we are sure the user did not specify an encoding_format
|
||||
diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
|
||||
index 0df3c6cc79a520e54acb4c2b5f77c43b774035ff..aa488b8a11b2c413c0a663d9a6059d286d7b5faf 100644
|
||||
--- a/resources/embeddings.mjs
|
||||
+++ b/resources/embeddings.mjs
|
||||
@@ -10,6 +10,9 @@ export class Embeddings extends APIResource {
|
||||
// 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) {
|
||||
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
|
||||
}
|
||||
@@ -21,7 +24,7 @@ export class Embeddings extends APIResource {
|
||||
...options,
|
||||
});
|
||||
// if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
|
||||
return response;
|
||||
}
|
||||
// in this stage, we are sure the user did not specify an encoding_format
|
||||
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
|
||||
96
README.md
96
README.md
@@ -3,10 +3,42 @@
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p>
|
||||
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/dev.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
|
||||
|
||||
<!-- 题头徽章组合 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][deepwiki-shield]][deepwiki-link]
|
||||
[![][twitter-shield]][twitter-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
[![][telegram-shield]][telegram-link]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 项目统计徽章 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][license-shield]][license-link]
|
||||
[![][commercial-shield]][commercial-link]
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
<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>
|
||||
<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: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
@@ -17,10 +49,6 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
|
||||
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
|
||||
|
||||
# 📖 Guide
|
||||
|
||||
<https://docs.cherry-ai.com>
|
||||
|
||||
# 🌠 Screenshot
|
||||
|
||||

|
||||
@@ -114,14 +142,6 @@ Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/
|
||||
|
||||
Welcome PR for more themes
|
||||
|
||||
# 🖥️ Develop
|
||||
|
||||
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:
|
||||
@@ -134,6 +154,8 @@ We welcome contributions to Cherry Studio! Here are some ways you can contribute
|
||||
6. **Community Engagement**: Join discussions and help users.
|
||||
7. **Promote Usage**: Spread the word about Cherry Studio.
|
||||
|
||||
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork the Repository**: Fork and clone it to your local machine.
|
||||
@@ -158,22 +180,34 @@ Thank you for your support and contributions!
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 Community
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ Sponsor
|
||||
|
||||
[Buy Me a Coffee](docs/sponsor.md)
|
||||
|
||||
# 📃 License
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
# ✉️ Contact
|
||||
|
||||
<yinsenho@cherry-ai.com>
|
||||
|
||||
# ⭐️ Star History
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
|
||||
|
||||
<!-- Links & Images -->
|
||||
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
|
||||
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
|
||||
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
|
||||
[twitter-link]: https://twitter.com/CherryStudioApp
|
||||
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
|
||||
[discord-link]: https://discord.gg/wez8HtpxqQ
|
||||
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
|
||||
[telegram-link]: https://t.me/CherryStudioAI
|
||||
|
||||
<!-- Links & Images -->
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
|
||||
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
|
||||
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
|
||||
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
|
||||
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
|
||||
|
||||
<!-- Links & Images -->
|
||||
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
|
||||
[license-link]: https://www.gnu.org/licenses/agpl-3.0
|
||||
[commercial-shield]: https://img.shields.io/badge/License-Contact-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
|
||||
[commercial-link]: mailto:license@cherry-ai.com?subject=Commercial%20License%20Inquiry
|
||||
[sponsor-shield]: https://img.shields.io/badge/Sponsor-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
|
||||
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md
|
||||
|
||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/renderer/src/assets/styles/tailwind.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@renderer/ui/third-party",
|
||||
"utils": "@renderer/utils",
|
||||
"ui": "@renderer/ui",
|
||||
"lib": "@renderer/lib",
|
||||
"hooks": "@renderer/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,15 +1,46 @@
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/releases">
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 <br>
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 | <a href="https://cherry-ai.com">公式サイト</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/ja">ドキュメント</a> | <a href="./dev.md">開発</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">フィードバック</a><br>
|
||||
</p>
|
||||
|
||||
<!-- バッジコレクション -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][deepwiki-shield]][deepwiki-link]
|
||||
[![][twitter-shield]][twitter-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
[![][telegram-shield]][telegram-link]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- プロジェクト統計 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][license-shield]][license-link]
|
||||
[![][commercial-shield]][commercial-link]
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
<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>
|
||||
<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: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
@@ -20,10 +51,6 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
|
||||
|
||||
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!
|
||||
|
||||
# 📖 ガイド
|
||||
|
||||
https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 スクリーンショット
|
||||
|
||||

|
||||
@@ -117,14 +144,6 @@ https://docs.cherry-ai.com
|
||||
|
||||
より多くのテーマの PR を歓迎します
|
||||
|
||||
# 🖥️ 開発
|
||||
|
||||
[開発ドキュメント](dev.md)を参照してください
|
||||
|
||||
[アーキテクチャ概要ドキュメント](https://deepwiki.com/CherryHQ/cherry-studio)を参照してください
|
||||
|
||||
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
|
||||
|
||||
# 🤝 貢献
|
||||
|
||||
Cherry Studio への貢献を歓迎します!以下の方法で貢献できます:
|
||||
@@ -137,6 +156,8 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
|
||||
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します
|
||||
7. **使用の促進**:Cherry Studio を広めます
|
||||
|
||||
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
|
||||
|
||||
## 始め方
|
||||
|
||||
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします
|
||||
@@ -161,22 +182,34 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 コミュニティ
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ スポンサー
|
||||
|
||||
[開発者を支援する](sponsor.md)
|
||||
|
||||
# 📃 ライセンス
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ✉️ お問い合わせ
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ スター履歴
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
|
||||
|
||||
<!-- リンクと画像 -->
|
||||
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
|
||||
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
|
||||
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
|
||||
[twitter-link]: https://twitter.com/CherryStudioApp
|
||||
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
|
||||
[discord-link]: https://discord.gg/wez8HtpxqQ
|
||||
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
|
||||
[telegram-link]: https://t.me/CherryStudioAI
|
||||
|
||||
<!-- プロジェクト統計 -->
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
|
||||
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
|
||||
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
|
||||
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
|
||||
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
|
||||
|
||||
<!-- ライセンスとスポンサー -->
|
||||
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
|
||||
[license-link]: https://www.gnu.org/licenses/agpl-3.0
|
||||
[commercial-shield]: https://img.shields.io/badge/商用ライセンス-お問い合わせ-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
|
||||
[commercial-link]: mailto:license@cherry-ai.com?subject=商業ライセンスについて
|
||||
[sponsor-shield]: https://img.shields.io/badge/スポンサー-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
|
||||
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/releases">
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br>
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a> | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/zh-cn">文档</a> | <a href="./dev.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
|
||||
</p>
|
||||
|
||||
<!-- 题头徽章组合 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][deepwiki-shield]][deepwiki-link]
|
||||
[![][twitter-shield]][twitter-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
[![][telegram-shield]][telegram-link]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 项目统计徽章 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][license-shield]][license-link]
|
||||
[![][commercial-shield]][commercial-link]
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
<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>
|
||||
<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: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
@@ -124,14 +156,6 @@ https://docs.cherry-ai.com
|
||||
|
||||
欢迎 PR 更多主题
|
||||
|
||||
# 🖥️ 开发
|
||||
|
||||
参考[开发文档](dev.md)
|
||||
|
||||
参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio)
|
||||
|
||||
参考[分支策略](branching-strategy-zh.md)了解贡献指南
|
||||
|
||||
# 🤝 贡献
|
||||
|
||||
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
|
||||
@@ -144,6 +168,8 @@ https://docs.cherry-ai.com
|
||||
6. **社区参与**:加入讨论并帮助用户
|
||||
7. **推广使用**:宣传 Cherry Studio
|
||||
|
||||
参考[分支策略](branching-strategy-zh.md)了解贡献指南
|
||||
|
||||
## 入门
|
||||
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器
|
||||
@@ -168,22 +194,34 @@ https://docs.cherry-ai.com
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 社区
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ 赞助
|
||||
|
||||
[赞助开发者](sponsor.md)
|
||||
|
||||
# 📃 许可证
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ✉️ 联系我们
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
|
||||
|
||||
<!-- Links & Images -->
|
||||
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
|
||||
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
|
||||
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
|
||||
[twitter-link]: https://twitter.com/CherryStudioApp
|
||||
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
|
||||
[discord-link]: https://discord.gg/wez8HtpxqQ
|
||||
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
|
||||
[telegram-link]: https://t.me/CherryStudioAI
|
||||
|
||||
<!-- 项目统计徽章 -->
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
|
||||
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
|
||||
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
|
||||
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
|
||||
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
|
||||
|
||||
<!-- 许可和赞助徽章 -->
|
||||
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
|
||||
[license-link]: https://www.gnu.org/licenses/agpl-3.0
|
||||
[commercial-shield]: https://img.shields.io/badge/商用授权-联系-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
|
||||
[commercial-link]: mailto:license@cherry-ai.com?subject=商业授权咨询
|
||||
[sponsor-shield]: https://img.shields.io/badge/赞助支持-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
|
||||
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md
|
||||
|
||||
@@ -12,30 +12,43 @@ electronLanguages:
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!{.vscode,.yarn,.github}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '**/*'
|
||||
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}}'
|
||||
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!**/{.editorconfig,.jekyll-metadata}'
|
||||
- '!src'
|
||||
- '!scripts'
|
||||
- '!local'
|
||||
- '!docs'
|
||||
- '!packages'
|
||||
- '!.swc'
|
||||
- '!.bin'
|
||||
- '!._*'
|
||||
- '!*.log'
|
||||
- '!stats.html'
|
||||
- '!*.md'
|
||||
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
|
||||
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
|
||||
- '!**/{test,tests,__tests__,coverage}/**'
|
||||
- '!**/{test,tests,__tests__,powered-test,coverage}/**'
|
||||
- '!**/{example,examples}/**'
|
||||
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||
- '!**/*.min.*.map'
|
||||
- '!**/*.d.ts'
|
||||
- '!**/{.DS_Store,Thumbs.db}'
|
||||
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}'
|
||||
- '!**/dist/es6/**'
|
||||
- '!**/dist/demo/**'
|
||||
- '!**/amd/**'
|
||||
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
|
||||
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
|
||||
- '!node_modules/rollup-plugin-visualizer'
|
||||
- '!node_modules/js-tiktoken'
|
||||
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
|
||||
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
@@ -94,10 +107,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
⚠️ 注意:升级前请备份数据,否则将无法降级
|
||||
文生图新增服务商 DMXAPI(限时免费)
|
||||
输入框按钮支持拖拽排序
|
||||
修复知识库搜索结果 100% 问题
|
||||
修复拖拽多选消息相关问题
|
||||
修复翻译回复内容导致内存异常问题
|
||||
常规错误修复和优化
|
||||
新增划词助手
|
||||
助手支持分组
|
||||
支持主题颜色切换
|
||||
划词助手支持应用过滤
|
||||
翻译模块功能改进
|
||||
|
||||
@@ -9,25 +9,7 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@cherrystudio/embedjs',
|
||||
'@cherrystudio/embedjs-openai',
|
||||
'@cherrystudio/embedjs-loader-web',
|
||||
'@cherrystudio/embedjs-loader-markdown',
|
||||
'@cherrystudio/embedjs-loader-msoffice',
|
||||
'@cherrystudio/embedjs-loader-xml',
|
||||
'@cherrystudio/embedjs-loader-pdf',
|
||||
'@cherrystudio/embedjs-loader-sitemap',
|
||||
'@cherrystudio/embedjs-libsql',
|
||||
'@cherrystudio/embedjs-loader-image',
|
||||
'p-queue',
|
||||
'webdav'
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('main')
|
||||
],
|
||||
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
@@ -37,7 +19,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client']
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate']
|
||||
},
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
},
|
||||
@@ -58,6 +40,7 @@ export default defineConfig({
|
||||
},
|
||||
renderer: {
|
||||
plugins: [
|
||||
(async () => (await import('@tailwindcss/vite')).default())(),
|
||||
react({
|
||||
plugins: [
|
||||
[
|
||||
@@ -89,7 +72,9 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ export default defineConfig([
|
||||
'.yarn/**',
|
||||
'.gitignore',
|
||||
'scripts/cloudflare-worker.js',
|
||||
'src/main/integration/nutstore/sso/lib/**'
|
||||
'src/main/integration/nutstore/sso/lib/**',
|
||||
'src/renderer/src/ui/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
81
package.json
81
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.3.12",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -22,7 +22,7 @@
|
||||
"dev": "electron-vite dev",
|
||||
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
|
||||
"build:check": "yarn typecheck && yarn check:i18n && yarn test",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
@@ -38,19 +38,20 @@
|
||||
"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",
|
||||
@@ -69,14 +70,12 @@
|
||||
"@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",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tanstack/react-query": "^5.27.0",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"electron-log": "^5.1.5",
|
||||
@@ -84,22 +83,19 @@
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-diff": "^1.3.0",
|
||||
"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",
|
||||
"markdown-it": "^14.1.0",
|
||||
"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.22",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"webdav": "^5.8.0",
|
||||
"ws": "^8.18.1",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -112,18 +108,32 @@
|
||||
"@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.13.0",
|
||||
"@google/genai": "^1.0.1",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
|
||||
"@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/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
@@ -137,17 +147,21 @@
|
||||
"@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/ui": "^3.1.1",
|
||||
"@vitest/web-worker": "^3.1.3",
|
||||
"@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",
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"color": "^5.0.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
@@ -155,7 +169,6 @@
|
||||
"electron": "35.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^3.1.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
@@ -163,20 +176,25 @@
|
||||
"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",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"motion": "^12.12.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.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",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -200,26 +218,31 @@
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.4.2",
|
||||
"sonner": "^2.0.3",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^0.4.1",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.6.2",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"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.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.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",
|
||||
"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",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.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"
|
||||
"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.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -11,9 +11,9 @@ 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_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_SetFeedUrl = 'app:set-feed-url',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
@@ -21,6 +21,8 @@ export enum IpcChannel {
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
|
||||
App_QuoteToMain = 'app:quote-to-main',
|
||||
|
||||
Notification_Send = 'notification:send',
|
||||
Notification_OnClick = 'notification:on-click',
|
||||
|
||||
@@ -111,6 +113,7 @@ export enum IpcChannel {
|
||||
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_BinaryImage = 'file:binaryImage',
|
||||
@@ -144,7 +147,7 @@ export enum IpcChannel {
|
||||
|
||||
// events
|
||||
BackupProgress = 'backup-progress',
|
||||
ThemeChange = 'theme:change',
|
||||
ThemeUpdated = 'theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
@@ -176,5 +179,23 @@ export enum IpcChannel {
|
||||
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
|
||||
|
||||
// Provider
|
||||
Provider_AddKey = 'provider:add-key'
|
||||
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,368 @@ 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]
|
||||
|
||||
@@ -170,3 +403,8 @@ export const KB = 1024
|
||||
export const MB = 1024 * KB
|
||||
export const GB = 1024 * MB
|
||||
export const defaultLanguage = 'en-US'
|
||||
|
||||
export enum FeedUrl {
|
||||
PRODUCTION = 'https://releases.cherry-ai.com',
|
||||
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||
}
|
||||
|
||||
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,
|
||||
// },
|
||||
})
|
||||
@@ -11,13 +11,13 @@ if (isDev) {
|
||||
export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
}
|
||||
|
||||
export const titleBarOverlayLight = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000'
|
||||
}
|
||||
|
||||
58
src/main/configs/SelectionConfig.ts
Normal file
58
src/main/configs/SelectionConfig.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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: [
|
||||
'explorer.exe',
|
||||
// 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']
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { isDev } from './constant'
|
||||
import { isDev, isWin } from './constant'
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
@@ -16,6 +16,7 @@ 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'
|
||||
@@ -23,6 +24,16 @@ 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
|
||||
@@ -84,6 +95,9 @@ if (!app.requestSingleInstanceLock()) {
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
|
||||
//start selection assistant service
|
||||
initSelectionService()
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
@@ -110,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 () => {
|
||||
|
||||
@@ -6,11 +6,10 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro
|
||||
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'
|
||||
@@ -18,7 +17,6 @@ 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'
|
||||
@@ -26,15 +24,17 @@ 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 storeSyncService from './services/StoreSyncService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
@@ -113,10 +113,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
|
||||
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
|
||||
appUpdater.setFeedUrl(feedUrl)
|
||||
})
|
||||
|
||||
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) => {
|
||||
@@ -125,34 +127,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// theme
|
||||
ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => {
|
||||
const updateTitleBarOverlay = () => {
|
||||
if (!mainWindow?.setTitleBarOverlay) return
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
mainWindow.setTitleBarOverlay(isDark ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
}
|
||||
|
||||
const broadcastThemeChange = () => {
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
const effectiveTheme = isDark ? ThemeMode.dark : ThemeMode.light
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.webContents.send(IpcChannel.ThemeChange, effectiveTheme))
|
||||
}
|
||||
|
||||
const notifyThemeChange = () => {
|
||||
updateTitleBarOverlay()
|
||||
broadcastThemeChange()
|
||||
}
|
||||
|
||||
if (theme === ThemeMode.auto) {
|
||||
nativeTheme.themeSource = 'system'
|
||||
nativeTheme.on('updated', notifyThemeChange)
|
||||
} else {
|
||||
nativeTheme.themeSource = theme
|
||||
nativeTheme.off('updated', notifyThemeChange)
|
||||
}
|
||||
|
||||
updateTitleBarOverlay()
|
||||
configManager.setTheme(theme)
|
||||
notifyThemeChange()
|
||||
themeService.setTheme(theme)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => {
|
||||
@@ -249,6 +224,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
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)
|
||||
@@ -297,13 +273,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())
|
||||
@@ -379,4 +348,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// store sync
|
||||
storeSyncService.registerIpcHandler()
|
||||
|
||||
// selection assistant
|
||||
SelectionService.registerIpcHandler()
|
||||
|
||||
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
@@ -19,6 +21,7 @@ export default class AppUpdater {
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
autoUpdater.setFeedURL(configManager.getFeedUrl())
|
||||
|
||||
// 检测下载错误
|
||||
autoUpdater.on('error', (error) => {
|
||||
@@ -61,6 +64,11 @@ export default class AppUpdater {
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
}
|
||||
|
||||
public setFeedUrl(feedUrl: FeedUrl) {
|
||||
autoUpdater.setFeedURL(feedUrl)
|
||||
configManager.setFeedUrl(feedUrl)
|
||||
}
|
||||
|
||||
public async checkForUpdates() {
|
||||
if (isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env) {
|
||||
return {
|
||||
@@ -94,15 +102,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 +133,7 @@ export default class AppUpdater {
|
||||
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return '暂无更新说明'
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
|
||||
@@ -7,7 +7,7 @@ import Logger from 'electron-log'
|
||||
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'
|
||||
@@ -295,10 +295,12 @@ 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, undefined, webdavConfig.skipBackupFile)
|
||||
const contentLength = (await fs.stat(backupedFilePath)).size
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
try {
|
||||
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
overwrite: true
|
||||
overwrite: true,
|
||||
contentLength
|
||||
})
|
||||
// 上传成功后删除本地备份文件
|
||||
await fs.remove(backupedFilePath)
|
||||
@@ -340,12 +342,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,11 +1,11 @@
|
||||
import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { defaultLanguage, FeedUrl, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
|
||||
import { locales } from '../utils/locales'
|
||||
|
||||
enum ConfigKeys {
|
||||
export enum ConfigKeys {
|
||||
Language = 'language',
|
||||
Theme = 'theme',
|
||||
LaunchToTray = 'launchToTray',
|
||||
@@ -16,7 +16,14 @@ enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
EnableDataCollection = 'enableDataCollection'
|
||||
FeedUrl = 'feedUrl',
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
|
||||
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
|
||||
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -32,12 +39,12 @@ 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) {
|
||||
@@ -57,8 +64,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setTray(value: boolean) {
|
||||
this.set(ConfigKeys.Tray, value)
|
||||
this.notifySubscribers(ConfigKeys.Tray, value)
|
||||
this.setAndNotify(ConfigKeys.Tray, value)
|
||||
}
|
||||
|
||||
getTrayOnClose(): boolean {
|
||||
@@ -74,8 +80,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) {
|
||||
@@ -107,11 +112,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 {
|
||||
@@ -127,7 +131,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setEnableQuickAssistant(value: boolean) {
|
||||
this.set(ConfigKeys.EnableQuickAssistant, value)
|
||||
this.setAndNotify(ConfigKeys.EnableQuickAssistant, value)
|
||||
}
|
||||
|
||||
getAutoUpdate(): boolean {
|
||||
@@ -138,6 +142,14 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.AutoUpdate, value)
|
||||
}
|
||||
|
||||
getFeedUrl(): string {
|
||||
return this.get<string>(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION)
|
||||
}
|
||||
|
||||
setFeedUrl(value: FeedUrl) {
|
||||
this.set(ConfigKeys.FeedUrl, value)
|
||||
}
|
||||
|
||||
getEnableDataCollection(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.EnableDataCollection, true)
|
||||
}
|
||||
@@ -146,8 +158,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, false)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
export default class FileService {
|
||||
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
|
||||
return fs.readFileSync(path, 'utf8')
|
||||
public static async readFile(_: Electron.IpcMainInvokeEvent, pathOrUrl: string, encoding?: BufferEncoding) {
|
||||
const path = pathOrUrl.startsWith('file://') ? new URL(pathOrUrl) : pathOrUrl
|
||||
if (encoding) return fs.readFile(path, { encoding })
|
||||
return fs.readFile(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,51 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,79 +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, baseURL }: { apiKey: string; baseURL: string }
|
||||
): Promise<File> {
|
||||
const sdk = new GoogleGenAI({
|
||||
vertexai: false,
|
||||
apiKey,
|
||||
httpOptions: {
|
||||
baseUrl: baseURL
|
||||
}
|
||||
})
|
||||
|
||||
return await sdk.files.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
displayName: file.origin_name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
1250
src/main/services/SelectionService.ts
Normal file
1250
src/main/services/SelectionService.ts
Normal file
File diff suppressed because it is too large
Load Diff
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,5 +1,7 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import https from 'https'
|
||||
import path from 'path'
|
||||
import Stream from 'stream'
|
||||
import {
|
||||
BufferLike,
|
||||
@@ -14,13 +16,14 @@ export default class WebDav {
|
||||
private webdavPath: string
|
||||
|
||||
constructor(params: WebDavConfig) {
|
||||
this.webdavPath = params.webdavPath
|
||||
this.webdavPath = params.webdavPath || '/'
|
||||
|
||||
this.instance = createClient(params.webdavHost, {
|
||||
username: params.webdavUser,
|
||||
password: params.webdavPass,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity
|
||||
maxContentLength: Infinity,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false })
|
||||
})
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
@@ -49,7 +52,7 @@ export default class WebDav {
|
||||
throw error
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
const remoteFilePath = path.posix.join(this.webdavPath, filename)
|
||||
|
||||
try {
|
||||
return await this.instance.putFileContents(remoteFilePath, data, options)
|
||||
@@ -64,7 +67,7 @@ export default class WebDav {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
const remoteFilePath = path.posix.join(this.webdavPath, filename)
|
||||
|
||||
try {
|
||||
return await this.instance.getFileContents(remoteFilePath, options)
|
||||
@@ -74,6 +77,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')
|
||||
@@ -105,7 +121,7 @@ export default class WebDav {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
const remoteFilePath = path.posix.join(this.webdavPath, filename)
|
||||
|
||||
try {
|
||||
return await this.instance.deleteFile(remoteFilePath)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// 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, nativeTheme, shell } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
@@ -45,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,
|
||||
@@ -61,14 +56,14 @@ export class WindowService {
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
transparent: false,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -549,6 +544,25 @@ export class WindowService {
|
||||
public setPinMiniWindow(isPinned) {
|
||||
this.isPinnedMiniWindow = isPinned
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用文本到主窗口
|
||||
* @param text 原始文本(未格式化)
|
||||
*/
|
||||
public quoteToMainWindow(text: string): void {
|
||||
try {
|
||||
this.showMainWindow()
|
||||
|
||||
const mainWindow = this.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
setTimeout(() => {
|
||||
mainWindow.webContents.send(IpcChannel.App_QuoteToMain, text)
|
||||
}, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to quote to main window:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const windowService = WindowService.getInstance()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth'
|
||||
import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import Logger from 'electron-log'
|
||||
import open from 'open'
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/mcp-servers')")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
71
src/main/utils/__tests__/aes.test.ts
Normal file
71
src/main/utils/__tests__/aes.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { decrypt, encrypt } from '../aes'
|
||||
|
||||
const key = '12345678901234567890123456789012' // 32字节
|
||||
const iv = '1234567890abcdef1234567890abcdef' // 32字节hex,实际应16字节hex
|
||||
|
||||
function getIv16() {
|
||||
// 取前16字节作为 hex
|
||||
return iv.slice(0, 32)
|
||||
}
|
||||
|
||||
describe('aes utils', () => {
|
||||
it('should encrypt and decrypt normal string', () => {
|
||||
const text = 'hello world'
|
||||
const { iv: outIv, encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(typeof encryptedData).toBe('string')
|
||||
expect(outIv).toBe(getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should support unicode and special chars', () => {
|
||||
const text = '你好,世界!🌟🚀'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const text = ''
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should encrypt and decrypt long string', () => {
|
||||
const text = 'a'.repeat(100_000)
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should throw error for wrong key', () => {
|
||||
const text = 'test'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for wrong iv', () => {
|
||||
const text = 'test'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid key/iv length', () => {
|
||||
expect(() => encrypt('test', 'shortkey', getIv16())).toThrow()
|
||||
expect(() => encrypt('test', key, 'shortiv')).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid encrypted data', () => {
|
||||
expect(() => decrypt('nothexdata', getIv16(), key)).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for non-string input', () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
expect(() => encrypt(null, key, getIv16())).toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
expect(() => decrypt(null, getIv16(), key)).toThrow()
|
||||
})
|
||||
})
|
||||
243
src/main/utils/__tests__/file.test.ts
Normal file
243
src/main/utils/__tests__/file.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileTypes } from '@types'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
vi.mock('node:os')
|
||||
vi.mock('node:path')
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'mock-uuid'
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((key) => {
|
||||
if (key === 'temp') return '/mock/temp'
|
||||
if (key === 'userData') return '/mock/userData'
|
||||
return '/mock/unknown'
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
describe('file', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock path.extname
|
||||
vi.mocked(path.extname).mockImplementation((file) => {
|
||||
const parts = file.split('.')
|
||||
return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''
|
||||
})
|
||||
|
||||
// Mock path.basename
|
||||
vi.mocked(path.basename).mockImplementation((file) => {
|
||||
const parts = file.split('/')
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
|
||||
// Mock path.join
|
||||
vi.mocked(path.join).mockImplementation((...args) => args.join('/'))
|
||||
|
||||
// Mock os.homedir
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('getFileType', () => {
|
||||
it('should return IMAGE for image extensions', () => {
|
||||
expect(getFileType('.jpg')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.png')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.gif')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.webp')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.bmp')).toBe(FileTypes.IMAGE)
|
||||
})
|
||||
|
||||
it('should return VIDEO for video extensions', () => {
|
||||
expect(getFileType('.mp4')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.avi')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.mov')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.mkv')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.flv')).toBe(FileTypes.VIDEO)
|
||||
})
|
||||
|
||||
it('should return AUDIO for audio extensions', () => {
|
||||
expect(getFileType('.mp3')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.wav')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.ogg')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.flac')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.aac')).toBe(FileTypes.AUDIO)
|
||||
})
|
||||
|
||||
it('should return TEXT for text extensions', () => {
|
||||
expect(getFileType('.txt')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.md')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.html')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.json')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.js')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.ts')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.css')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.java')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.py')).toBe(FileTypes.TEXT)
|
||||
})
|
||||
|
||||
it('should return DOCUMENT for document extensions', () => {
|
||||
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should return OTHER for unknown extensions', () => {
|
||||
expect(getFileType('.unknown')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('...')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.123')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
|
||||
it('should handle case-insensitive extensions', () => {
|
||||
expect(getFileType('.JPG')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.HtMl')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should handle extensions without leading dot', () => {
|
||||
expect(getFileType('jpg')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('pdf')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('mp3')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
|
||||
it('should handle extreme cases', () => {
|
||||
expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.文件')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.файл')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllFiles', () => {
|
||||
it('should return all valid files recursively', () => {
|
||||
// Mock file system
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => {
|
||||
if (dirPath === '/test') {
|
||||
return ['file1.txt', 'file2.pdf', 'subdir']
|
||||
} else if (dirPath === '/test/subdir') {
|
||||
return ['file3.md', 'file4.docx']
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
vi.mocked(fs.statSync).mockImplementation((filePath) => {
|
||||
const isDir = String(filePath).endsWith('subdir')
|
||||
return {
|
||||
isDirectory: () => isDir,
|
||||
size: 1024
|
||||
} as fs.Stats
|
||||
})
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].id).toBe('mock-uuid')
|
||||
expect(result[0].name).toBe('file1.txt')
|
||||
expect(result[0].type).toBe(FileTypes.TEXT)
|
||||
expect(result[1].name).toBe('file2.pdf')
|
||||
expect(result[1].type).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should skip hidden files', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt'])
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => false,
|
||||
size: 1024
|
||||
} as fs.Stats)
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('visible.txt')
|
||||
})
|
||||
|
||||
it('should skip unsupported file types', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf'])
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => false,
|
||||
size: 1024
|
||||
} as fs.Stats)
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
// Should only include document.pdf as the others are excluded types
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('document.pdf')
|
||||
expect(result[0].type).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should return empty array for empty directory', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue([])
|
||||
|
||||
const result = getAllFiles('/empty')
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle file system errors', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
|
||||
throw new Error('Directory not found')
|
||||
})
|
||||
|
||||
// Since the function doesn't have error handling, we expect it to propagate
|
||||
expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTempDir', () => {
|
||||
it('should return correct temp directory path', () => {
|
||||
const tempDir = getTempDir()
|
||||
expect(tempDir).toBe('/mock/temp/CherryStudio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilesDir', () => {
|
||||
it('should return correct files directory path', () => {
|
||||
const filesDir = getFilesDir()
|
||||
expect(filesDir).toBe('/mock/userData/Data/Files')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfigDir', () => {
|
||||
it('should return correct config directory path', () => {
|
||||
const configDir = getConfigDir()
|
||||
expect(configDir).toBe('/mock/home/.cherrystudio/config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAppConfigDir', () => {
|
||||
it('should return correct app config directory path', () => {
|
||||
const appConfigDir = getAppConfigDir('test-app')
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app')
|
||||
})
|
||||
|
||||
it('should handle empty app name', () => {
|
||||
const appConfigDir = getAppConfigDir('')
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
|
||||
})
|
||||
})
|
||||
})
|
||||
61
src/main/utils/__tests__/zip.test.ts
Normal file
61
src/main/utils/__tests__/zip.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compress, decompress } from '../zip'
|
||||
|
||||
const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] })
|
||||
|
||||
// 辅助函数:生成大字符串
|
||||
function makeLargeString(size: number) {
|
||||
return 'a'.repeat(size)
|
||||
}
|
||||
|
||||
describe('zip', () => {
|
||||
describe('compress & decompress', () => {
|
||||
it('should compress and decompress a normal JSON string', async () => {
|
||||
const compressed = await compress(jsonStr)
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe(jsonStr)
|
||||
})
|
||||
|
||||
it('should handle empty string', async () => {
|
||||
const compressed = await compress('')
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe('')
|
||||
})
|
||||
|
||||
it('should handle large string', async () => {
|
||||
const largeStr = makeLargeString(100_000)
|
||||
const compressed = await compress(largeStr)
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
expect(compressed.length).toBeLessThan(largeStr.length)
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe(largeStr)
|
||||
})
|
||||
|
||||
it('should throw error when decompressing invalid buffer', async () => {
|
||||
const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8')
|
||||
await expect(decompress(invalidBuffer)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error when compress input is not string', async () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(null)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(undefined)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(123)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error when decompress input is not buffer', async () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress(null)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress(undefined)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress('string')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip)
|
||||
|
||||
/**
|
||||
* 压缩字符串
|
||||
* @param {string} str 要压缩的 JSON 字符串
|
||||
* @returns {Promise<Buffer>} 压缩后的 Buffer
|
||||
* @param str
|
||||
*/
|
||||
export async function compress(str) {
|
||||
export async function compress(str: string): Promise<Buffer> {
|
||||
try {
|
||||
const buffer = Buffer.from(str, 'utf-8')
|
||||
return await gzipPromise(buffer)
|
||||
@@ -27,7 +27,7 @@ export async function compress(str) {
|
||||
* @param {Buffer} compressedBuffer - 压缩的 Buffer
|
||||
* @returns {Promise<string>} 解压缩后的 JSON 字符串
|
||||
*/
|
||||
export async function decompress(compressedBuffer) {
|
||||
export async function decompress(compressedBuffer: Buffer): Promise<string> {
|
||||
try {
|
||||
const buffer = await gunzipPromise(compressedBuffer)
|
||||
return buffer.toString('utf-8')
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
|
||||
@@ -18,8 +21,8 @@ const api = {
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
|
||||
setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl),
|
||||
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
@@ -74,14 +77,16 @@ const api = {
|
||||
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||
download: (url: string, isUseContentType?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
},
|
||||
fs: {
|
||||
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
||||
@@ -124,7 +129,8 @@ const api = {
|
||||
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
|
||||
},
|
||||
miniWindow: {
|
||||
@@ -204,7 +210,26 @@ const api = {
|
||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||
unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
|
||||
onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action)
|
||||
}
|
||||
},
|
||||
selection: {
|
||||
hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide),
|
||||
writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text),
|
||||
determineToolbarSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height),
|
||||
setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled),
|
||||
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
|
||||
setFollowToolbar: (isFollowToolbar: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
|
||||
setRemeberWinSize: (isRemeberWinSize: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
|
||||
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
|
||||
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text)
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('electron-log/renderer', () => {
|
||||
return {
|
||||
default: {
|
||||
info: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
debug: console.debug,
|
||||
verbose: console.log,
|
||||
silly: console.log,
|
||||
log: console.log,
|
||||
transports: {
|
||||
console: {
|
||||
level: 'info'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
on: vi.fn(), // Mocking ipcRenderer.on
|
||||
send: vi.fn() // Mocking ipcRenderer.send
|
||||
}
|
||||
},
|
||||
api: {
|
||||
file: {
|
||||
read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
|
||||
writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
|
||||
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
|
||||
// You can add other axios methods like put, delete etc. as needed
|
||||
}
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
...global.window, // Copy other global properties
|
||||
addEventListener: vi.fn(), // Mock addEventListener
|
||||
removeEventListener: vi.fn() // You can also mock removeEventListener if needed
|
||||
})
|
||||
41
src/renderer/selectionAction.html
Normal file
41
src/renderer/selectionAction.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Assistant</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
43
src/renderer/selectionToolbar.html
Normal file
43
src/renderer/selectionToolbar.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@ import { Provider } from 'react-redux'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import MainSidebar from './components/app/MainSidebar'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
@@ -13,14 +13,9 @@ import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import DiscoverPage from './pages/discover'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
@@ -34,16 +29,18 @@ function App(): React.ReactElement {
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<MainSidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
{/* <Route path="/agents" element={<AgentsPage />} /> */}
|
||||
{/* <Route path="/paintings/*" element={<PaintingsRoutePage />} /> */}
|
||||
{/* <Route path="/translate" element={<TranslatePage />} /> */}
|
||||
{/* <Route path="/files" element={<FilesPage />} /> */}
|
||||
{/* <Route path="/knowledge" element={<KnowledgePage />} /> */}
|
||||
{/* <Route path="/apps" element={<AppsPage />} /> */}
|
||||
{/* <Route path="/mcp-servers/*" element={<McpServersPage />} /> */}
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/discover/*" element={<DiscoverPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -25,7 +25,6 @@
|
||||
}
|
||||
|
||||
.minapp-drawer {
|
||||
max-width: calc(100vw - var(--sidebar-width));
|
||||
.ant-drawer-content-wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -33,7 +32,7 @@
|
||||
position: absolute;
|
||||
-webkit-app-region: drag;
|
||||
min-height: calc(var(--navbar-height) + 0.5px);
|
||||
width: calc(100vw - var(--sidebar-width));
|
||||
width: 100%;
|
||||
margin-top: -0.5px;
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -206,8 +205,14 @@
|
||||
|
||||
.ant-collapse {
|
||||
border: 1px solid var(--color-border);
|
||||
.ant-color-picker & {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-text-secondary: rgba(235, 235, 245, 0.7);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border: #383838;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
@@ -43,6 +44,9 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: rgba(255, 255, 255, 0.1);
|
||||
--color-list-item-hover: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
@@ -52,7 +56,7 @@
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--navbar-height: 42px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
@@ -67,7 +71,8 @@
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
--list-item-border-radius: 8px;
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
@@ -98,6 +103,7 @@
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-text-secondary: rgba(0, 0, 0, 0.75);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000019;
|
||||
@@ -115,6 +121,9 @@
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--color-list-item: rgba(255, 255, 255, 0.9);
|
||||
--color-list-item-hover: rgba(255, 255, 255, 0.5);
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--color-highlight: initial;
|
||||
@@ -128,4 +137,6 @@
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-text-user: var(--color-text);
|
||||
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#content-container {
|
||||
background-color: var(--color-background);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-container {
|
||||
@@ -10,3 +8,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', serif, Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
// margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -147,11 +147,16 @@ ul {
|
||||
background-color: var(--color-white-soft);
|
||||
}
|
||||
}
|
||||
.group-grid-container.horizontal,
|
||||
.group-grid-container.grid {
|
||||
.message-content-container-assistant {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.group-message-wrapper {
|
||||
background-color: var(--color-background);
|
||||
.message-content-container {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-background-mute);
|
||||
}
|
||||
}
|
||||
.group-menu-bar {
|
||||
@@ -170,6 +175,7 @@ span.highlight {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
span.highlight.selected {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
@@ -299,15 +299,21 @@ emoji-picker {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
border-radius: 5px;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
font-family: var(--code-font-family);
|
||||
padding: 1px;
|
||||
border-radius: 5px;
|
||||
|
||||
.cm-gutters {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
:root {
|
||||
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(255, 255, 255, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(0, 0, 0, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 全局初始化滚动条样式 */
|
||||
|
||||
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@use './font.scss';
|
||||
|
||||
html {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
|
||||
--color-selection-toolbar-hover-bg: #222222;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-error: #f44336;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
|
||||
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
146
src/renderer/src/assets/styles/tailwind.css
Normal file
146
src/renderer/src/assets/styles/tailwind.css
Normal file
@@ -0,0 +1,146 @@
|
||||
@import 'tailwindcss' source('../../../src');
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* 如需自定义:
|
||||
1. 清晰地组织自定义 CSS 到相应的层中。
|
||||
2. 基础样式(如全局重置、链接样式)放入 base 层;
|
||||
3. 可复用的组件样式(如果仍使用 @apply 或原生 CSS 嵌套创建)放入 components 层;
|
||||
4. 新的自定义工具类放入 utilities 层。
|
||||
*/
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -134,26 +134,31 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
return () => cleanupTokenizers(callerId)
|
||||
}, [callerId, cleanupTokenizers])
|
||||
|
||||
// 处理第二次开始的代码高亮
|
||||
// 触发代码高亮
|
||||
// - 进入视口后触发第一次高亮
|
||||
// - 内容变化后触发之后的高亮
|
||||
useEffect(() => {
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
}
|
||||
}, [highlightCode])
|
||||
|
||||
// 视口检测逻辑,只处理第一次代码高亮
|
||||
useEffect(() => {
|
||||
const codeElement = codeContentRef.current
|
||||
if (!codeElement || prevCodeLengthRef.current > 0) return
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
observer.disconnect()
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const codeElement = codeContentRef.current
|
||||
if (!codeElement) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].intersectionRatio > 0 && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '50px 0px 50px 0px'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
observer.observe(codeElement)
|
||||
|
||||
@@ -231,7 +236,6 @@ const ContentContainer = styled.div<{
|
||||
$wrap: boolean
|
||||
$fadeIn: boolean
|
||||
}>`
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border: 0.5px solid transparent;
|
||||
@@ -239,12 +243,11 @@ const ContentContainer = styled.div<{
|
||||
margin-top: 0;
|
||||
|
||||
.shiki {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
padding: 1em;
|
||||
|
||||
code {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.line {
|
||||
display: block;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex } from 'antd'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@@ -10,12 +12,16 @@ interface Props {
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const { mermaid, isLoading, error: mermaidError } = useMermaid()
|
||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isRendering, setIsRendering] = useState(false)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
|
||||
@@ -32,55 +38,69 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
const render = useCallback(async () => {
|
||||
try {
|
||||
if (!children) return
|
||||
// 实际的渲染函数
|
||||
const renderMermaid = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content || !mermaidRef.current) return
|
||||
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(children)
|
||||
try {
|
||||
setIsRendering(true)
|
||||
|
||||
if (!mermaidRef.current) return
|
||||
const { svg } = await mermaid.render(diagramId, children, mermaidRef.current)
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(content)
|
||||
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
const { svg } = await mermaid.render(diagramId, content, mermaidRef.current)
|
||||
|
||||
// 没有语法错误时清除错误记录和定时器
|
||||
setError(null)
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
// 延迟显示错误
|
||||
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = setTimeout(() => {
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message)
|
||||
}, 500)
|
||||
}
|
||||
}, [children, diagramId, mermaid])
|
||||
|
||||
// 渲染Mermaid图表
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
|
||||
startTransition(render)
|
||||
|
||||
// 清理定时器
|
||||
return () => {
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
} finally {
|
||||
setIsRendering(false)
|
||||
}
|
||||
},
|
||||
[diagramId, mermaid]
|
||||
)
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderMermaid(content))
|
||||
}, 300),
|
||||
[renderMermaid]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (isLoadingMermaid) return
|
||||
|
||||
if (children) {
|
||||
setIsRendering(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsRendering(false)
|
||||
}
|
||||
}, [isLoading, render])
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, isLoadingMermaid, debouncedRender])
|
||||
|
||||
const isLoading = isLoadingMermaid || isRendering
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = svgContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' })
|
||||
|
||||
// 添加基础样式
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host {
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
|
||||
// 清空并重新添加内容
|
||||
shadowRoot.innerHTML = ''
|
||||
shadowRoot.appendChild(style)
|
||||
|
||||
const svgContainer = document.createElement('div')
|
||||
svgContainer.innerHTML = children
|
||||
shadowRoot.appendChild(svgContainer)
|
||||
}, [children])
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
||||
imgSelector: '.svg-preview svg',
|
||||
imgSelector: 'svg',
|
||||
prefix: 'svg-image'
|
||||
})
|
||||
|
||||
@@ -23,18 +58,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
)
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
}
|
||||
|
||||
const SvgPreviewContainer = styled.div`
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
`
|
||||
|
||||
export default memo(SvgPreview)
|
||||
|
||||
@@ -249,8 +249,8 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
}
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
/* FIXME: 在 bubble style 中撑开一些宽度*/
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
@@ -285,13 +285,10 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
|
||||
const SplitViewWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -224,11 +224,10 @@ const CodeEditor = ({
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
border: '0.5px solid transparent',
|
||||
borderRadius: '5px',
|
||||
marginTop: 0,
|
||||
...style
|
||||
marginTop: 0
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
||||
copy: {
|
||||
id: 'copy',
|
||||
type: 'core',
|
||||
order: 10
|
||||
order: 11
|
||||
},
|
||||
download: {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 11
|
||||
order: 10
|
||||
},
|
||||
edit: {
|
||||
id: 'edit',
|
||||
|
||||
@@ -32,6 +32,14 @@ export const usePreviewToolHandlers = (
|
||||
// 创建选择器函数
|
||||
const getImgElement = useCallback(() => {
|
||||
if (!containerRef.current) return null
|
||||
|
||||
// 优先尝试从 Shadow DOM 中查找
|
||||
const shadowRoot = containerRef.current.shadowRoot
|
||||
if (shadowRoot) {
|
||||
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
||||
}
|
||||
|
||||
// 降级到常规 DOM 查找
|
||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||
}, [containerRef, imgSelector])
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Dropdown } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -12,7 +11,6 @@ interface ContextMenuProps {
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
||||
const { t } = useTranslation()
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
@@ -20,12 +18,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
@@ -45,7 +37,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
}, [])
|
||||
|
||||
// 获取右键菜单项
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
@@ -66,8 +58,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedQuoteText) {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
if (selectedText) {
|
||||
window.api?.quoteToMainWindow(selectedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +70,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
menu={{ items: getContextMenuItems(t, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
|
||||
83
src/renderer/src/components/CopyButton.tsx
Normal file
83
src/renderer/src/components/CopyButton.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Tooltip } from 'antd'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CopyButtonProps {
|
||||
tooltip?: string
|
||||
textToCopy: string
|
||||
label?: string
|
||||
color?: string
|
||||
hoverColor?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ButtonContainerProps {
|
||||
$color: string
|
||||
$hoverColor: string
|
||||
}
|
||||
|
||||
const CopyButton: FC<CopyButtonProps> = ({
|
||||
tooltip,
|
||||
textToCopy,
|
||||
label,
|
||||
color = 'var(--color-text-2)',
|
||||
hoverColor = 'var(--color-primary)',
|
||||
size = 14
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard
|
||||
.writeText(textToCopy)
|
||||
.then(() => {
|
||||
window.message?.success(t('message.copy.success'))
|
||||
})
|
||||
.catch(() => {
|
||||
window.message?.error(t('message.copy.failed'))
|
||||
})
|
||||
}
|
||||
|
||||
const button = (
|
||||
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
|
||||
<Copy size={size} className="copy-icon" />
|
||||
{label && <RightText size={size}>{label}</RightText>}
|
||||
</ButtonContainer>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return <Tooltip title={tooltip}>{button}</Tooltip>
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
const ButtonContainer = styled.div<ButtonContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RightText = styled.span<{ size: number }>`
|
||||
font-size: ${(props) => props.size}px;
|
||||
`
|
||||
|
||||
export default CopyButton
|
||||
@@ -66,6 +66,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
const collapseStyle = merge({}, defaultCollapseStyle, style)
|
||||
const collapseItemStyles = useMemo(() => {
|
||||
return merge({}, defaultCollapseItemStyles, styles)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeKeys])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getLeadingEmoji } from '@renderer/utils'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -8,12 +7,10 @@ interface EmojiIconProps {
|
||||
}
|
||||
|
||||
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className }) => {
|
||||
const _emoji = getLeadingEmoji(emoji || '⭐️') || '⭐️'
|
||||
|
||||
return (
|
||||
<Container className={className}>
|
||||
<EmojiBackground>{_emoji}</EmojiBackground>
|
||||
{_emoji}
|
||||
<EmojiBackground>{emoji || '⭐️'}</EmojiBackground>
|
||||
{emoji}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
|
||||
if (ref.current) {
|
||||
ref.current.addEventListener('emoji-click', (event: any) => {
|
||||
event.stopPropagation()
|
||||
onEmojiClick(event.detail.emoji.unicode)
|
||||
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
|
||||
})
|
||||
}
|
||||
}, [onEmojiClick])
|
||||
|
||||
141
src/renderer/src/components/ImageViewer.tsx
Normal file
141
src/renderer/src/components/ImageViewer.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
RotateLeftOutlined,
|
||||
RotateRightOutlined,
|
||||
SwapOutlined,
|
||||
UndoOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Dropdown, Image as AntImage, ImageProps as AntImageProps, Space } from 'antd'
|
||||
import { Base64 } from 'js-base64'
|
||||
import mime from 'mime'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ImageViewerProps extends AntImageProps {
|
||||
src: string
|
||||
}
|
||||
|
||||
const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 复制图片到剪贴板
|
||||
const handleCopyImage = async (src: string) => {
|
||||
try {
|
||||
if (src.startsWith('data:')) {
|
||||
// 处理 base64 格式的图片
|
||||
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
|
||||
if (!match) throw new Error('无效的 base64 图片格式')
|
||||
const mimeType = match[1]
|
||||
const byteArray = Base64.toUint8Array(match[2])
|
||||
const blob = new Blob([byteArray], { type: mimeType })
|
||||
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
|
||||
} else if (src.startsWith('file://')) {
|
||||
// 处理本地文件路径
|
||||
const bytes = await window.api.fs.read(src)
|
||||
const mimeType = mime.getType(src) || 'application/octet-stream'
|
||||
const blob = new Blob([bytes], { type: mimeType })
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[mimeType]: blob
|
||||
})
|
||||
])
|
||||
} else {
|
||||
// 处理 URL 格式的图片
|
||||
const response = await fetch(src)
|
||||
const blob = await response.blob()
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
console.error('复制图片失败:', error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const getContextMenuItems = (src: string) => {
|
||||
return [
|
||||
{
|
||||
key: 'copy-url',
|
||||
label: t('common.copy'),
|
||||
icon: <CopyOutlined />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(src)
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: t('common.download'),
|
||||
icon: <DownloadOutlined />,
|
||||
onClick: () => download(src)
|
||||
},
|
||||
{
|
||||
key: 'copy-image',
|
||||
label: t('code_block.preview.copy.image'),
|
||||
icon: <FileImageOutlined />,
|
||||
onClick: () => handleCopyImage(src)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: getContextMenuItems(src) }} trigger={['contextMenu']}>
|
||||
<AntImage
|
||||
src={src}
|
||||
style={style}
|
||||
{...props}
|
||||
preview={{
|
||||
mask: typeof props.preview === 'object' ? props.preview.mask : false,
|
||||
toolbarRender: (
|
||||
_,
|
||||
{
|
||||
transform: { scale },
|
||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
}
|
||||
) => (
|
||||
<ToolbarWrapper size={12} className="toolbar-wrapper">
|
||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
<SwapOutlined onClick={onFlipX} />
|
||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||
<RotateRightOutlined onClick={onRotateRight} />
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<CopyOutlined onClick={() => handleCopyImage(src)} />
|
||||
<DownloadOutlined onClick={() => download(src)} />
|
||||
</ToolbarWrapper>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolbarWrapper = styled(Space)`
|
||||
padding: 0px 24px;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 100px;
|
||||
.anticon {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anticon:hover {
|
||||
opacity: 0.3;
|
||||
}
|
||||
`
|
||||
|
||||
export default ImageViewer
|
||||
@@ -395,10 +395,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
height={'100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
style={{
|
||||
marginLeft: 'var(--sidebar-width)',
|
||||
backgroundColor: window.root.style.background
|
||||
}}>
|
||||
style={{ backgroundColor: window.root.style.background }}>
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<Avatar
|
||||
@@ -418,7 +415,7 @@ const TitleContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${isMac ? '20px' : '10px'};
|
||||
padding-left: ${isMac ? '80px' : '10px'};
|
||||
padding-right: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -78,7 +78,7 @@ const WebviewContainer = memo(
|
||||
)
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: 'calc(100vw - var(--sidebar-width))',
|
||||
width: '100vw',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
backgroundColor: 'var(--color-background)',
|
||||
display: 'inline-flex'
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { exportMarkdownToObsidian } from '@renderer/utils/export'
|
||||
import { Alert, Empty, Form, Input, Modal, Select, Spin, TreeSelect } from 'antd'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
exportMarkdownToObsidian,
|
||||
messagesToMarkdown,
|
||||
messageToMarkdown,
|
||||
messageToMarkdownWithReasoning,
|
||||
topicToMarkdown
|
||||
} from '@renderer/utils/export'
|
||||
import { Alert, Empty, Form, Input, Modal, Select, Spin, Switch, TreeSelect } from 'antd'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
interface ObsidianExportDialogProps {
|
||||
title: string
|
||||
markdown: string
|
||||
open: boolean
|
||||
onClose: (success: boolean) => void
|
||||
obsidianTags: string | null
|
||||
processingMethod: string | '3' //默认新增(存在就覆盖)
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
path: string
|
||||
type: 'folder' | 'markdown'
|
||||
name: string
|
||||
}
|
||||
|
||||
const ObsidianProcessingMethod = {
|
||||
APPEND: '1',
|
||||
PREPEND: '2',
|
||||
NEW_OR_OVERWRITE: '3'
|
||||
} as const
|
||||
|
||||
interface PopupContainerProps {
|
||||
title: string
|
||||
obsidianTags: string | null
|
||||
processingMethod: (typeof ObsidianProcessingMethod)[keyof typeof ObsidianProcessingMethod]
|
||||
open: boolean
|
||||
resolve: (success: boolean) => void
|
||||
message?: Message
|
||||
messages?: Message[]
|
||||
topic?: Topic
|
||||
}
|
||||
|
||||
// 转换文件信息数组为树形结构
|
||||
const convertToTreeData = (files: FileInfo[]) => {
|
||||
const treeData: any[] = [
|
||||
@@ -113,13 +129,15 @@ const convertToTreeData = (files: FileInfo[]) => {
|
||||
return treeData
|
||||
}
|
||||
|
||||
const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({
|
||||
title,
|
||||
markdown,
|
||||
open,
|
||||
onClose,
|
||||
obsidianTags,
|
||||
processingMethod
|
||||
processingMethod,
|
||||
open,
|
||||
resolve,
|
||||
message,
|
||||
messages,
|
||||
topic
|
||||
}) => {
|
||||
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
|
||||
const [state, setState] = useState({
|
||||
@@ -130,8 +148,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
processingMethod: processingMethod,
|
||||
folder: ''
|
||||
})
|
||||
|
||||
// 是否手动编辑过标题
|
||||
const [hasTitleBeenManuallyEdited, setHasTitleBeenManuallyEdited] = useState(false)
|
||||
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
|
||||
const [files, setFiles] = useState<FileInfo[]>([])
|
||||
@@ -139,8 +155,8 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
const [selectedVault, setSelectedVault] = useState<string>('')
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [exportReasoning, setExportReasoning] = useState(false)
|
||||
|
||||
// 处理文件数据转为树形结构
|
||||
useEffect(() => {
|
||||
if (files.length > 0) {
|
||||
const treeData = convertToTreeData(files)
|
||||
@@ -157,28 +173,21 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
}
|
||||
}, [files])
|
||||
|
||||
// 组件加载时获取Vault列表
|
||||
useEffect(() => {
|
||||
const fetchVaults = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const vaultsData = await window.obsidian.getVaults()
|
||||
|
||||
if (vaultsData.length === 0) {
|
||||
setError(i18n.t('chat.topics.export.obsidian_no_vaults'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setVaults(vaultsData)
|
||||
|
||||
// 如果没有选择的vault,使用默认值或第一个
|
||||
const vaultToUse = defaultObsidianVault || vaultsData[0]?.name
|
||||
if (vaultToUse) {
|
||||
setSelectedVault(vaultToUse)
|
||||
|
||||
// 获取选中vault的文件和文件夹
|
||||
const filesData = await window.obsidian.getFiles(vaultToUse)
|
||||
setFiles(filesData)
|
||||
}
|
||||
@@ -189,11 +198,9 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchVaults()
|
||||
}, [defaultObsidianVault])
|
||||
|
||||
// 当选择的vault变化时,获取其文件和文件夹
|
||||
useEffect(() => {
|
||||
if (selectedVault) {
|
||||
const fetchFiles = async () => {
|
||||
@@ -209,7 +216,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchFiles()
|
||||
}
|
||||
}, [selectedVault])
|
||||
@@ -219,82 +225,71 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
setError(i18n.t('chat.topics.export.obsidian_no_vault_selected'))
|
||||
return
|
||||
}
|
||||
|
||||
//构建content 并复制到粘贴板
|
||||
let markdown = ''
|
||||
if (topic) {
|
||||
markdown = await topicToMarkdown(topic, exportReasoning)
|
||||
} else if (messages && messages.length > 0) {
|
||||
markdown = messagesToMarkdown(messages, exportReasoning)
|
||||
} else if (message) {
|
||||
markdown = exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message)
|
||||
} else {
|
||||
markdown = ''
|
||||
}
|
||||
let content = ''
|
||||
if (state.processingMethod !== '3') {
|
||||
if (state.processingMethod !== ObsidianProcessingMethod.NEW_OR_OVERWRITE) {
|
||||
content = `\n---\n${markdown}`
|
||||
} else {
|
||||
content = `---
|
||||
\ntitle: ${state.title}
|
||||
\ncreated: ${state.createdAt}
|
||||
\nsource: ${state.source}
|
||||
\ntags: ${state.tags}
|
||||
\n---\n${markdown}`
|
||||
content = `---\ntitle: ${state.title}\ncreated: ${state.createdAt}\nsource: ${state.source}\ntags: ${state.tags}\n---\n${markdown}`
|
||||
}
|
||||
if (content === '') {
|
||||
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(content)
|
||||
|
||||
// 导出到Obsidian
|
||||
exportMarkdownToObsidian({
|
||||
...state,
|
||||
folder: state.folder,
|
||||
vault: selectedVault
|
||||
})
|
||||
|
||||
onClose(true)
|
||||
setOpen(false)
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
const [openState, setOpen] = useState(open)
|
||||
useEffect(() => {
|
||||
setOpen(open)
|
||||
}, [open])
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose(false)
|
||||
setOpen(false)
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
setState((prevState) => ({ ...prevState, [key]: value }))
|
||||
}
|
||||
|
||||
// 处理title输入变化
|
||||
const handleTitleInputChange = (newTitle: string) => {
|
||||
handleChange('title', newTitle)
|
||||
setHasTitleBeenManuallyEdited(true)
|
||||
}
|
||||
|
||||
const handleVaultChange = (value: string) => {
|
||||
setSelectedVault(value)
|
||||
// 文件夹会通过useEffect自动获取
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
folder: ''
|
||||
}))
|
||||
setState((prevState) => ({ ...prevState, folder: '' }))
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (value: string) => {
|
||||
// 更新folder值
|
||||
handleChange('folder', value)
|
||||
|
||||
// 检查是否选中md文件
|
||||
if (value) {
|
||||
const selectedFile = files.find((file) => file.path === value)
|
||||
if (selectedFile) {
|
||||
if (selectedFile.type === 'markdown') {
|
||||
// 如果是md文件,自动设置标题为文件名并设置处理方式为1(追加)
|
||||
const fileName = selectedFile.name
|
||||
const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName
|
||||
handleChange('title', titleWithoutExt)
|
||||
// 重置手动编辑标记,因为这是非用户设置的title
|
||||
setHasTitleBeenManuallyEdited(false)
|
||||
handleChange('processingMethod', '1')
|
||||
handleChange('processingMethod', ObsidianProcessingMethod.APPEND)
|
||||
} else {
|
||||
// 如果是文件夹,自动设置标题为话题名并设置处理方式为3(新建)
|
||||
handleChange('processingMethod', '3')
|
||||
// 仅当用户未手动编辑过 title 时,才将其重置为 props.title
|
||||
handleChange('processingMethod', ObsidianProcessingMethod.NEW_OR_OVERWRITE)
|
||||
if (!hasTitleBeenManuallyEdited) {
|
||||
// title 是 props.title
|
||||
handleChange('title', title)
|
||||
}
|
||||
}
|
||||
@@ -305,7 +300,7 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
return (
|
||||
<Modal
|
||||
title={i18n.t('chat.topics.export.obsidian_atributes')}
|
||||
open={open}
|
||||
open={openState}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
width={600}
|
||||
@@ -317,9 +312,9 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
type: 'primary',
|
||||
disabled: vaults.length === 0 || loading || !!error
|
||||
}}
|
||||
okText={i18n.t('chat.topics.export.obsidian_btn')}>
|
||||
okText={i18n.t('chat.topics.export.obsidian_btn')}
|
||||
afterClose={() => setOpen(open)}>
|
||||
{error && <Alert message={error} type="error" showIcon style={{ marginBottom: 16 }} />}
|
||||
|
||||
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign="left">
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
|
||||
<Input
|
||||
@@ -328,7 +323,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_vault')}>
|
||||
{vaults.length > 0 ? (
|
||||
<Select
|
||||
@@ -354,7 +348,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
|
||||
<Spin spinning={loading}>
|
||||
{selectedVault ? (
|
||||
@@ -376,7 +369,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
)}
|
||||
</Spin>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
|
||||
<Input
|
||||
value={state.tags}
|
||||
@@ -398,21 +390,29 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_operate')}>
|
||||
<Select
|
||||
value={state.processingMethod}
|
||||
onChange={(value) => handleChange('processingMethod', value)}
|
||||
placeholder={i18n.t('chat.topics.export.obsidian_operate_placeholder')}
|
||||
allowClear>
|
||||
<Option value="1">{i18n.t('chat.topics.export.obsidian_operate_append')}</Option>
|
||||
<Option value="2">{i18n.t('chat.topics.export.obsidian_operate_prepend')}</Option>
|
||||
<Option value="3">{i18n.t('chat.topics.export.obsidian_operate_new_or_overwrite')}</Option>
|
||||
<Option value={ObsidianProcessingMethod.APPEND}>
|
||||
{i18n.t('chat.topics.export.obsidian_operate_append')}
|
||||
</Option>
|
||||
<Option value={ObsidianProcessingMethod.PREPEND}>
|
||||
{i18n.t('chat.topics.export.obsidian_operate_prepend')}
|
||||
</Option>
|
||||
<Option value={ObsidianProcessingMethod.NEW_OR_OVERWRITE}>
|
||||
{i18n.t('chat.topics.export.obsidian_operate_new_or_overwrite')}
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
|
||||
<Switch checked={exportReasoning} onChange={setExportReasoning} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObsidianExportDialog
|
||||
export { ObsidianProcessingMethod, PopupContainer }
|
||||
|
||||
@@ -1,44 +1,38 @@
|
||||
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ObsidianProcessingMethod, PopupContainer } from '@renderer/components/ObsidianExportDialog'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
|
||||
interface ObsidianExportOptions {
|
||||
title: string
|
||||
markdown: string
|
||||
processingMethod: string | '3' // 默认新增(存在就覆盖)
|
||||
processingMethod: (typeof ObsidianProcessingMethod)[keyof typeof ObsidianProcessingMethod]
|
||||
topic?: Topic
|
||||
message?: Message
|
||||
messages?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置Obsidian 笔记属性弹窗
|
||||
* @param options.title 标题
|
||||
* @param options.markdown markdown内容
|
||||
* @param options.processingMethod 处理方式
|
||||
* @returns
|
||||
*/
|
||||
const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const div = document.createElement('div')
|
||||
document.body.appendChild(div)
|
||||
const root = createRoot(div)
|
||||
|
||||
const handleClose = (success: boolean) => {
|
||||
root.unmount()
|
||||
document.body.removeChild(div)
|
||||
resolve(success)
|
||||
}
|
||||
// 不再从store中获取tag配置
|
||||
root.render(
|
||||
<ObsidianExportDialog
|
||||
title={options.title}
|
||||
markdown={options.markdown}
|
||||
obsidianTags=""
|
||||
processingMethod={options.processingMethod}
|
||||
open={true}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
show: showObsidianExportDialog
|
||||
export default class ObsidianExportPopup {
|
||||
static hide() {
|
||||
TopView.hide('ObsidianExportPopup')
|
||||
}
|
||||
static show(options: ObsidianExportOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
title={options.title}
|
||||
processingMethod={options.processingMethod}
|
||||
topic={options.topic}
|
||||
message={options.message}
|
||||
messages={options.messages}
|
||||
obsidianTags={''}
|
||||
open={true}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
ObsidianExportPopup.hide()
|
||||
}}
|
||||
/>,
|
||||
'ObsidianExportPopup'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,9 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
const onAfterClose = () => {
|
||||
resolve(null)
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
@@ -61,7 +62,7 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
afterClose={onAfterClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
@@ -95,16 +96,7 @@ export default class PromptPopup {
|
||||
}
|
||||
static show(props: PromptPopupShowParams) {
|
||||
return new Promise<string>((resolve) => {
|
||||
TopView.show(
|
||||
<PromptPopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
'PromptPopup'
|
||||
)
|
||||
TopView.show(<PromptPopupContainer {...props} resolve={resolve} />, 'PromptPopup')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ShowParams {
|
||||
text: string
|
||||
textareaProps?: TextAreaProps
|
||||
modalProps?: ModalProps
|
||||
showTranslate?: boolean
|
||||
children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode
|
||||
}
|
||||
|
||||
@@ -25,7 +26,14 @@ interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, resolve, children }) => {
|
||||
const PopupContainer: React.FC<Props> = ({
|
||||
text,
|
||||
textareaProps,
|
||||
modalProps,
|
||||
resolve,
|
||||
children,
|
||||
showTranslate = true
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [textValue, setTextValue] = useState(text)
|
||||
@@ -148,12 +156,14 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
onInput={resizeTextArea}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
/>
|
||||
<TranslateButton
|
||||
onClick={handleTranslate}
|
||||
aria-label="Translate text"
|
||||
disabled={isTranslating || !textValue.trim()}>
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</TranslateButton>
|
||||
{showTranslate && (
|
||||
<TranslateButton
|
||||
onClick={handleTranslate}
|
||||
aria-label="Translate text"
|
||||
disabled={isTranslating || !textValue.trim()}>
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</TranslateButton>
|
||||
)}
|
||||
</TextAreaContainer>
|
||||
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
|
||||
</Modal>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { theme } from 'antd'
|
||||
import Color from 'color'
|
||||
import { t } from 'i18next'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -40,8 +39,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
throw new Error('QuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
|
||||
const { token } = theme.useToken()
|
||||
const colorPrimary = Color(token.colorPrimary || '#008000')
|
||||
const { colorPrimary } = useUserTheme()
|
||||
const selectedColor = colorPrimary.alpha(0.15).toString()
|
||||
const selectedColorHover = colorPrimary.alpha(0.2).toString()
|
||||
|
||||
@@ -434,7 +432,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
$pageSize={ctx.pageSize}
|
||||
$selectedColor={selectedColor}
|
||||
$selectedColorHover={selectedColorHover}
|
||||
className={ctx.isVisible ? 'visible' : ''}>
|
||||
className={ctx.isVisible ? 'visible' : ''}
|
||||
data-testid="quick-panel">
|
||||
<QuickPanelBody
|
||||
ref={bodyRef}
|
||||
onMouseMove={() =>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
right?: boolean
|
||||
ref?: React.RefObject<HTMLDivElement | null>
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
@@ -12,38 +11,46 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsScrolling(true)
|
||||
|
||||
const clearScrollingTimeout = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
|
||||
}, [])
|
||||
|
||||
const throttledInternalScrollHandler = throttle(handleScroll, 200)
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsScrolling(true)
|
||||
clearScrollingTimeout()
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsScrolling(false)
|
||||
timeoutRef.current = null
|
||||
}, 1500)
|
||||
}, [clearScrollingTimeout])
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledInternalScrollHandler = useCallback(throttle(handleScroll, 100, { leading: true, trailing: true }), [
|
||||
handleScroll
|
||||
])
|
||||
|
||||
// Combined scroll handler
|
||||
const combinedOnScroll = useCallback(() => {
|
||||
// Event is available if needed by internal handler
|
||||
throttledInternalScrollHandler() // Call internal logic
|
||||
throttledInternalScrollHandler()
|
||||
if (externalOnScroll) {
|
||||
externalOnScroll() // Call external logic (from useScrollPosition)
|
||||
externalOnScroll()
|
||||
}
|
||||
}, [throttledInternalScrollHandler, externalOnScroll])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current)
|
||||
clearScrollingTimeout()
|
||||
throttledInternalScrollHandler.cancel()
|
||||
}
|
||||
}, [throttledInternalScrollHandler])
|
||||
}, [throttledInternalScrollHandler, clearScrollingTimeout])
|
||||
|
||||
return (
|
||||
<Container
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
isScrolling={isScrolling}
|
||||
$isScrolling={isScrolling}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
@@ -51,15 +58,13 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
|
||||
const Container = styled.div<{ $isScrolling: boolean }>`
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: ${(props) =>
|
||||
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''})` : 'transparent'};
|
||||
background: ${(props) => (props.$isScrolling ? 'var(--color-scrollbar-thumb)' : 'transparent')};
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''}-hover)` : 'transparent'};
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -65,7 +65,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
|
||||
const FullScreenContainer: React.FC<PropsWithChildren> = useCallback(({ children }) => {
|
||||
return (
|
||||
<Box flex={1} position="absolute" w="100%" h="100%">
|
||||
<Box flex={1} position="absolute" w="100%" h="100%" className="topview-fullscreen-container">
|
||||
<Box position="absolute" w="100%" h="100%" onClick={onPop} />
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
@@ -14,9 +14,9 @@ interface BackupFile {
|
||||
|
||||
interface WebdavConfig {
|
||||
webdavHost: string
|
||||
webdavUser: string
|
||||
webdavPass: string
|
||||
webdavPath: string
|
||||
webdavUser?: string
|
||||
webdavPass?: string
|
||||
webdavPath?: string
|
||||
}
|
||||
|
||||
interface WebdavBackupManagerProps {
|
||||
@@ -47,7 +47,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
const { webdavHost, webdavUser, webdavPass, webdavPath } = webdavConfig
|
||||
|
||||
const fetchBackupFiles = useCallback(async () => {
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
return
|
||||
}
|
||||
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
}
|
||||
|
||||
const handleDeleteSingle = async (fileName: string) => {
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
}
|
||||
|
||||
const handleRestore = async (fileName: string) => {
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function useWebdavRestoreModal({
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
|
||||
const showRestoreModal = useCallback(async () => {
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
if (!webdavHost) {
|
||||
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
|
||||
return
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export function useWebdavRestoreModal({
|
||||
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
if (!selectedFile || !webdavHost) {
|
||||
window.message.error({
|
||||
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
|
||||
key: 'restore-error'
|
||||
@@ -170,7 +170,7 @@ export function useWebdavRestoreModal({
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [selectedFile, webdavHost, webdavUser, webdavPass, webdavPath, t, restoreMethod])
|
||||
}, [selectedFile, webdavHost, t, restoreMethod])
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsRestoreModalVisible(false)
|
||||
|
||||
44
src/renderer/src/components/__tests__/CustomTag.test.tsx
Normal file
44
src/renderer/src/components/__tests__/CustomTag.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import CustomTag from '../CustomTag'
|
||||
|
||||
const COLOR = '#ff0000'
|
||||
|
||||
describe('CustomTag', () => {
|
||||
it('should render children text', () => {
|
||||
render(<CustomTag color={COLOR}>content</CustomTag>)
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon if provided', () => {
|
||||
render(
|
||||
<CustomTag color={COLOR} icon={<span data-testid="icon">cherry</span>}>
|
||||
content
|
||||
</CustomTag>
|
||||
)
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tooltip if tooltip prop is set', async () => {
|
||||
render(
|
||||
<CustomTag color={COLOR} tooltip="reasoning model">
|
||||
reasoning
|
||||
</CustomTag>
|
||||
)
|
||||
// 鼠标悬停触发 Tooltip
|
||||
await userEvent.hover(screen.getByText('reasoning'))
|
||||
expect(await screen.findByText('reasoning model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render Tooltip when tooltip is not set', () => {
|
||||
render(<CustomTag color="#ff0000">no tooltip</CustomTag>)
|
||||
|
||||
expect(screen.getByText('no tooltip')).toBeInTheDocument()
|
||||
// 不应有 tooltip 相关内容
|
||||
expect(document.querySelector('.ant-tooltip')).toBeNull()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
282
src/renderer/src/components/__tests__/DragableList.test.tsx
Normal file
282
src/renderer/src/components/__tests__/DragableList.test.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/// <reference types="@vitest/browser/context" />
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DragableList from '../DragableList'
|
||||
|
||||
// mock @hello-pangea/dnd 组件
|
||||
vi.mock('@hello-pangea/dnd', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
DragDropContext: ({ children, onDragEnd }: any) => {
|
||||
// 挂载到 window 以便测试用例直接调用
|
||||
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
|
||||
onDragEnd && onDragEnd(result, provided)
|
||||
}
|
||||
return <div data-testid="drag-drop-context">{children}</div>
|
||||
},
|
||||
Droppable: ({ children }: any) => (
|
||||
<div data-testid="droppable">
|
||||
{children({ droppableProps: {}, innerRef: () => {}, placeholder: <div data-testid="placeholder" /> })}
|
||||
</div>
|
||||
),
|
||||
Draggable: ({ children, draggableId, index }: any) => (
|
||||
<div data-testid={`draggable-${draggableId}-${index}`}>
|
||||
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: () => {} })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// mock VirtualList 只做简单渲染
|
||||
vi.mock('rc-virtual-list', () => ({
|
||||
__esModule: true,
|
||||
default: ({ data, itemKey, children }: any) => (
|
||||
<div data-testid="virtual-list">
|
||||
{data.map((item: any, idx: number) => (
|
||||
<div key={item[itemKey] || item} data-testid="virtual-list-item">
|
||||
{children(item, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
triggerOnDragEnd: (result?: any, provided?: any) => void
|
||||
}
|
||||
}
|
||||
|
||||
describe('DragableList', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render all list items', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
const items = screen.getAllByTestId('item')
|
||||
expect(items.length).toBe(3)
|
||||
expect(items[0].textContent).toBe('A')
|
||||
expect(items[1].textContent).toBe('B')
|
||||
expect(items[2].textContent).toBe('C')
|
||||
})
|
||||
|
||||
it('should render with custom style and listStyle', () => {
|
||||
const list = [{ id: 'a', name: 'A' }]
|
||||
const style = { background: 'red' }
|
||||
const listStyle = { color: 'blue' }
|
||||
render(
|
||||
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
// 检查 style 是否传递到外层容器
|
||||
const virtualList = screen.getByTestId('virtual-list')
|
||||
expect(virtualList.parentElement).toHaveStyle({ background: 'red' })
|
||||
})
|
||||
|
||||
it('should render nothing when list is empty', () => {
|
||||
render(
|
||||
<DragableList list={[]} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
// 虚拟列表存在但无内容
|
||||
const items = screen.queryAllByTestId('item')
|
||||
expect(items.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop', () => {
|
||||
it('should call onUpdate with new order after drag end', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const newOrder = [list[1], list[2], list[0]]
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(newOrder)
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onDragStart and onDragEnd', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const onDragStart = vi.fn()
|
||||
const onDragEnd = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 先手动调用 onDragStart
|
||||
onDragStart()
|
||||
// 再模拟拖拽结束
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
|
||||
expect(onDragStart).toHaveBeenCalledTimes(1)
|
||||
expect(onDragEnd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onUpdate if dropped at same position', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 模拟拖拽到自身
|
||||
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(list)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should work with single item', () => {
|
||||
const list = [{ id: 'a', name: 'A' }]
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 拖拽自身
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(list)
|
||||
})
|
||||
|
||||
it('should not crash if callbacks are undefined', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' }
|
||||
]
|
||||
|
||||
// 不传 onDragStart/onDragEnd
|
||||
expect(() => {
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle items without id', () => {
|
||||
const list = ['A', 'B', 'C']
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 拖拽第0项到第2项
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(['B', 'C', 'A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction', () => {
|
||||
it('should show placeholder during drag', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// placeholder 应该在初始渲染时就存在
|
||||
const placeholder = screen.getByTestId('placeholder')
|
||||
expect(placeholder).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reorder correctly when dragged to first/last', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const onUpdate = vi.fn()
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 拖拽第2项到第0项
|
||||
window.triggerOnDragEnd({ source: { index: 2 }, destination: { index: 0 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: 'c', name: 'C' },
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' }
|
||||
])
|
||||
|
||||
// 拖拽第0项到第2项
|
||||
onUpdate.mockClear()
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' },
|
||||
{ id: 'a', name: 'A' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot', () => {
|
||||
it('should match snapshot', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const { container } = render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ExpandableText from '../ExpandableText'
|
||||
|
||||
// mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k })
|
||||
}))
|
||||
|
||||
describe('ExpandableText', () => {
|
||||
const TEXT = 'This is a long text for testing.'
|
||||
|
||||
it('should render text and expand button', () => {
|
||||
render(<ExpandableText text={TEXT} />)
|
||||
expect(screen.getByText(TEXT)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toHaveTextContent('common.expand')
|
||||
})
|
||||
|
||||
it('should toggle expand/collapse when button is clicked', async () => {
|
||||
render(<ExpandableText text={TEXT} />)
|
||||
const button = screen.getByRole('button')
|
||||
// 初始为收起状态
|
||||
expect(button).toHaveTextContent('common.expand')
|
||||
// 点击展开
|
||||
await userEvent.click(button)
|
||||
expect(button).toHaveTextContent('common.collapse')
|
||||
// 再次点击收起
|
||||
await userEvent.click(button)
|
||||
expect(button).toHaveTextContent('common.expand')
|
||||
})
|
||||
})
|
||||
211
src/renderer/src/components/__tests__/QuickPanelView.test.tsx
Normal file
211
src/renderer/src/components/__tests__/QuickPanelView.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel'
|
||||
|
||||
// Mock Redux store
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
settings: (state = { userTheme: { colorPrimary: '#1677ff' } }) => state
|
||||
}
|
||||
})
|
||||
|
||||
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
|
||||
return Array.from({ length }, (_, i) => ({
|
||||
label: `${prefix} ${i + 1}`,
|
||||
description: `${prefix} Description ${i + 1}`,
|
||||
icon: `${prefix} Icon ${i + 1}`,
|
||||
action: () => {},
|
||||
...extra
|
||||
}))
|
||||
}
|
||||
|
||||
type KeyStep = {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
expected: string | ((text: string) => boolean)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 7
|
||||
|
||||
// 用于测试 open 行为的组件
|
||||
function OpenPanelOnMount({ list }: { list: QuickPanelListItem[] }) {
|
||||
const quickPanel = useQuickPanel()
|
||||
useEffect(() => {
|
||||
quickPanel.open({
|
||||
title: 'Test Panel',
|
||||
list,
|
||||
symbol: 'test',
|
||||
pageSize: PAGE_SIZE
|
||||
})
|
||||
}, [list, quickPanel])
|
||||
return null
|
||||
}
|
||||
|
||||
function wrapWithProviders(children: React.ReactNode) {
|
||||
return (
|
||||
<Provider store={mockStore}>
|
||||
<QuickPanelProvider>{children}</QuickPanelProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('QuickPanelView', () => {
|
||||
beforeEach(() => {
|
||||
// 添加一个假的 .inputbar textarea 到 document.body
|
||||
const inputbar = document.createElement('div')
|
||||
inputbar.className = 'inputbar'
|
||||
const textarea = document.createElement('textarea')
|
||||
inputbar.appendChild(textarea)
|
||||
document.body.appendChild(inputbar)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const inputbar = document.querySelector('.inputbar')
|
||||
if (inputbar) inputbar.remove()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render without crashing when wrapped in QuickPanelProvider', () => {
|
||||
render(wrapWithProviders(<QuickPanelView setInputText={vi.fn()} />))
|
||||
|
||||
// 检查面板容器是否存在且初始不可见
|
||||
const panel = screen.getByTestId('quick-panel')
|
||||
expect(panel.classList.contains('visible')).toBe(false)
|
||||
})
|
||||
|
||||
it('should render list after open', async () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
// 检查面板可见
|
||||
const panel = screen.getByTestId('quick-panel')
|
||||
expect(panel.classList.contains('visible')).toBe(true)
|
||||
// 检查第一个 item 是否渲染
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('focusing', () => {
|
||||
// 执行一系列按键,检查 focused item 是否正确
|
||||
async function runKeySequenceAndCheck(panel: HTMLElement, sequence: KeyStep[]) {
|
||||
const user = userEvent.setup()
|
||||
for (const { key, ctrlKey, expected } of sequence) {
|
||||
let keyString = ''
|
||||
if (ctrlKey) keyString += '{Control>}'
|
||||
keyString += key.length === 1 ? key : `{${key}}`
|
||||
if (ctrlKey) keyString += '{/Control}'
|
||||
await user.keyboard(keyString)
|
||||
|
||||
// 检查是否只有一个 focused item
|
||||
const focused = panel.querySelectorAll('.focused')
|
||||
expect(focused.length).toBe(1)
|
||||
// 检查 focused item 是否包含预期文本
|
||||
const text = focused[0].textContent || ''
|
||||
if (typeof expected === 'string') {
|
||||
expect(text).toContain(expected)
|
||||
} else {
|
||||
expect(expected(text)).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should focus on the first item after panel open', () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
// 检查第一个 item 是否有 focused
|
||||
const item1 = screen.getByText('Item 1')
|
||||
const focused = item1.closest('.focused')
|
||||
expect(focused).not.toBeNull()
|
||||
expect(item1).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'ArrowUp', expected: 'Item 99' },
|
||||
{ key: 'ArrowDown', expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', expected: 'Item 1' }
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
})
|
||||
|
||||
it('should focus on the right item using PageUp, PageDown', async () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
|
||||
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
|
||||
{ key: 'PageDown', expected: 'Item 100' }
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
})
|
||||
|
||||
it('should focus on the right item using Ctrl+ArrowUp, Ctrl+ArrowDown', async () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
})
|
||||
})
|
||||
})
|
||||
176
src/renderer/src/components/__tests__/Scrollbar.test.tsx
Normal file
176
src/renderer/src/components/__tests__/Scrollbar.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
// Mock lodash throttle
|
||||
vi.mock('lodash', async () => {
|
||||
const actual = await import('lodash')
|
||||
return {
|
||||
...actual,
|
||||
throttle: vi.fn((fn) => {
|
||||
// 简单地直接返回函数,不实际执行节流
|
||||
const throttled = (...args: any[]) => fn(...args)
|
||||
throttled.cancel = vi.fn()
|
||||
return throttled
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('Scrollbar', () => {
|
||||
beforeEach(() => {
|
||||
// 使用 fake timers
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// 恢复真实的 timers
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render children correctly', () => {
|
||||
render(
|
||||
<Scrollbar data-testid="scrollbar">
|
||||
<div data-testid="child">测试内容</div>
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
const child = screen.getByTestId('child')
|
||||
expect(child).toBeDefined()
|
||||
expect(child.textContent).toBe('测试内容')
|
||||
})
|
||||
|
||||
it('should pass custom props to container', () => {
|
||||
render(
|
||||
<Scrollbar data-testid="scrollbar" className="custom-class">
|
||||
内容
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
expect(scrollbar.className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('should match default styled snapshot', () => {
|
||||
const { container } = render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrolling behavior', () => {
|
||||
it('should update isScrolling state when scrolled', () => {
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 初始状态下应该不是滚动状态
|
||||
expect(scrollbar.getAttribute('isScrolling')).toBeFalsy()
|
||||
|
||||
// 触发滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 由于 isScrolling 是组件内部状态,不直接反映在 DOM 属性上
|
||||
// 但可以检查模拟的事件处理是否被调用
|
||||
expect(scrollbar).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset isScrolling after timeout', () => {
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 触发滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 前进时间但不超过timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
// 前进超过timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
|
||||
// 不测试样式,这里只检查组件是否存在
|
||||
expect(scrollbar).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset timeout on continuous scrolling', () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
|
||||
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 第一次滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 前进一部分时间
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800)
|
||||
})
|
||||
|
||||
// 再次滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// clearTimeout 应该被调用,因为在第二次滚动时会清除之前的定时器
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('throttling', () => {
|
||||
it('should use throttled scroll handler', async () => {
|
||||
const { throttle } = await import('lodash')
|
||||
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
// 验证 throttle 被调用
|
||||
expect(throttle).toHaveBeenCalled()
|
||||
// 验证 throttle 调用时使用了 100ms 延迟和正确的选项
|
||||
expect(throttle).toHaveBeenCalledWith(expect.any(Function), 100, { leading: true, trailing: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clear timeout and cancel throttle on unmount', async () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
|
||||
|
||||
const { unmount } = render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 触发滚动设置定时器
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 卸载组件
|
||||
unmount()
|
||||
|
||||
// 验证 clearTimeout 被调用
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
|
||||
// 验证 throttle.cancel 被调用
|
||||
const { throttle } = await import('lodash')
|
||||
const throttledFunction = (throttle as unknown as Mock).mock.results[0].value
|
||||
expect(throttledFunction.cancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('props handling', () => {
|
||||
it('should handle ref forwarding', () => {
|
||||
const ref = { current: null }
|
||||
|
||||
render(
|
||||
<Scrollbar data-testid="scrollbar" ref={ref}>
|
||||
内容
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
// 验证 ref 被正确设置
|
||||
expect(ref.current).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DragableList > snapshot > should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
data-testid="drag-drop-context"
|
||||
>
|
||||
<div
|
||||
data-testid="droppable"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
data-testid="virtual-list"
|
||||
>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-a-0"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-b-1"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-c-2"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="scrollbar"
|
||||
>
|
||||
内容
|
||||
</div>
|
||||
`;
|
||||
90
src/renderer/src/components/app/MainNavbar.tsx
Normal file
90
src/renderer/src/components/app/MainNavbar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { MessageSquareDiff, Search } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SearchPopup from '../Popups/SearchPopup'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const HeaderNavbar: FC<Props> = () => {
|
||||
return (
|
||||
<Container>
|
||||
<div>
|
||||
{!isMac && (
|
||||
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={() => SearchPopup.show()}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} style={{ marginRight: 5 }}>
|
||||
<MessageSquareDiff size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
width: var(--assistant-width);
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
padding-left: var(--sidebar-width);
|
||||
height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
background-color: transparent;
|
||||
-webkit-app-region: drag;
|
||||
padding-left: ${isMac ? '75px' : '0'};
|
||||
`
|
||||
|
||||
export const NavbarIcon = styled.div`
|
||||
-webkit-app-region: none;
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
padding: 0 7px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease-in-out;
|
||||
-webkit-app-region: no-drag;
|
||||
cursor: pointer;
|
||||
.iconfont {
|
||||
font-size: 18px;
|
||||
color: var(--color-icon);
|
||||
&.icon-a-addchat {
|
||||
font-size: 20px;
|
||||
}
|
||||
&.icon-a-darkmode {
|
||||
font-size: 20px;
|
||||
}
|
||||
&.icon-appstore {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
.anticon {
|
||||
color: var(--color-icon);
|
||||
font-size: 16px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
`
|
||||
|
||||
const NarrowIcon = styled(NavbarIcon)`
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default HeaderNavbar
|
||||
379
src/renderer/src/components/app/MainSidebar.tsx
Normal file
379
src/renderer/src/components/app/MainSidebar.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
|
||||
import { UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useChat } from '@renderer/hooks/useChat'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Tooltip } from 'antd'
|
||||
import {
|
||||
Blocks,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Languages,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
Palette,
|
||||
SquareTerminal,
|
||||
Sun,
|
||||
SunMoon
|
||||
} from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Tabs from '../../pages/home/Tabs'
|
||||
import MainNavbar from './MainNavbar'
|
||||
|
||||
type Tab = 'assistants' | 'topic' | 'settings'
|
||||
|
||||
const MainSidebar: FC = () => {
|
||||
const { assistants } = useAssistants()
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<Tab>('assistants')
|
||||
const avatar = useAvatar()
|
||||
const { userName, defaultPaintingProvider } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const { theme, settedTheme, toggleTheme } = useTheme()
|
||||
const [isAppMenuExpanded, setIsAppMenuExpanded] = useState(false)
|
||||
|
||||
const location = useLocation()
|
||||
const { pathname } = location
|
||||
|
||||
const { activeAssistant, activeTopic, setActiveAssistant, setActiveTopic } = useChat()
|
||||
const { showAssistants, showTopics, topicPosition } = useSettings()
|
||||
|
||||
useEffect(() => {
|
||||
NavigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, () => setTab('topic'))
|
||||
return () => unsubscribe()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => {
|
||||
const newAssistant = assistants.find((a) => a.id === assistantId)
|
||||
if (newAssistant) {
|
||||
setActiveAssistant(newAssistant)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [assistants, setActiveAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
const canMinimize = !showAssistants && !showTopics
|
||||
window.api.window.setMinimumSize(canMinimize ? 520 : 1080, 600)
|
||||
|
||||
return () => {
|
||||
window.api.window.resetMinimumSize()
|
||||
}
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
useEffect(() => {
|
||||
setIsAppMenuExpanded(false)
|
||||
}, [activeAssistant.id, activeTopic.id])
|
||||
|
||||
const onAvatarClick = () => {
|
||||
navigate('/settings/provider')
|
||||
}
|
||||
|
||||
const onChageTheme = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
toggleTheme()
|
||||
}
|
||||
|
||||
const appMenuItems = [
|
||||
// { icon: <Sparkle size={18} className="icon" />, text: t('agents.title'), path: '/agents' },
|
||||
{ icon: <Compass size={18} className="icon" />, text: t('discover.title'), path: '/discover' },
|
||||
{ icon: <Languages size={18} className="icon" />, text: t('translate.title'), path: '/translate' },
|
||||
{
|
||||
icon: <Palette size={18} className="icon" />,
|
||||
text: t('paintings.title'),
|
||||
path: `/paintings/${defaultPaintingProvider}`
|
||||
},
|
||||
// { icon: <LayoutGrid size={18} className="icon" />, text: t('minapp.title'), path: '/apps' },
|
||||
{ icon: <FileSearch size={18} className="icon" />, text: t('knowledge.title'), path: '/knowledge' },
|
||||
{ icon: <SquareTerminal size={18} className="icon" />, text: t('common.mcp'), path: '/mcp-servers' },
|
||||
{ icon: <Folder size={18} className="icon" />, text: t('files.title'), path: '/files' }
|
||||
]
|
||||
|
||||
const isRoutes = (path: string): boolean => pathname.startsWith(path)
|
||||
|
||||
const onChageTab = (tab: Tab) => {
|
||||
setTab(tab)
|
||||
setIsAppMenuExpanded(false)
|
||||
}
|
||||
|
||||
if (!showAssistants) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (location.pathname !== '/') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container id="main-sidebar">
|
||||
<MainNavbar />
|
||||
<MainMenu>
|
||||
<MainMenuItem
|
||||
active={tab === 'assistants' && location.pathname === '/'}
|
||||
onClick={() => onChageTab('assistants')}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
<Bot size={18} />
|
||||
</MainMenuItemIcon>
|
||||
<MainMenuItemText>{t('assistants.title')}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
{tab === 'topic' && (
|
||||
<MainMenuItemRight>
|
||||
<MainMenuItemRightText>{activeAssistant.name}</MainMenuItemRightText>
|
||||
</MainMenuItemRight>
|
||||
)}
|
||||
</MainMenuItem>
|
||||
<MainMenuItem active={tab === 'topic' && location.pathname === '/'} onClick={() => onChageTab('topic')}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
<MessageSquare size={18} />
|
||||
</MainMenuItemIcon>
|
||||
<MainMenuItemText>{t('common.topics')}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
</MainMenuItem>
|
||||
<MainMenuItem
|
||||
style={{ opacity: isAppMenuExpanded ? 0.5 : 1 }}
|
||||
onClick={() => setIsAppMenuExpanded(!isAppMenuExpanded)}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
<Blocks size={19} className="icon" />
|
||||
</MainMenuItemIcon>
|
||||
<MainMenuItemText>{t('common.apps')}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
<MainMenuItemRight>
|
||||
{isAppMenuExpanded ? (
|
||||
<ChevronDown size={18} color="var(--color-text-3)" />
|
||||
) : (
|
||||
<ChevronRight size={18} color="var(--color-text-3)" />
|
||||
)}
|
||||
</MainMenuItemRight>
|
||||
</MainMenuItem>
|
||||
{isAppMenuExpanded && (
|
||||
<SubMenu>
|
||||
{appMenuItems.map((item) => (
|
||||
<MainMenuItem key={item.path} active={isRoutes(item.path)} onClick={() => navigate(item.path)}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>{item.icon}</MainMenuItemIcon>
|
||||
<MainMenuItemText>{item.text}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
</MainMenuItem>
|
||||
))}
|
||||
</SubMenu>
|
||||
)}
|
||||
</MainMenu>
|
||||
<Tabs
|
||||
tab={tab}
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
/>
|
||||
<UserMenu onClick={onAvatarClick}>
|
||||
<UserMenuLeft>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar className="sidebar-avatar" size={31} fontSize={18}>
|
||||
{avatar}
|
||||
</EmojiAvatar>
|
||||
) : (
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" />
|
||||
)}
|
||||
<UserMenuText>{userName}</UserMenuText>
|
||||
</UserMenuLeft>
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settedTheme}`)}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="right">
|
||||
<Icon theme={theme} onClick={onChageTheme}>
|
||||
{settedTheme === ThemeMode.dark ? (
|
||||
<Moon size={18} className="icon" />
|
||||
) : settedTheme === ThemeMode.light ? (
|
||||
<Sun size={18} className="icon" />
|
||||
) : (
|
||||
<SunMoon size={18} className="icon" />
|
||||
)}
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
</UserMenu>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--assistant-width);
|
||||
max-width: var(--assistant-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const MainMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
padding-top: 0;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const MainMenuItem = styled.div<{ active?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background-color: ${({ active }) => (active ? 'var(--color-list-item)' : 'transparent')};
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
&:hover {
|
||||
background-color: ${({ active }) => (active ? 'var(--color-list-item)' : 'var(--color-list-item-hover)')};
|
||||
}
|
||||
`
|
||||
|
||||
const MainMenuItemLeft = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
const MainMenuItemRight = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
const MainMenuItemRightText = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const MainMenuItemIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
`
|
||||
|
||||
const MainMenuItemText = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const UserMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 8px;
|
||||
gap: 5px;
|
||||
|
||||
border-radius: 8px;
|
||||
&:hover {
|
||||
background-color: var(--color-list-item);
|
||||
}
|
||||
`
|
||||
|
||||
const UserMenuLeft = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const AvatarImg = styled(Avatar)`
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: var(--color-background-soft);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const UserMenuText = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const Icon = styled.div<{ theme: string }>`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
-webkit-app-region: none;
|
||||
border: 0.5px solid transparent;
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
.icon {
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
|
||||
border: 0.5px solid var(--color-border);
|
||||
.icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes borderBreath {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
&.opened-minapp {
|
||||
position: relative;
|
||||
}
|
||||
&.opened-minapp::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
opacity: 0.3;
|
||||
border: 0.5px solid var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const SubMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
export default MainSidebar
|
||||
@@ -1,24 +1,16 @@
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { Button } from 'antd'
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Navbar: FC<Props> = ({ children, ...props }) => {
|
||||
const backgroundColor = useNavBackgroundColor()
|
||||
|
||||
return (
|
||||
<NavbarContainer {...props} style={{ backgroundColor }}>
|
||||
{children}
|
||||
</NavbarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const NavbarLeft: FC<Props> = ({ children, ...props }) => {
|
||||
return <NavbarLeftContainer {...props}>{children}</NavbarLeftContainer>
|
||||
return <NavbarContainer {...props}>{children}</NavbarContainer>
|
||||
}
|
||||
|
||||
export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
|
||||
@@ -34,34 +26,56 @@ export const NavbarRight: FC<Props> = ({ children, ...props }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const NavbarMain: FC<Props> = ({ children, ...props }) => {
|
||||
const isFullscreen = useFullscreen()
|
||||
|
||||
return (
|
||||
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
|
||||
<CloseIconWindows />
|
||||
{children}
|
||||
<CloseIconMac />
|
||||
</NavbarMainContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const CloseIconMac = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!isMac) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <Button type="text" icon={<X size={18} />} onClick={() => navigate('/')} className="nodrag" />
|
||||
}
|
||||
|
||||
const CloseIconWindows = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (isMac) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
shape="circle"
|
||||
icon={<ChevronDown size={16} />}
|
||||
onClick={() => navigate('/')}
|
||||
className="nodrag"
|
||||
style={{ marginRight: 5 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NavbarContainer = styled.div`
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
|
||||
padding-left: ${isMac ? 'var(--sidebar-width)' : 0};
|
||||
-webkit-app-region: drag;
|
||||
`
|
||||
|
||||
const NavbarLeftContainer = styled.div`
|
||||
min-width: var(--assistants-width);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const NavbarCenterContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
@@ -72,3 +86,32 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
justify-content: space-between;
|
||||
padding-left: ${isMac ? '70px' : '10px'};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
-webkit-app-region: drag;
|
||||
`
|
||||
|
||||
const NavbarCenterContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
padding: 0 8px;
|
||||
font-weight: bold;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
@@ -9,16 +9,19 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import {
|
||||
CircleHelp,
|
||||
Compass,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
MessageSquareQuote,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
Palette,
|
||||
Settings,
|
||||
@@ -44,7 +47,7 @@ const Sidebar: FC = () => {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { theme, settingTheme, toggleTheme } = useTheme()
|
||||
const { theme, settedTheme, toggleTheme } = useTheme()
|
||||
const avatar = useAvatar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -61,10 +64,11 @@ const Sidebar: FC = () => {
|
||||
|
||||
const docsId = 'cherrystudio-docs'
|
||||
const onOpenDocs = () => {
|
||||
const isChinese = i18n.language.startsWith('zh')
|
||||
openMinapp({
|
||||
id: docsId,
|
||||
name: t('docs.title'),
|
||||
url: 'https://docs.cherry-ai.com/',
|
||||
url: isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us',
|
||||
logo: AppLogo
|
||||
})
|
||||
}
|
||||
@@ -104,13 +108,13 @@ const Sidebar: FC = () => {
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settingTheme}`)}
|
||||
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settedTheme}`)}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="right">
|
||||
<Icon theme={theme} onClick={() => toggleTheme()}>
|
||||
{settingTheme === 'dark' ? (
|
||||
{settedTheme === ThemeMode.dark ? (
|
||||
<Moon size={20} className="icon" />
|
||||
) : settingTheme === 'light' ? (
|
||||
) : settedTheme === ThemeMode.light ? (
|
||||
<Sun size={20} className="icon" />
|
||||
) : (
|
||||
<SunMoon size={20} className="icon" />
|
||||
@@ -137,7 +141,7 @@ const MainMenus: FC = () => {
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const { pathname } = useLocation()
|
||||
const { sidebarIcons } = useSettings()
|
||||
const { sidebarIcons, defaultPaintingProvider } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
const { theme } = useTheme()
|
||||
@@ -146,23 +150,25 @@ const MainMenus: FC = () => {
|
||||
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
|
||||
|
||||
const iconMap = {
|
||||
assistants: <MessageSquareQuote size={18} className="icon" />,
|
||||
assistants: <MessageSquare size={18} className="icon" />,
|
||||
agents: <Sparkle size={18} className="icon" />,
|
||||
paintings: <Palette size={18} className="icon" />,
|
||||
translate: <Languages size={18} className="icon" />,
|
||||
minapp: <LayoutGrid size={18} className="icon" />,
|
||||
knowledge: <FileSearch size={18} className="icon" />,
|
||||
files: <Folder size={17} className="icon" />
|
||||
files: <Folder size={17} className="icon" />,
|
||||
discover: <Compass size={18} className="icon" />
|
||||
}
|
||||
|
||||
const pathMap = {
|
||||
assistants: '/',
|
||||
agents: '/agents',
|
||||
paintings: '/paintings',
|
||||
paintings: `/paintings/${defaultPaintingProvider}`,
|
||||
translate: '/translate',
|
||||
minapp: '/apps',
|
||||
knowledge: '/knowledge',
|
||||
files: '/files'
|
||||
files: '/files',
|
||||
discover: '/discover'
|
||||
}
|
||||
|
||||
return sidebarIcons.visible.map((icon) => {
|
||||
|
||||
@@ -15,3 +15,18 @@ export const TOKENFLUX_HOST = 'https://tokenflux.ai'
|
||||
// Messages loading configuration
|
||||
export const INITIAL_MESSAGES_COUNT = 20
|
||||
export const LOAD_MORE_COUNT = 20
|
||||
|
||||
export const DEFAULT_COLOR_PRIMARY = '#00b96b'
|
||||
export const THEME_COLOR_PRESETS = [
|
||||
DEFAULT_COLOR_PRIMARY,
|
||||
'#FF5470', // Coral Pink
|
||||
'#14B8A6', // Teal
|
||||
'#6366F1', // Indigo
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#3B82F6', // Blue
|
||||
'#F59E0B', // Amber
|
||||
'#6D28D9', // Violet
|
||||
'#0EA5E9', // Sky Blue
|
||||
'#0284C7' // Light Blue
|
||||
]
|
||||
|
||||
@@ -395,6 +395,37 @@ export function getModelLogo(modelId: string) {
|
||||
}
|
||||
|
||||
export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
defaultModel: [
|
||||
{
|
||||
// 默认助手模型
|
||||
id: 'deepseek-ai/DeepSeek-V3',
|
||||
name: 'deepseek-ai/DeepSeek-V3',
|
||||
provider: 'silicon',
|
||||
group: 'deepseek-ai'
|
||||
},
|
||||
{
|
||||
// 默认话题命名模型
|
||||
id: 'Qwen/Qwen3-8B',
|
||||
name: 'Qwen/Qwen3-8B',
|
||||
provider: 'silicon',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
// 默认翻译模型
|
||||
id: 'deepseek-ai/DeepSeek-V3',
|
||||
name: 'deepseek-ai/DeepSeek-V3',
|
||||
provider: 'silicon',
|
||||
group: 'deepseek-ai'
|
||||
},
|
||||
{
|
||||
// 默认快捷助手模型
|
||||
id: 'deepseek-ai/DeepSeek-V3',
|
||||
name: 'deepseek-ai/DeepSeek-V3',
|
||||
provider: 'silicon',
|
||||
group: 'deepseek-ai'
|
||||
}
|
||||
],
|
||||
|
||||
aihubmix: [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
@@ -600,17 +631,17 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'Qwen2.5-7B-Instruct',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'meta-llama/Llama-3.3-70B-Instruct',
|
||||
name: 'meta-llama/Llama-3.3-70B-Instruct',
|
||||
provider: 'silicon',
|
||||
group: 'meta-llama'
|
||||
},
|
||||
{
|
||||
id: 'BAAI/bge-m3',
|
||||
name: 'BAAI/bge-m3',
|
||||
provider: 'silicon',
|
||||
group: 'BAAI'
|
||||
},
|
||||
{
|
||||
id: 'Qwen/Qwen3-8B',
|
||||
name: 'Qwen/Qwen3-8B',
|
||||
provider: 'silicon',
|
||||
group: 'Qwen'
|
||||
}
|
||||
],
|
||||
ppio: [
|
||||
@@ -1348,15 +1379,39 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
],
|
||||
grok: [
|
||||
{
|
||||
id: 'grok-beta',
|
||||
id: 'grok-3',
|
||||
provider: 'grok',
|
||||
name: 'Grok Beta',
|
||||
name: 'Grok 3',
|
||||
group: 'Grok'
|
||||
},
|
||||
{
|
||||
id: 'grok-vision-beta',
|
||||
id: 'grok-3-fast',
|
||||
provider: 'grok',
|
||||
name: 'Grok Vision Beta',
|
||||
name: 'Grok 3 Fast',
|
||||
group: 'Grok'
|
||||
},
|
||||
{
|
||||
id: 'grok-3-mini',
|
||||
provider: 'grok',
|
||||
name: 'Grok 3 Mini',
|
||||
group: 'Grok'
|
||||
},
|
||||
{
|
||||
id: 'grok-3-mini-fast',
|
||||
provider: 'grok',
|
||||
name: 'Grok 3 Mini Fast',
|
||||
group: 'Grok'
|
||||
},
|
||||
{
|
||||
id: 'grok-2-vision-1212',
|
||||
provider: 'grok',
|
||||
name: 'Grok 2 Vision 1212',
|
||||
group: 'Grok'
|
||||
},
|
||||
{
|
||||
id: 'grok-2-1212',
|
||||
provider: 'grok',
|
||||
name: 'Grok 2 1212',
|
||||
group: 'Grok'
|
||||
}
|
||||
],
|
||||
@@ -1685,24 +1740,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'ERNIE-Speed-128K',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'THUDM/glm-4-9b-chat',
|
||||
provider: 'dmxapi',
|
||||
name: 'THUDM/glm-4-9b-chat',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'glm-4-flash',
|
||||
provider: 'dmxapi',
|
||||
name: 'glm-4-flash',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'hunyuan-lite',
|
||||
provider: 'dmxapi',
|
||||
name: 'hunyuan-lite',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
provider: 'dmxapi',
|
||||
@@ -2315,7 +2352,8 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
|
||||
}
|
||||
|
||||
return (
|
||||
model.id.toLowerCase().includes('qwen3') ||
|
||||
model.id.toLowerCase().startsWith('qwen3') ||
|
||||
model.id.toLowerCase().startsWith('qwen/qwen3') ||
|
||||
[
|
||||
'qwen-plus-latest',
|
||||
'qwen-plus-0428',
|
||||
@@ -2606,7 +2644,8 @@ export function groupQwenModels(models: Model[]): Record<string, Model[]> {
|
||||
|
||||
export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
|
||||
// Gemini models
|
||||
'gemini-.*$': { min: 0, max: 24576 },
|
||||
'gemini-.*-flash.*$': { min: 0, max: 24576 },
|
||||
'gemini-.*-pro.*$': { min: 128, max: 32768 },
|
||||
|
||||
// Qwen models
|
||||
'qwen-plus-.*$': { min: 0, max: 38912 },
|
||||
@@ -2617,7 +2656,7 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
|
||||
// Claude models
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 64000 }
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
|
||||
}
|
||||
|
||||
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
|
||||
|
||||
@@ -47,7 +47,7 @@ As [role name], with [list skills], strictly adhering to [list constraints], usi
|
||||
`
|
||||
|
||||
export const SUMMARIZE_PROMPT =
|
||||
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks or other special symbols"
|
||||
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks, markdown language markers, or other special symbols"
|
||||
|
||||
// https://github.com/ItzCrazyKns/Perplexica/blob/master/src/lib/prompts/webSearch.ts
|
||||
export const SEARCH_SUMMARY_PROMPT = `
|
||||
|
||||
@@ -124,8 +124,8 @@ export const PROVIDER_CONFIG = {
|
||||
websites: {
|
||||
official: 'https://o3.fan',
|
||||
apiKey: 'https://o3.fan/token',
|
||||
docs: 'https://docs.o3.fan',
|
||||
models: 'https://docs.o3.fan/models'
|
||||
docs: '',
|
||||
models: 'https://o3.fan/info/models/'
|
||||
}
|
||||
},
|
||||
burncloud: {
|
||||
@@ -144,11 +144,10 @@ export const PROVIDER_CONFIG = {
|
||||
url: 'https://api.ppinfra.com/v3/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
official: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
models:
|
||||
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link'
|
||||
models: 'https://ppio.cn/model-api/product/llm-api?invited_by=JYT9GD&utm_source=github_cherry-studio'
|
||||
}
|
||||
},
|
||||
gemini: {
|
||||
@@ -170,7 +169,7 @@ export const PROVIDER_CONFIG = {
|
||||
official: 'https://www.siliconflow.cn',
|
||||
apiKey: 'https://cloud.siliconflow.cn/i/d1nTBKXU',
|
||||
docs: 'https://docs.siliconflow.cn/',
|
||||
models: 'https://docs.siliconflow.cn/docs/model-names'
|
||||
models: 'https://cloud.siliconflow.cn/models'
|
||||
}
|
||||
},
|
||||
'gitee-ai': {
|
||||
@@ -395,7 +394,7 @@ export const PROVIDER_CONFIG = {
|
||||
official: 'https://openrouter.ai/',
|
||||
apiKey: 'https://openrouter.ai/settings/keys',
|
||||
docs: 'https://openrouter.ai/docs/quick-start',
|
||||
models: 'https://openrouter.ai/docs/models'
|
||||
models: 'https://openrouter.ai/models'
|
||||
}
|
||||
},
|
||||
groq: {
|
||||
@@ -447,7 +446,7 @@ export const PROVIDER_CONFIG = {
|
||||
websites: {
|
||||
official: 'https://x.ai/',
|
||||
docs: 'https://docs.x.ai/',
|
||||
models: 'https://docs.x.ai/docs#getting-started'
|
||||
models: 'https://docs.x.ai/docs/models'
|
||||
}
|
||||
},
|
||||
hyperbolic: {
|
||||
|
||||
@@ -1,65 +1,127 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
export const TranslateLanguageOptions = [
|
||||
export interface TranslateLanguageOption {
|
||||
value: string
|
||||
langCode?: string
|
||||
label: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export const TranslateLanguageOptions: TranslateLanguageOption[] = [
|
||||
{
|
||||
value: 'english',
|
||||
langCode: 'en-us',
|
||||
label: i18n.t('languages.english'),
|
||||
emoji: '🇬🇧'
|
||||
},
|
||||
{
|
||||
value: 'chinese',
|
||||
langCode: 'zh-cn',
|
||||
label: i18n.t('languages.chinese'),
|
||||
emoji: '🇨🇳'
|
||||
},
|
||||
{
|
||||
value: 'chinese-traditional',
|
||||
langCode: 'zh-tw',
|
||||
label: i18n.t('languages.chinese-traditional'),
|
||||
emoji: '🇭🇰'
|
||||
},
|
||||
{
|
||||
value: 'japanese',
|
||||
langCode: 'ja-jp',
|
||||
label: i18n.t('languages.japanese'),
|
||||
emoji: '🇯🇵'
|
||||
},
|
||||
{
|
||||
value: 'korean',
|
||||
langCode: 'ko-kr',
|
||||
label: i18n.t('languages.korean'),
|
||||
emoji: '🇰🇷'
|
||||
},
|
||||
{
|
||||
value: 'russian',
|
||||
label: i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
},
|
||||
{
|
||||
value: 'spanish',
|
||||
label: i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
|
||||
{
|
||||
value: 'french',
|
||||
langCode: 'fr-fr',
|
||||
label: i18n.t('languages.french'),
|
||||
emoji: '🇫🇷'
|
||||
},
|
||||
{
|
||||
value: 'german',
|
||||
langCode: 'de-de',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
},
|
||||
{
|
||||
value: 'italian',
|
||||
langCode: 'it-it',
|
||||
label: i18n.t('languages.italian'),
|
||||
emoji: '🇮🇹'
|
||||
},
|
||||
{
|
||||
value: 'spanish',
|
||||
langCode: 'es-es',
|
||||
label: i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
{
|
||||
value: 'portuguese',
|
||||
langCode: 'pt-pt',
|
||||
label: i18n.t('languages.portuguese'),
|
||||
emoji: '🇵🇹'
|
||||
},
|
||||
{
|
||||
value: 'russian',
|
||||
langCode: 'ru-ru',
|
||||
label: i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
},
|
||||
{
|
||||
value: 'polish',
|
||||
langCode: 'pl-pl',
|
||||
label: i18n.t('languages.polish'),
|
||||
emoji: '🇵🇱'
|
||||
},
|
||||
{
|
||||
value: 'arabic',
|
||||
langCode: 'ar-ar',
|
||||
label: i18n.t('languages.arabic'),
|
||||
emoji: '🇸🇦'
|
||||
},
|
||||
{
|
||||
value: 'german',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
value: 'turkish',
|
||||
langCode: 'tr-tr',
|
||||
label: i18n.t('languages.turkish'),
|
||||
emoji: '🇹🇷'
|
||||
},
|
||||
{
|
||||
value: 'thai',
|
||||
langCode: 'th-th',
|
||||
label: i18n.t('languages.thai'),
|
||||
emoji: '🇹🇭'
|
||||
},
|
||||
{
|
||||
value: 'vietnamese',
|
||||
langCode: 'vi-vn',
|
||||
label: i18n.t('languages.vietnamese'),
|
||||
emoji: '🇻🇳'
|
||||
},
|
||||
{
|
||||
value: 'indonesian',
|
||||
langCode: 'id-id',
|
||||
label: i18n.t('languages.indonesian'),
|
||||
emoji: '🇮🇩'
|
||||
},
|
||||
{
|
||||
value: 'urdu',
|
||||
langCode: 'ur-pk',
|
||||
label: i18n.t('languages.urdu'),
|
||||
emoji: '🇵🇰'
|
||||
},
|
||||
{
|
||||
value: 'malay',
|
||||
langCode: 'ms-my',
|
||||
label: i18n.t('languages.malay'),
|
||||
emoji: '🇲🇾'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -15,13 +15,18 @@ import { FC, PropsWithChildren } from 'react'
|
||||
import { useTheme } from './ThemeProvider'
|
||||
|
||||
const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { language } = useSettings()
|
||||
const {
|
||||
language,
|
||||
userTheme: { colorPrimary }
|
||||
} = useSettings()
|
||||
const { theme: _theme } = useTheme()
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={getAntdLocale(language)}
|
||||
theme={{
|
||||
cssVar: true,
|
||||
hashed: false,
|
||||
algorithm: [_theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm],
|
||||
components: {
|
||||
Menu: {
|
||||
@@ -40,10 +45,17 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
},
|
||||
Tooltip: {
|
||||
fontSize: 13
|
||||
},
|
||||
ColorPicker: {
|
||||
fontFamily: 'var(--code-font-family)'
|
||||
},
|
||||
Segmented: {
|
||||
itemActiveBg: 'var(--color-background-mute)',
|
||||
itemHoverBg: 'var(--color-background-mute)'
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: '#00b96b',
|
||||
colorPrimary: colorPrimary,
|
||||
fontFamily: 'var(--font-family)'
|
||||
}
|
||||
}}>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeMode
|
||||
settingTheme: ThemeMode
|
||||
settedTheme: ThemeMode
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: ThemeMode.auto,
|
||||
settingTheme: ThemeMode.auto,
|
||||
theme: ThemeMode.system,
|
||||
settedTheme: ThemeMode.dark,
|
||||
toggleTheme: () => {}
|
||||
})
|
||||
|
||||
@@ -20,47 +21,62 @@ interface ThemeProviderProps extends PropsWithChildren {
|
||||
defaultTheme?: ThemeMode
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultTheme }) => {
|
||||
const { theme, setTheme } = useSettings()
|
||||
const [effectiveTheme, setEffectiveTheme] = useState(theme)
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// 用户设置的主题
|
||||
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
|
||||
const [actualTheme, setActualTheme] = useState<ThemeMode>(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
|
||||
)
|
||||
const { initUserTheme } = useUserTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
// 主题顺序是light, dark, auto, 所以需要先判断当前主题,然后取下一个主题
|
||||
switch (theme) {
|
||||
case ThemeMode.light:
|
||||
setTheme(ThemeMode.dark)
|
||||
break
|
||||
case ThemeMode.dark:
|
||||
setTheme(ThemeMode.auto)
|
||||
break
|
||||
case ThemeMode.auto:
|
||||
setTheme(ThemeMode.light)
|
||||
break
|
||||
}
|
||||
const nextTheme = {
|
||||
[ThemeMode.light]: ThemeMode.dark,
|
||||
[ThemeMode.dark]: ThemeMode.system,
|
||||
[ThemeMode.system]: ThemeMode.light
|
||||
}[settedTheme]
|
||||
setSettedTheme(nextTheme || ThemeMode.system)
|
||||
}
|
||||
|
||||
const tailwindThemeChange = (theme) => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.api?.setTheme(defaultTheme || theme)
|
||||
}, [defaultTheme, theme])
|
||||
window.api?.setTheme(settedTheme || actualTheme)
|
||||
}, [settedTheme, actualTheme])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('theme-mode', effectiveTheme)
|
||||
}, [effectiveTheme])
|
||||
document.body.setAttribute('theme-mode', settedTheme)
|
||||
tailwindThemeChange(settedTheme)
|
||||
}, [settedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
|
||||
const themeChangeListenerRemover = window.electron.ipcRenderer.on(
|
||||
IpcChannel.ThemeChange,
|
||||
(_, realTheam: ThemeMode) => {
|
||||
setEffectiveTheme(realTheam)
|
||||
}
|
||||
)
|
||||
return () => {
|
||||
themeChangeListenerRemover()
|
||||
}
|
||||
})
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
|
||||
return <ThemeContext value={{ theme: effectiveTheme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext>
|
||||
// if theme is old auto, then set theme to system
|
||||
// we can delete this after next big release
|
||||
if (settedTheme !== ThemeMode.dark && settedTheme !== ThemeMode.light && settedTheme !== ThemeMode.system) {
|
||||
setSettedTheme(ThemeMode.system)
|
||||
}
|
||||
|
||||
initUserTheme()
|
||||
|
||||
// listen for theme updates from main process
|
||||
return window.electron.ipcRenderer.on(IpcChannel.ThemeUpdated, (_, actualTheme: ThemeMode) => {
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
setActualTheme(actualTheme)
|
||||
})
|
||||
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.setTheme(settedTheme)
|
||||
}, [settedTheme])
|
||||
|
||||
return <ThemeContext value={{ theme: actualTheme, settedTheme, toggleTheme }}>{children}</ThemeContext>
|
||||
}
|
||||
|
||||
export const useTheme = () => use(ThemeContext)
|
||||
|
||||
@@ -281,7 +281,6 @@ export async function upgradeToV7(tx: Transaction): Promise<void> {
|
||||
modelId: oldMessage.modelId,
|
||||
model: oldMessage.model,
|
||||
type: oldMessage.type === 'clear' ? 'clear' : undefined,
|
||||
isPreset: oldMessage.isPreset,
|
||||
useful: oldMessage.useful,
|
||||
askId: oldMessage.askId,
|
||||
mentions: oldMessage.mentions,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './assets/styles/tailwind.css'
|
||||
import './assets/styles/index.scss'
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
|
||||
|
||||
19
src/renderer/src/hooks/use-mobile.ts
Normal file
19
src/renderer/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user