Compare commits
130 Commits
v1.4.0-rc.
...
v1.4.2-ui-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a33a8da5c1 | ||
|
|
e029159067 | ||
|
|
8582ad2529 | ||
|
|
e7f1127aee | ||
|
|
7e54c465b1 | ||
|
|
5c76d398c5 | ||
|
|
f6a935f14f | ||
|
|
26d018b1b7 | ||
|
|
cd8c5115df | ||
|
|
0020e9f3c9 | ||
|
|
8df4cd7e76 | ||
|
|
ee7e6c0f87 | ||
|
|
e65091f83c | ||
|
|
3ee8186f96 | ||
|
|
49f1b62848 | ||
|
|
90a84bb55a | ||
|
|
d2147aed3b | ||
|
|
4f28086a64 | ||
|
|
d9c20c8815 | ||
|
|
b951d89c6a | ||
|
|
ac7d4cb4fa | ||
|
|
d2ea0592ce | ||
|
|
66ddeb94bf | ||
|
|
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 |
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
|
||||
|
||||
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
@@ -115,3 +115,38 @@ jobs:
|
||||
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/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 }}"}'
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ stats.html
|
||||
local
|
||||
.aider*
|
||||
.cursorrules
|
||||
.cursor/rules
|
||||
.cursor/*
|
||||
|
||||
# vitest
|
||||
coverage
|
||||
|
||||
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 && typeof response.data[0]?.embedding === 'string') {
|
||||
+ 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 && typeof response.data[0]?.embedding === 'string') {
|
||||
+ 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/CherryStudioHQ
|
||||
[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
|
||||
|
||||
@@ -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/CherryStudioHQ
|
||||
[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/CherryStudioHQ
|
||||
[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
|
||||
|
||||
@@ -107,7 +107,11 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
新增划词助手
|
||||
助手支持分组
|
||||
支持主题颜色切换
|
||||
划词助手支持应用过滤
|
||||
划词助手:支持文本选择快捷键、开关快捷键、思考块支持和引用功能
|
||||
复制功能:新增纯文本复制(去除Markdown格式符号)
|
||||
知识库:支持设置向量维度,修复Ollama分数错误和维度编辑问题
|
||||
多语言:增加模型名称多语言提示和翻译源语言手动选择
|
||||
文件管理:修复主题/消息删除时文件未清理问题,优化文件选择流程
|
||||
模型:修复Gemini模型推理预算、Voyage AI嵌入问题和DeepSeek翻译模型更新
|
||||
图像功能:统一图片查看器,支持Base64图片渲染,修复图片预览相关问题
|
||||
UI:实现标签折叠/拖拽排序,修复气泡溢出,增加引文索引显示
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.0-rc.2",
|
||||
"version": "1.4.2",
|
||||
"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",
|
||||
@@ -47,6 +47,7 @@
|
||||
"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",
|
||||
@@ -67,9 +68,11 @@
|
||||
"@cherrystudio/embedjs-loader-sitemap": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-web": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/ollama": "^0.2.1",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tanstack/react-query": "^5.27.0",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
@@ -83,6 +86,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
@@ -90,7 +94,8 @@
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"selection-hook": "^0.9.17",
|
||||
"remove-markdown": "^0.6.2",
|
||||
"selection-hook": "^0.9.23",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"webdav": "^5.8.0",
|
||||
@@ -176,7 +181,7 @@
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"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",
|
||||
@@ -217,10 +222,10 @@
|
||||
"@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",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum IpcChannel {
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
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',
|
||||
@@ -20,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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
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']
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,15 @@ import EmbeddingsFactory from './EmbeddingsFactory'
|
||||
|
||||
export default class Embeddings {
|
||||
private sdk: BaseEmbeddings
|
||||
constructor({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
|
||||
this.sdk = EmbeddingsFactory.create({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
|
||||
constructor({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
|
||||
this.sdk = EmbeddingsFactory.create({
|
||||
model,
|
||||
provider,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
dimensions
|
||||
} as KnowledgeBaseParams)
|
||||
}
|
||||
public async init(): Promise<void> {
|
||||
return this.sdk.init()
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
|
||||
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
|
||||
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import VoyageEmbeddings from './VoyageEmbeddings'
|
||||
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
if (model.includes('voyage')) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize: 8
|
||||
if (provider === 'voyageai') {
|
||||
if (VOYAGE_SUPPORTED_DIM_MODELS.includes(model)) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize: 8
|
||||
})
|
||||
} else {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
}
|
||||
if (provider === 'ollama') {
|
||||
if (baseURL.includes('v1/')) {
|
||||
return new OllamaEmbeddings({
|
||||
model: model,
|
||||
baseUrl: baseURL.replace('v1/', ''),
|
||||
requestOptions: {
|
||||
// @ts-ignore expected
|
||||
'encoding-format': 'float'
|
||||
}
|
||||
})
|
||||
}
|
||||
return new OllamaEmbeddings({
|
||||
model: model,
|
||||
baseUrl: baseURL,
|
||||
requestOptions: {
|
||||
// @ts-ignore expected
|
||||
'encoding-format': 'float'
|
||||
}
|
||||
})
|
||||
}
|
||||
if (apiVersion !== undefined) {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
|
||||
|
||||
export default class VoyageEmbeddings extends BaseEmbeddings {
|
||||
/**
|
||||
* 支持设置嵌入维度的模型
|
||||
*/
|
||||
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
export class VoyageEmbeddings extends BaseEmbeddings {
|
||||
private model: _VoyageEmbeddings
|
||||
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
|
||||
super()
|
||||
if (!this.configuration) this.configuration = {}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
|
||||
if (!this.configuration.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
if (!SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
throw new Error(`VoyageEmbeddings only supports ${SUPPORTED_DIM_MODELS.join(', ')}`)
|
||||
}
|
||||
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
}
|
||||
override async getDimensions(): Promise<number> {
|
||||
|
||||
@@ -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'
|
||||
@@ -24,6 +24,36 @@ 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')
|
||||
}
|
||||
|
||||
// Enable features for unresponsive renderer js call stacks
|
||||
app.commandLine.appendSwitch('enable-features', 'DocumentPolicyIncludeJSCallStacksInCrashReports')
|
||||
app.on('web-contents-created', (_, webContents) => {
|
||||
webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Document-Policy': ['include-js-call-stacks-in-crash-reports']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
webContents.on('unresponsive', async () => {
|
||||
// Interrupt execution and collect call stack from unresponsive renderer
|
||||
Logger.error('Renderer unresponsive start')
|
||||
const callStack = await webContents.mainFrame.collectJavaScriptCallStack()
|
||||
Logger.error('Renderer unresponsive js call stack\n', callStack)
|
||||
})
|
||||
})
|
||||
|
||||
// in production mode, handle uncaught exception and unhandled rejection globally
|
||||
if (!isDev) {
|
||||
// handle uncaught exception
|
||||
|
||||
@@ -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'
|
||||
@@ -28,12 +27,14 @@ import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
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()
|
||||
@@ -112,6 +113,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
|
||||
appUpdater.setFeedUrl(feedUrl)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
@@ -122,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) => {
|
||||
@@ -373,4 +351,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// 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,4 +1,4 @@
|
||||
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'
|
||||
@@ -16,6 +16,7 @@ export enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
FeedUrl = 'feedUrl',
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
@@ -43,7 +44,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
getTheme(): ThemeMode {
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.auto)
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.system)
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
@@ -141,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)
|
||||
}
|
||||
@@ -151,7 +160,7 @@ export class ConfigManager {
|
||||
|
||||
// Selection Assistant: is enabled the selection assistant
|
||||
getSelectionAssistantEnabled(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, true)
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, false)
|
||||
}
|
||||
|
||||
setSelectionAssistantEnabled(value: boolean) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +110,21 @@ class KnowledgeService {
|
||||
private getRagApplication = async ({
|
||||
id,
|
||||
model,
|
||||
provider,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
dimensions
|
||||
}: KnowledgeBaseParams): Promise<RAGApplication> => {
|
||||
let ragApplication: RAGApplication
|
||||
const embeddings = new Embeddings({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
|
||||
const embeddings = new Embeddings({
|
||||
model,
|
||||
provider,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
dimensions
|
||||
} as KnowledgeBaseParams)
|
||||
try {
|
||||
ragApplication = await new RAGApplicationBuilder()
|
||||
.setModel('NO_MODEL')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isWin } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
|
||||
import type { ActionItem } from '../../renderer/src/types/selectionTypes'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import storeSyncService from './StoreSyncService'
|
||||
|
||||
let SelectionHook: SelectionHookConstructor | null = null
|
||||
try {
|
||||
@@ -36,6 +38,12 @@ type RelativeOrientation =
|
||||
| 'middleRight'
|
||||
| 'center'
|
||||
|
||||
enum TriggerMode {
|
||||
Selected = 'selected',
|
||||
Ctrlkey = 'ctrlkey',
|
||||
Shortcut = 'shortcut'
|
||||
}
|
||||
|
||||
/** SelectionService is a singleton class that manages the selection hook and the toolbar window
|
||||
*
|
||||
* Features:
|
||||
@@ -58,7 +66,7 @@ export class SelectionService {
|
||||
private initStatus: boolean = false
|
||||
private started: boolean = false
|
||||
|
||||
private triggerMode = 'selected'
|
||||
private triggerMode = TriggerMode.Selected
|
||||
private isFollowToolbar = true
|
||||
private isRemeberWinSize = false
|
||||
private filterMode = 'default'
|
||||
@@ -144,17 +152,25 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
private initConfig() {
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode()
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
|
||||
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
|
||||
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
|
||||
this.filterMode = configManager.getSelectionAssistantFilterMode()
|
||||
this.filterList = configManager.getSelectionAssistantFilterList()
|
||||
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
this.setHookFineTunedList()
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: TriggerMode) => {
|
||||
const oldTriggerMode = this.triggerMode
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => {
|
||||
this.triggerMode = triggerMode
|
||||
this.processTriggerMode()
|
||||
|
||||
//trigger mode changed, need to update the filter list
|
||||
if (oldTriggerMode !== triggerMode) {
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
}
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => {
|
||||
@@ -192,15 +208,52 @@ export class SelectionService {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const modeMap = {
|
||||
default: 0,
|
||||
whitelist: 1,
|
||||
blacklist: 2
|
||||
default: SelectionHook!.FilterMode.DEFAULT,
|
||||
whitelist: SelectionHook!.FilterMode.INCLUDE_LIST,
|
||||
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
|
||||
}
|
||||
if (!this.selectionHook.setGlobalFilterMode(modeMap[mode], list)) {
|
||||
|
||||
let combinedList: string[] = list
|
||||
let combinedMode = mode
|
||||
|
||||
//only the selected mode need to combine the predefined blacklist with the user-defined blacklist
|
||||
if (this.triggerMode === TriggerMode.Selected) {
|
||||
switch (mode) {
|
||||
case 'blacklist':
|
||||
//combine the predefined blacklist with the user-defined blacklist
|
||||
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
|
||||
break
|
||||
case 'whitelist':
|
||||
combinedList = [...list]
|
||||
break
|
||||
case 'default':
|
||||
default:
|
||||
//use the predefined blacklist as the default filter list
|
||||
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
|
||||
combinedMode = 'blacklist'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.selectionHook.setGlobalFilterMode(modeMap[combinedMode], combinedList)) {
|
||||
this.logError(new Error('Failed to set selection-hook global filter mode'))
|
||||
}
|
||||
}
|
||||
|
||||
private setHookFineTunedList() {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
this.selectionHook.setFineTunedList(
|
||||
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
|
||||
SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
|
||||
)
|
||||
|
||||
this.selectionHook.setFineTunedList(
|
||||
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
|
||||
SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the selection service and initialize required windows
|
||||
* @returns {boolean} Success status of service start
|
||||
@@ -253,11 +306,18 @@ export class SelectionService {
|
||||
if (!this.selectionHook) return false
|
||||
|
||||
this.selectionHook.stop()
|
||||
this.selectionHook.cleanup()
|
||||
this.selectionHook.cleanup() //already remove all listeners
|
||||
|
||||
//reset the listener states
|
||||
this.isCtrlkeyListenerActive = false
|
||||
this.isHideByMouseKeyListenerActive = false
|
||||
|
||||
if (this.toolbarWindow) {
|
||||
this.toolbarWindow.close()
|
||||
this.toolbarWindow = null
|
||||
}
|
||||
this.closePreloadedActionWindows()
|
||||
|
||||
this.started = false
|
||||
this.logInfo('SelectionService Stopped')
|
||||
return true
|
||||
@@ -278,6 +338,21 @@ export class SelectionService {
|
||||
this.logInfo('SelectionService Quitted')
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the enabled state of the selection service
|
||||
* Will sync the new enabled store to all renderer windows
|
||||
*/
|
||||
public toggleEnabled(enabled: boolean | undefined = undefined) {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled
|
||||
|
||||
configManager.setSelectionAssistantEnabled(newEnabled)
|
||||
|
||||
//sync the new enabled state to all renderer windows
|
||||
storeSyncService.syncToRenderer('selectionStore/setSelectionEnabled', newEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure the toolbar window
|
||||
* Sets up window properties, event handlers, and loads the toolbar UI
|
||||
@@ -322,6 +397,9 @@ export class SelectionService {
|
||||
|
||||
// Clean up when closed
|
||||
this.toolbarWindow.on('closed', () => {
|
||||
if (!this.toolbarWindow?.isDestroyed()) {
|
||||
this.toolbarWindow?.destroy()
|
||||
}
|
||||
this.toolbarWindow = null
|
||||
})
|
||||
|
||||
@@ -507,6 +585,21 @@ export class SelectionService {
|
||||
return startTop.y === endTop.y && startBottom.y === endBottom.y
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user selected text and process it (trigger by shortcut)
|
||||
*
|
||||
* it's a public method used by shortcut service
|
||||
*/
|
||||
public processSelectTextByShortcut(): void {
|
||||
if (!this.selectionHook || !this.started || this.triggerMode !== TriggerMode.Shortcut) return
|
||||
|
||||
const selectionData = this.selectionHook.getCurrentSelection()
|
||||
|
||||
if (selectionData) {
|
||||
this.processTextSelection(selectionData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the text selection should be processed by filter mode&list
|
||||
* @param selectionData Text selection information and coordinates
|
||||
@@ -753,11 +846,11 @@ export class SelectionService {
|
||||
*/
|
||||
private handleKeyDownHide = (data: KeyboardEventData) => {
|
||||
//dont hide toolbar when ctrlkey is pressed
|
||||
if (this.triggerMode === 'ctrlkey' && this.isCtrlkey(data.vkCode)) {
|
||||
if (this.triggerMode === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) {
|
||||
return
|
||||
}
|
||||
//dont hide toolbar when shiftkey is pressed, because it's used for selection
|
||||
if (this.isShiftkey(data.vkCode)) {
|
||||
//dont hide toolbar when shiftkey or altkey is pressed, because it's used for selection
|
||||
if (this.isShiftkey(data.vkCode) || this.isAltkey(data.vkCode)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -785,6 +878,9 @@ export class SelectionService {
|
||||
//ctrlkey pressed
|
||||
if (this.lastCtrlkeyDownTime === 0) {
|
||||
this.lastCtrlkeyDownTime = Date.now()
|
||||
//add the mouse-wheel&mouse-down listener, detect if user is zooming in/out or multi-selecting
|
||||
this.selectionHook!.on('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
|
||||
this.selectionHook!.on('mouse-down', this.handleMouseDownCtrlkeyMode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -795,7 +891,6 @@ export class SelectionService {
|
||||
this.lastCtrlkeyDownTime = -1
|
||||
|
||||
const selectionData = this.selectionHook!.getCurrentSelection()
|
||||
|
||||
if (selectionData) {
|
||||
this.processTextSelection(selectionData)
|
||||
}
|
||||
@@ -808,9 +903,30 @@ export class SelectionService {
|
||||
*/
|
||||
private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => {
|
||||
if (!this.isCtrlkey(data.vkCode)) return
|
||||
//remove the mouse-wheel&mouse-down listener
|
||||
this.selectionHook!.off('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
|
||||
this.selectionHook!.off('mouse-down', this.handleMouseDownCtrlkeyMode)
|
||||
this.lastCtrlkeyDownTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel events in ctrlkey trigger mode
|
||||
* ignore CtrlKey pressing when mouse wheel is used
|
||||
* because user is zooming in/out
|
||||
*/
|
||||
private handleMouseWheelCtrlkeyMode = () => {
|
||||
this.lastCtrlkeyDownTime = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down events in ctrlkey trigger mode
|
||||
* ignore CtrlKey pressing when mouse down is used
|
||||
* because user is multi-selecting
|
||||
*/
|
||||
private handleMouseDownCtrlkeyMode = () => {
|
||||
this.lastCtrlkeyDownTime = -1
|
||||
}
|
||||
|
||||
//check if the key is ctrl key
|
||||
private isCtrlkey(vkCode: number) {
|
||||
return vkCode === 162 || vkCode === 163
|
||||
@@ -821,6 +937,11 @@ export class SelectionService {
|
||||
return vkCode === 160 || vkCode === 161
|
||||
}
|
||||
|
||||
//check if the key is alt key
|
||||
private isAltkey(vkCode: number) {
|
||||
return vkCode === 164 || vkCode === 165
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a preloaded action window for quick response
|
||||
* Action windows handle specific operations on selected text
|
||||
@@ -873,6 +994,17 @@ export class SelectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all preloaded action windows
|
||||
*/
|
||||
private closePreloadedActionWindows() {
|
||||
for (const actionWindow of this.preloadedActionWindows) {
|
||||
if (!actionWindow.isDestroyed()) {
|
||||
actionWindow.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a new action window asynchronously
|
||||
* This method is called after popping a window to ensure we always have windows ready
|
||||
@@ -1021,29 +1153,44 @@ export class SelectionService {
|
||||
* Manages appropriate event listeners for each mode
|
||||
*/
|
||||
private processTriggerMode() {
|
||||
if (this.triggerMode === 'selected') {
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
switch (this.triggerMode) {
|
||||
case TriggerMode.Selected:
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(false)
|
||||
} else if (this.triggerMode === 'ctrlkey') {
|
||||
if (!this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
this.selectionHook!.setSelectionPassiveMode(false)
|
||||
break
|
||||
case TriggerMode.Ctrlkey:
|
||||
if (!this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = true
|
||||
}
|
||||
this.isCtrlkeyListenerActive = true
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
break
|
||||
case TriggerMode.Shortcut:
|
||||
//remove the ctrlkey listener, don't need any key listener for shortcut mode
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public writeToClipboard(text: string): boolean {
|
||||
return this.selectionHook?.writeToClipboard(text) ?? false
|
||||
if (!this.selectionHook || !this.started) return false
|
||||
return this.selectionHook.writeToClipboard(text)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,16 @@ import { BrowserWindow, globalShortcut } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
let showAppAccelerator: string | null = null
|
||||
let showMiniWindowAccelerator: string | null = null
|
||||
let selectionAssistantToggleAccelerator: string | null = null
|
||||
let selectionAssistantSelectTextAccelerator: string | null = null
|
||||
|
||||
//indicate if the shortcuts are registered on app boot time
|
||||
let isRegisterOnBoot = true
|
||||
|
||||
// store the focus and blur handlers for each window to unregister them later
|
||||
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
|
||||
@@ -28,6 +34,18 @@ function getShortcutHandler(shortcut: Shortcut) {
|
||||
return () => {
|
||||
windowService.toggleMiniWindow()
|
||||
}
|
||||
case 'selection_assistant_toggle':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
}
|
||||
}
|
||||
case 'selection_assistant_select_text':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.processSelectTextByShortcut()
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -37,9 +55,8 @@ function formatShortcutKey(shortcut: string[]): string {
|
||||
return shortcut.join('+')
|
||||
}
|
||||
|
||||
const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat = (
|
||||
shortcut: string | string[]
|
||||
): string => {
|
||||
// convert the shortcut recorded by keyboard event key value to electron global shortcut format
|
||||
const convertShortcutFormat = (shortcut: string | string[]): string => {
|
||||
const accelerator = (() => {
|
||||
if (Array.isArray(shortcut)) {
|
||||
return shortcut
|
||||
@@ -93,11 +110,14 @@ const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutForm
|
||||
}
|
||||
|
||||
export function registerShortcuts(window: BrowserWindow) {
|
||||
window.once('ready-to-show', () => {
|
||||
if (configManager.getLaunchToTray()) {
|
||||
registerOnlyUniversalShortcuts()
|
||||
}
|
||||
})
|
||||
if (isRegisterOnBoot) {
|
||||
window.once('ready-to-show', () => {
|
||||
if (configManager.getLaunchToTray()) {
|
||||
registerOnlyUniversalShortcuts()
|
||||
}
|
||||
})
|
||||
isRegisterOnBoot = false
|
||||
}
|
||||
|
||||
//only for clearer code
|
||||
const registerOnlyUniversalShortcuts = () => {
|
||||
@@ -124,7 +144,12 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
// only register universal shortcuts when needed
|
||||
if (onlyUniversalShortcuts && !['show_app', 'mini_window'].includes(shortcut.key)) {
|
||||
if (
|
||||
onlyUniversalShortcuts &&
|
||||
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
|
||||
shortcut.key
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,6 +171,14 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_toggle':
|
||||
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_select_text':
|
||||
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
//the following ZOOMs will register shortcuts seperately, so will return
|
||||
case 'zoom_in':
|
||||
globalShortcut.register('CommandOrControl+=', () => handler(window))
|
||||
@@ -162,9 +195,7 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
const accelerator = convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(
|
||||
shortcut.shortcut
|
||||
)
|
||||
const accelerator = convertShortcutFormat(shortcut.shortcut)
|
||||
|
||||
globalShortcut.register(accelerator, () => handler(window))
|
||||
} catch (error) {
|
||||
@@ -181,15 +212,25 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
|
||||
if (showAppAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
|
||||
const accelerator =
|
||||
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showAppAccelerator)
|
||||
const accelerator = convertShortcutFormat(showAppAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (showMiniWindowAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
|
||||
const accelerator =
|
||||
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showMiniWindowAccelerator)
|
||||
const accelerator = convertShortcutFormat(showMiniWindowAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantToggleAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantSelectTextAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantSelectTextAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -217,6 +258,8 @@ export function unregisterAllShortcuts() {
|
||||
try {
|
||||
showAppAccelerator = null
|
||||
showMiniWindowAccelerator = null
|
||||
selectionAssistantToggleAccelerator = null
|
||||
selectionAssistantSelectTextAccelerator = null
|
||||
windowOnHandlers.forEach((handlers, window) => {
|
||||
window.off('focus', handlers.onFocusHandler)
|
||||
window.off('blur', handlers.onBlurHandler)
|
||||
|
||||
@@ -49,6 +49,23 @@ export class StoreSyncService {
|
||||
this.windowIds = this.windowIds.filter((id) => id !== windowId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an action to all renderer windows
|
||||
* @param type Action type, like 'settings/setTray'
|
||||
* @param payload Action payload
|
||||
*
|
||||
* NOTICE: DO NOT use directly in ConfigManager, may cause infinite sync loop
|
||||
*/
|
||||
public syncToRenderer(type: string, payload: any): void {
|
||||
const action: StoreSyncAction = {
|
||||
type,
|
||||
payload
|
||||
}
|
||||
|
||||
//-1 means the action is from the main process, will be broadcast to all windows
|
||||
this.broadcastToOtherWindows(-1, action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for store sync communication
|
||||
* Handles window subscription, unsubscription and action broadcasting
|
||||
|
||||
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()
|
||||
@@ -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,
|
||||
@@ -121,12 +116,6 @@ export class WindowService {
|
||||
app.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('unresponsive', () => {
|
||||
// 在升级到electron 34后,可以获取具体js stack trace,目前只打个日志监控下
|
||||
// https://www.electronjs.org/blog/electron-34-0#unresponsive-renderer-javascript-call-stacks
|
||||
Logger.error('Renderer process unresponsive')
|
||||
})
|
||||
}
|
||||
|
||||
private setupMaximize(mainWindow: BrowserWindow, isMaximized: boolean) {
|
||||
@@ -549,6 +538,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,7 +1,8 @@
|
||||
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'
|
||||
@@ -20,7 +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),
|
||||
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),
|
||||
@@ -84,7 +86,7 @@ const api = {
|
||||
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)
|
||||
@@ -226,7 +228,8 @@ const api = {
|
||||
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
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/renderer/src/assets/images/providers/nomic.png
Normal file
BIN
src/renderer/src/assets/images/providers/nomic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -26,6 +26,7 @@
|
||||
--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;
|
||||
@@ -43,6 +44,9 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: #222;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
@@ -67,7 +71,7 @@
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
--list-item-border-radius: 20px;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
@@ -98,6 +102,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 +120,9 @@
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--color-list-item: #eee;
|
||||
--color-list-item-hover: #f5f5f5;
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--color-highlight: initial;
|
||||
|
||||
@@ -4,9 +4,3 @@
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-container {
|
||||
.context-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,22 +129,29 @@ ul {
|
||||
.message-content-container {
|
||||
margin: 5px 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 15px 0 15px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.block-wrapper {
|
||||
display: flow-root;
|
||||
}
|
||||
|
||||
.message-content-container > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-thought-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
color: var(--chat-text-user);
|
||||
.markdown,
|
||||
.anticon,
|
||||
.iconfont,
|
||||
.lucide,
|
||||
.message-tokens {
|
||||
.message-content-container-user .anticon {
|
||||
color: var(--chat-text-user) !important;
|
||||
}
|
||||
.message-action-button:hover {
|
||||
background-color: var(--color-white-soft);
|
||||
|
||||
.markdown {
|
||||
color: var(--chat-text-user);
|
||||
}
|
||||
}
|
||||
.group-grid-container.horizontal,
|
||||
@@ -165,6 +172,12 @@ ul {
|
||||
code {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.markdown {
|
||||
display: flow-root;
|
||||
*:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lucide {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 全局初始化滚动条样式 */
|
||||
|
||||
@@ -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,28 +1,53 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useCallback, useEffect, 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)
|
||||
|
||||
const sanitizeSvg = useCallback((svgContent: string): string => {
|
||||
return svgContent.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (svgContainerRef.current) {
|
||||
svgContainerRef.current.innerHTML = sanitizeSvg(children)
|
||||
}
|
||||
}, [children, sanitizeSvg])
|
||||
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'
|
||||
})
|
||||
|
||||
@@ -33,16 +58,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return <SvgPreviewContainer ref={svgContainerRef} className="svg-preview" />
|
||||
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%;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ interface Props {
|
||||
onSave?: (newContent: string) => void
|
||||
onChange?: (newContent: string) => void
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
height?: string
|
||||
minHeight?: string
|
||||
maxHeight?: string
|
||||
/** 用于覆写编辑器的某些设置 */
|
||||
@@ -54,6 +55,7 @@ const CodeEditor = ({
|
||||
onSave,
|
||||
onChange,
|
||||
setTools,
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
options,
|
||||
@@ -193,6 +195,7 @@ const CodeEditor = ({
|
||||
value={initialContent.current}
|
||||
placeholder={placeholder}
|
||||
width="100%"
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
editable={true}
|
||||
@@ -224,11 +227,10 @@ const CodeEditor = ({
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
border: '0.5px solid transparent',
|
||||
borderRadius: '5px',
|
||||
marginTop: 0,
|
||||
...style
|
||||
marginTop: 0
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
@@ -7,12 +6,12 @@ import styled from 'styled-components'
|
||||
interface ContextMenuProps {
|
||||
children: React.ReactNode
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu, style }) => {
|
||||
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 +19,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 +38,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,19 +59,19 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container">
|
||||
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container" style={style}>
|
||||
{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 />
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import styled from 'styled-components'
|
||||
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
ref?: React.RefObject<HTMLDivElement | null>
|
||||
right?: boolean
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
|
||||
const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
@@ -25,7 +24,7 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: exter
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsScrolling(false)
|
||||
timeoutRef.current = null
|
||||
}, 1000)
|
||||
}, 1500)
|
||||
}, [clearScrollingTimeout])
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -52,7 +51,6 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: exter
|
||||
<Container
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
$isScrolling={isScrolling}
|
||||
$right={right}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
@@ -60,15 +58,13 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: exter
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -160,21 +160,6 @@ describe('Scrollbar', () => {
|
||||
})
|
||||
|
||||
describe('props handling', () => {
|
||||
it('should handle right prop correctly', () => {
|
||||
const { container } = render(
|
||||
<Scrollbar data-testid="scrollbar" right>
|
||||
内容
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 验证 right 属性被正确传递
|
||||
expect(scrollbar).toBeDefined()
|
||||
// snapshot 测试 styled-components 样式
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should handle ref forwarding', () => {
|
||||
const ref = { current: null }
|
||||
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Scrollbar > props handling > should handle right prop correctly 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="scrollbar"
|
||||
>
|
||||
内容
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
@@ -33,7 +11,7 @@ exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: transparent;
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
<div
|
||||
|
||||
@@ -34,6 +34,15 @@ export const NavbarRight: FC<Props> = ({ children, ...props }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const NavbarMain: FC<Props> = ({ children, ...props }) => {
|
||||
const isFullscreen = useFullscreen()
|
||||
return (
|
||||
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
|
||||
{children}
|
||||
</NavbarMainContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const NavbarContainer = styled.div`
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
@@ -72,3 +81,15 @@ 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;
|
||||
justify-content: space-between;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
`
|
||||
|
||||
@@ -9,6 +9,8 @@ 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'
|
||||
@@ -18,7 +20,7 @@ import {
|
||||
Folder,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
MessageSquareQuote,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
Palette,
|
||||
Settings,
|
||||
@@ -44,7 +46,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 +63,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 +107,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" />
|
||||
@@ -146,7 +149,7 @@ 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" />,
|
||||
|
||||
@@ -140,6 +140,8 @@ import XirangModelLogo from '@renderer/assets/images/models/xirang.png'
|
||||
import XirangModelLogoDark from '@renderer/assets/images/models/xirang_dark.png'
|
||||
import YiModelLogo from '@renderer/assets/images/models/yi.png'
|
||||
import YiModelLogoDark from '@renderer/assets/images/models/yi_dark.png'
|
||||
import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg'
|
||||
import NomicLogo from '@renderer/assets/images/providers/nomic.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { Assistant, Model } from '@renderer/types'
|
||||
import OpenAI from 'openai'
|
||||
@@ -297,7 +299,7 @@ export function getModelLogo(modelId: string) {
|
||||
'davinci-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
glm: isLight ? ChatGLMModelLogo : ChatGLMModelLogoDark,
|
||||
deepseek: isLight ? DeepSeekModelLogo : DeepSeekModelLogoDark,
|
||||
'(qwen|qwq-|qvq-)': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
'(qwen|qwq|qwq-|qvq-)': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
gemma: isLight ? GemmaModelLogo : GemmaModelLogoDark,
|
||||
'yi-': isLight ? YiModelLogo : YiModelLogoDark,
|
||||
llama: isLight ? LlamaModelLogo : LlamaModelLogoDark,
|
||||
@@ -376,12 +378,14 @@ export function getModelLogo(modelId: string) {
|
||||
'google/': isLight ? GoogleModelLogo : GoogleModelLogoDark,
|
||||
xirang: isLight ? XirangModelLogo : XirangModelLogoDark,
|
||||
hugging: isLight ? HuggingfaceModelLogo : HuggingfaceModelLogoDark,
|
||||
youdao: YoudaoLogo,
|
||||
embedding: isLight ? EmbeddingModelLogo : EmbeddingModelLogoDark,
|
||||
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
|
||||
sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
|
||||
'bge-': BgeModelLogo,
|
||||
'voyage-': VoyageModelLogo,
|
||||
tokenflux: isLight ? TokenFluxModelLogo : TokenFluxModelLogoDark
|
||||
tokenflux: isLight ? TokenFluxModelLogo : TokenFluxModelLogoDark,
|
||||
'nomic-': NomicLogo
|
||||
}
|
||||
|
||||
for (const key in logoMap) {
|
||||
@@ -395,6 +399,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 +635,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 +1383,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 +1744,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 +2356,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 +2648,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 },
|
||||
|
||||
@@ -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: {
|
||||
@@ -169,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': {
|
||||
@@ -394,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: {
|
||||
@@ -446,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: '🇲🇾'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@ 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: {
|
||||
|
||||
@@ -7,13 +7,13 @@ import React, { createContext, PropsWithChildren, use, useEffect, useState } fro
|
||||
|
||||
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: () => {}
|
||||
})
|
||||
|
||||
@@ -21,53 +21,48 @@ 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)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.api?.setTheme(defaultTheme || theme)
|
||||
}, [defaultTheme, theme])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('theme-mode', effectiveTheme)
|
||||
}, [effectiveTheme])
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial theme and OS attributes on body
|
||||
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)
|
||||
|
||||
// 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(() => {
|
||||
initUserTheme()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
window.api.setTheme(settedTheme)
|
||||
}, [settedTheme])
|
||||
|
||||
return <ThemeContext value={{ theme: effectiveTheme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext>
|
||||
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,
|
||||
|
||||
@@ -332,11 +332,11 @@ export function useMessageOperations(topic: Topic) {
|
||||
}
|
||||
|
||||
// 6. Log operations for debugging
|
||||
console.log('[editMessageBlocks] Operations:', {
|
||||
blocksToRemove: blockIdsToRemove.length,
|
||||
blocksToUpdate: blocksToUpdate.length,
|
||||
blocksToAdd: blocksToAdd.length
|
||||
})
|
||||
// console.log('[editMessageBlocks] Operations:', {
|
||||
// blocksToRemove: blockIdsToRemove.length,
|
||||
// blocksToUpdate: blocksToUpdate.length,
|
||||
// blocksToAdd: blocksToAdd.length
|
||||
// })
|
||||
|
||||
// 7. Update Redux state and database
|
||||
// First update message and add/update blocks
|
||||
|
||||
@@ -10,16 +10,19 @@ export function usePaintings() {
|
||||
const edit = useAppSelector((state) => state.paintings.edit)
|
||||
const upscale = useAppSelector((state) => state.paintings.upscale)
|
||||
const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings)
|
||||
const tokenFluxPaintings = useAppSelector((state) => state.paintings.tokenFluxPaintings)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
paintings,
|
||||
DMXAPIPaintings,
|
||||
tokenFluxPaintings,
|
||||
persistentData: {
|
||||
generate,
|
||||
remix,
|
||||
edit,
|
||||
upscale
|
||||
upscale,
|
||||
tokenFluxPaintings
|
||||
},
|
||||
addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => {
|
||||
dispatch(addPainting({ namespace, painting }))
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
SendMessageShortcut,
|
||||
setAssistantIconType,
|
||||
setAutoCheckUpdate as _setAutoCheckUpdate,
|
||||
setEarlyAccess as _setEarlyAccess,
|
||||
setLaunchOnBoot,
|
||||
setLaunchToTray,
|
||||
setPinTopicsToTop,
|
||||
setSendMessageShortcut as _setSendMessageShortcut,
|
||||
setShowTokens,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTheme,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
setWindowStyle
|
||||
} from '@renderer/store/settings'
|
||||
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
|
||||
export function useSettings() {
|
||||
const settings = useAppSelector((state) => state.settings)
|
||||
@@ -57,6 +60,11 @@ export function useSettings() {
|
||||
window.api.setAutoUpdate(isAutoUpdate)
|
||||
},
|
||||
|
||||
setEarlyAccess(isEarlyAccess: boolean) {
|
||||
dispatch(_setEarlyAccess(isEarlyAccess))
|
||||
window.api.setFeedUrl(isEarlyAccess ? FeedUrl.EARLY_ACCESS : FeedUrl.PRODUCTION)
|
||||
},
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
dispatch(setTheme(theme))
|
||||
},
|
||||
@@ -83,6 +91,9 @@ export function useSettings() {
|
||||
},
|
||||
setAssistantIconType(assistantIconType: AssistantIconType) {
|
||||
dispatch(setAssistantIconType(assistantIconType))
|
||||
},
|
||||
setShowTokens(showTokens: boolean) {
|
||||
dispatch(setShowTokens(showTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,65 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { RootState, useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setTagsOrder, updateAssistants } from '@renderer/store/assistants'
|
||||
import { flatMap, groupBy, uniq } from 'lodash'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useAssistants } from './useAssistant'
|
||||
|
||||
// 基础选择器
|
||||
const selectAssistantsState = (state: RootState) => state.assistants
|
||||
// 记忆化 tagsOrder 选择器(自动处理默认值)--- 这是一个选择器,用于从 store 中获取 tagsOrder 的值。因为之前的tagsOrder是后面新加的,不这样做会报错,所以这里需要处理一下默认值
|
||||
const selectTagsOrder = createSelector([selectAssistantsState], (assistants) => assistants.tagsOrder ?? [])
|
||||
|
||||
// 定义useTags的返回类型,包含所有标签和获取特定标签的助手函数
|
||||
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
|
||||
// 但是为了方便管理,增加了一个获取特定标签的助手函数
|
||||
|
||||
export const useTags = () => {
|
||||
const { assistants } = useAssistants()
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const savedTagsOrder = useAppSelector(selectTagsOrder)
|
||||
|
||||
// 计算所有标签
|
||||
const allTags = useMemo(() => {
|
||||
return uniq(flatMap(assistants, (assistant) => assistant.tags || []))
|
||||
}, [assistants])
|
||||
const tags = uniq(flatMap(assistants, (assistant) => assistant.tags || []))
|
||||
if (savedTagsOrder.length > 0) {
|
||||
return [
|
||||
...savedTagsOrder.filter((tag) => tags.includes(tag)),
|
||||
...tags.filter((tag) => !savedTagsOrder.includes(tag))
|
||||
]
|
||||
}
|
||||
return tags
|
||||
}, [assistants, savedTagsOrder])
|
||||
|
||||
const getAssistantsByTag = useCallback(
|
||||
(tag: string) => assistants.filter((assistant) => assistant.tags?.includes(tag)),
|
||||
[assistants]
|
||||
)
|
||||
|
||||
const updateTagsOrder = useCallback(
|
||||
(newOrder: string[]) => {
|
||||
dispatch(setTagsOrder(newOrder))
|
||||
updateAssistants(
|
||||
assistants.map((assistant) => {
|
||||
if (!assistant.tags || assistant.tags.length === 0) {
|
||||
return assistant
|
||||
}
|
||||
const newTags = [...assistant.tags]
|
||||
newTags.sort((a, b) => {
|
||||
return newOrder.indexOf(a) - newOrder.indexOf(b)
|
||||
})
|
||||
return {
|
||||
...assistant,
|
||||
tags: newTags
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[assistants, dispatch]
|
||||
)
|
||||
|
||||
const getGroupedAssistants = useMemo(() => {
|
||||
// 按标签分组,处理多标签的情况
|
||||
const assistantsByTags = flatMap(assistants, (assistant) => {
|
||||
@@ -42,12 +80,30 @@ export const useTags = () => {
|
||||
grouped.unshift(untagged)
|
||||
}
|
||||
|
||||
// 根据savedTagsOrder对标签组进行排序
|
||||
if (savedTagsOrder.length > 0) {
|
||||
const untagged = grouped.length > 0 && grouped[0].tag === t('assistants.tags.untagged') ? grouped.shift() : null
|
||||
grouped.sort((a, b) => {
|
||||
const indexA = savedTagsOrder.indexOf(a.tag)
|
||||
const indexB = savedTagsOrder.indexOf(b.tag)
|
||||
if (indexA === -1 && indexB === -1) return 0
|
||||
if (indexA === -1) return 1
|
||||
if (indexB === -1) return -1
|
||||
|
||||
return indexA - indexB
|
||||
})
|
||||
if (untagged) {
|
||||
grouped.unshift(untagged)
|
||||
}
|
||||
}
|
||||
|
||||
return grouped
|
||||
}, [assistants, t])
|
||||
}, [assistants, t, savedTagsOrder])
|
||||
|
||||
return {
|
||||
allTags,
|
||||
getAssistantsByTag,
|
||||
getGroupedAssistants
|
||||
getGroupedAssistants,
|
||||
updateTagsOrder
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"add.name.placeholder": "Enter name",
|
||||
"add.prompt": "Prompt",
|
||||
"add.prompt.placeholder": "Enter prompt",
|
||||
"add.prompt.variables.tip": "Available variables: {{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "Available variables",
|
||||
"content": "{{date}}:\tDate\n{{time}}:\tTime\n{{datetime}}:\tDate and time\n{{system}}:\tOperating system\n{{arch}}:\tCPU architecture\n{{language}}:\tLanguage\n{{model_name}}:\tModel name"
|
||||
},
|
||||
"add.title": "Create Agent",
|
||||
"import": {
|
||||
"title": "Import from External",
|
||||
@@ -30,16 +33,7 @@
|
||||
"agent": "Export Agent"
|
||||
},
|
||||
"delete.popup.content": "Are you sure you want to delete this agent?",
|
||||
"edit.message.add.title": "Add",
|
||||
"edit.message.assistant.placeholder": "Enter assistant message",
|
||||
"edit.message.assistant.title": "Assistant",
|
||||
"edit.message.empty.content": "Conversation input content cannot be empty",
|
||||
"edit.message.group.title": "Message Group",
|
||||
"edit.message.title": "Preset messages",
|
||||
"edit.message.user.placeholder": "Enter user message",
|
||||
"edit.message.user.title": "User",
|
||||
"edit.model.select.title": "Select Model",
|
||||
"edit.settings.hide_preset_messages": "Hide Preset Message",
|
||||
"edit.title": "Edit Agent",
|
||||
"manage.title": "Manage Agents",
|
||||
"my_agents": "My Agents",
|
||||
@@ -76,7 +70,6 @@
|
||||
"settings.mcp.noServersAvailable": "No MCP servers available. Add servers in settings",
|
||||
"settings.mcp.description": "Default enabled MCP servers",
|
||||
"settings.model": "Model Settings",
|
||||
"settings.preset_messages": "Preset Messages",
|
||||
"settings.prompt": "Prompt Settings",
|
||||
"settings.reasoning_effort": "Reasoning effort",
|
||||
"settings.reasoning_effort.off": "Off",
|
||||
@@ -268,6 +261,7 @@
|
||||
"topics.clear.title": "Clear Messages",
|
||||
"topics.copy.image": "Copy as image",
|
||||
"topics.copy.md": "Copy as markdown",
|
||||
"topics.copy.plain_text": "Copy as plain text (remove Markdown)",
|
||||
"topics.copy.title": "Copy",
|
||||
"topics.delete.shortcut": "Hold {{key}} to delete directly",
|
||||
"topics.edit.placeholder": "Enter new name",
|
||||
@@ -322,6 +316,7 @@
|
||||
"translate": "Translate",
|
||||
"topics.export.siyuan": "Export to Siyuan Note",
|
||||
"topics.export.wait_for_title_naming": "Generating title...",
|
||||
"topics.export.obsidian_reasoning": "Include Reasoning Chain",
|
||||
"topics.export.title_naming_success": "Title generated successfully",
|
||||
"topics.export.title_naming_failed": "Failed to generate title, using default title",
|
||||
"input.translating": "Translating...",
|
||||
@@ -571,8 +566,12 @@
|
||||
"urls": "URLs",
|
||||
"dimensions": "Embedding dimension",
|
||||
"dimensions_size_tooltip": "The size of the embedding dimension; the larger the value, the larger the embedding dimension, but it also consumes more tokens.",
|
||||
"dimensions_size_placeholder": "Default value (modification not recommended)",
|
||||
"dimensions_size_too_large": "The embedding dimension cannot exceed the model's context limit ({{max_context}})."
|
||||
"dimensions_size_placeholder": " Embedding dimension size, e.g. 1024",
|
||||
"dimensions_auto_set": "Auto-set embedding dimensions",
|
||||
"dimensions_error_invalid": "Please enter embedding dimension size",
|
||||
"dimensions_size_too_large": "The embedding dimension cannot exceed the model's context limit ({{max_context}}).",
|
||||
"dimensions_set_right": "⚠️ Please ensure the model supports the set embedding dimension size",
|
||||
"dimensions_default": "The model will use default embedding dimensions"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Arabic",
|
||||
@@ -586,7 +585,14 @@
|
||||
"korean": "Korean",
|
||||
"portuguese": "Portuguese",
|
||||
"russian": "Russian",
|
||||
"spanish": "Spanish"
|
||||
"spanish": "Spanish",
|
||||
"polish": "Polish",
|
||||
"turkish": "Turkish",
|
||||
"thai": "Thai",
|
||||
"vietnamese": "Vietnamese",
|
||||
"indonesian": "Indonesian",
|
||||
"urdu": "Urdu",
|
||||
"malay": "Malay"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||
@@ -627,6 +633,7 @@
|
||||
"error.enter.api.key": "Please enter your API key first",
|
||||
"error.enter.model": "Please select a model first",
|
||||
"error.enter.name": "Please enter the name of the knowledge base",
|
||||
"error.fetchTopicName": "Failed to name the topic",
|
||||
"error.get_embedding_dimensions": "Failed to get embedding dimensions",
|
||||
"error.invalid.api.host": "Invalid API Host",
|
||||
"error.invalid.api.key": "Invalid API Key",
|
||||
@@ -940,7 +947,23 @@
|
||||
"seed_tip": "Controls upscaling randomness",
|
||||
"magic_prompt_option_tip": "Intelligently enhances upscaling prompts"
|
||||
},
|
||||
"text_desc_required": "Please enter image description first"
|
||||
"text_desc_required": "Please enter image description first",
|
||||
"image_handle_required": "Please upload an image first.",
|
||||
"req_error_text": "Operation failed. Please try again. Avoid using 'copyrighted' or 'sensitive' words in your prompt.",
|
||||
"req_error_token": "Please check the validity of the token",
|
||||
"req_error_no_balance": "Please check the validity of the token",
|
||||
"auto_create_paint": "Auto-create image",
|
||||
"auto_create_paint_tip": "After the image is generated, a new image will be created automatically.",
|
||||
"select_model": "Select Model",
|
||||
"input_parameters": "Input Parameters",
|
||||
"input_image": "Input Image",
|
||||
"generated_image": "Generated Image",
|
||||
"pricing": "Pricing",
|
||||
"model_and_pricing": "Model & Pricing",
|
||||
"per_image": "per image",
|
||||
"per_images": "per images",
|
||||
"required_field": "Required field",
|
||||
"uploaded_input": "Uploaded input"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Explain this concept to me",
|
||||
@@ -1093,7 +1116,9 @@
|
||||
"token": "Joplin Authorization Token",
|
||||
"token_placeholder": "Joplin Authorization Token",
|
||||
"url": "Joplin Web Clipper Service URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
"url_placeholder": "http://127.0.0.1:41184/",
|
||||
"export_reasoning.title": "Include Reasoning Chain in Export",
|
||||
"export_reasoning.help": "When enabled, the exported content will include the reasoning chain (thought process) generated by the assistant."
|
||||
},
|
||||
"markdown_export.force_dollar_math.help": "When enabled, $$ will be forcibly used to mark LaTeX formulas when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
|
||||
"markdown_export.force_dollar_math.title": "Force $$ for LaTeX formulas",
|
||||
@@ -1102,12 +1127,14 @@
|
||||
"markdown_export.path_placeholder": "Export Path",
|
||||
"markdown_export.select": "Select",
|
||||
"markdown_export.title": "Markdown Export",
|
||||
"markdown_export.show_model_name.title": "Use Model Name on Export",
|
||||
"markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
|
||||
"markdown_export.show_model_provider.title": "Show Model Provider",
|
||||
"markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown",
|
||||
"minute_interval_one": "{{count}} minute",
|
||||
"minute_interval_other": "{{count}} minutes",
|
||||
"notion.api_key": "Notion API Key",
|
||||
"notion.api_key_placeholder": "Enter Notion API Key",
|
||||
"notion.auto_split": "Auto split when exporting",
|
||||
"notion.auto_split_tip": "Automatically split pages when exporting long topics to Notion",
|
||||
"notion.check": {
|
||||
"button": "Check",
|
||||
"empty_api_key": "API key is not configured",
|
||||
@@ -1121,10 +1148,9 @@
|
||||
"notion.help": "Notion Configuration Documentation",
|
||||
"notion.page_name_key": "Page Title Field Name",
|
||||
"notion.page_name_key_placeholder": "Enter page title field name, default is Name",
|
||||
"notion.split_size": "Split size",
|
||||
"notion.split_size_help": "Recommended: 90 for Free plan, 24990 for Plus plan, default is 90",
|
||||
"notion.split_size_placeholder": "Enter block limit per page (default 90)",
|
||||
"notion.title": "Notion Configuration",
|
||||
"notion.title": "Notion Settings",
|
||||
"notion.export_reasoning.title": "Include Reasoning Chain in Export",
|
||||
"notion.export_reasoning.help": "When enabled, exported content will include reasoning chain (thought process).",
|
||||
"title": "Data Settings",
|
||||
"webdav": {
|
||||
"autoSync": "Auto Backup",
|
||||
@@ -1320,6 +1346,8 @@
|
||||
"general.emoji_picker": "Emoji Picker",
|
||||
"general.image_upload": "Image Upload",
|
||||
"general.auto_check_update.title": "Auto Update",
|
||||
"general.early_access.title": "Early Access",
|
||||
"general.early_access.tooltip": "Enable to use the latest version from GitHub, which may be slower. Please backup your data in advance.",
|
||||
"general.reset.button": "Reset",
|
||||
"general.reset.title": "Data Reset",
|
||||
"general.restore.button": "Restore",
|
||||
@@ -1487,7 +1515,9 @@
|
||||
"advancedSettings": "Advanced Settings"
|
||||
},
|
||||
"messages.prompt": "Show prompt",
|
||||
"messages.tokens": "Show token usage",
|
||||
"messages.divider": "Show divider between messages",
|
||||
"messages.divider.tooltip": "Not applicable to bubble-style message",
|
||||
"messages.grid_columns": "Message grid display columns",
|
||||
"messages.grid_popover_trigger": "Grid detail trigger",
|
||||
"messages.grid_popover_trigger.click": "Click to display",
|
||||
@@ -1520,6 +1550,7 @@
|
||||
"models.add.model_id.select.placeholder": "Select Model",
|
||||
"models.add.model_id.tooltip": "Example: gpt-3.5-turbo",
|
||||
"models.add.model_name": "Model Name",
|
||||
"models.add.model_name.tooltip": "Optional e.g. GPT-4",
|
||||
"models.add.model_name.placeholder": "Optional e.g. GPT-4",
|
||||
"models.check.all": "All",
|
||||
"models.check.all_models_passed": "All models check passed",
|
||||
@@ -1671,6 +1702,8 @@
|
||||
"exit_fullscreen": "Exit Fullscreen",
|
||||
"key": "Key",
|
||||
"mini_window": "Quick Assistant",
|
||||
"selection_assistant_toggle": "Toggle Selection Assistant",
|
||||
"selection_assistant_select_text": "Selection Assistant: Select Text",
|
||||
"new_topic": "New Topic",
|
||||
"press_shortcut": "Press Shortcut",
|
||||
"reset_defaults": "Reset Defaults",
|
||||
@@ -1688,7 +1721,7 @@
|
||||
"zoom_out": "Zoom Out",
|
||||
"zoom_reset": "Reset Zoom"
|
||||
},
|
||||
"theme.auto": "Auto",
|
||||
"theme.system": "System",
|
||||
"theme.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
"theme.title": "Theme",
|
||||
@@ -1790,10 +1823,13 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "Any language",
|
||||
"target_language": "Target Language",
|
||||
"alter_language": "Alternative Language",
|
||||
"button.translate": "Translate",
|
||||
"close": "Close",
|
||||
"closed": "Translation closed",
|
||||
"copied": "Translation content copied",
|
||||
"detected.language": "Auto Detect",
|
||||
"empty": "Translation content is empty",
|
||||
"not.found": "Translation content not found",
|
||||
"confirm": {
|
||||
@@ -1812,8 +1848,16 @@
|
||||
"input.placeholder": "Enter text to translate",
|
||||
"output.placeholder": "Translation",
|
||||
"processing": "Translation in progress...",
|
||||
"scroll_sync.disable": "Disable synced scroll",
|
||||
"scroll_sync.enable": "Enable synced scroll",
|
||||
"language.same": "Source and target languages are the same",
|
||||
"language.not_pair": "Source language is different from the set language",
|
||||
"settings": {
|
||||
"title": "Translation Settings",
|
||||
"model": "Model Settings",
|
||||
"model_desc": "Model used for translation service",
|
||||
"bidirectional": "Bidirectional Translation Settings",
|
||||
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
|
||||
"scroll_sync": "Scroll Sync Settings"
|
||||
},
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline",
|
||||
"menu": {
|
||||
@@ -1831,6 +1875,13 @@
|
||||
"show_window": "Show Window",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"update": {
|
||||
"title": "Update",
|
||||
"message": "New version {{version}} is ready, do you want to install it now?",
|
||||
"later": "Later",
|
||||
"install": "Install",
|
||||
"noReleaseNotes": "No release notes"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Selection Assistant",
|
||||
"action": {
|
||||
@@ -1840,7 +1891,8 @@
|
||||
"summary": "Summarize",
|
||||
"search": "Search",
|
||||
"refine": "Refine",
|
||||
"copy": "Copy"
|
||||
"copy": "Copy",
|
||||
"quote": "Quote"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Pin",
|
||||
@@ -1853,6 +1905,9 @@
|
||||
"esc_stop": "Esc: Stop",
|
||||
"c_copy": "C: Copy",
|
||||
"r_regenerate": "R: Regenerate"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "Smart Translation: Content will be translated to the target language first; content already in the target language will be translated to the alternative language"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1865,10 +1920,15 @@
|
||||
"title": "Toolbar",
|
||||
"trigger_mode": {
|
||||
"title": "Trigger Mode",
|
||||
"description": "Show toolbar immediately when text is selected, or show only when Ctrl key is held after selection.",
|
||||
"description_note": "The Ctrl key may not work in some apps. If you use AHK or other tools to remap the Ctrl key, it may not work.",
|
||||
"description": "The way to trigger the selection assistant and show the toolbar",
|
||||
"description_note": "Some applications do not support selecting text with the Ctrl key. If you have remapped the Ctrl key using tools like AHK, it may cause some applications to fail to select text.",
|
||||
"selected": "Selection",
|
||||
"ctrlkey": "Ctrl Key"
|
||||
"selected_note": "Show toolbar immediately when text is selected",
|
||||
"ctrlkey": "Ctrl Key",
|
||||
"ctrlkey_note": "After selection, hold down the Ctrl key to show the toolbar",
|
||||
"shortcut": "Shortcut",
|
||||
"shortcut_note": "After selection, use shortcut to show the toolbar. Please set the shortcut in the shortcut settings page and enable it. ",
|
||||
"shortcut_link": "Go to Shortcut Settings"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "Compact Mode",
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"add.name.placeholder": "名前を入力",
|
||||
"add.prompt": "プロンプト",
|
||||
"add.prompt.placeholder": "プロンプトを入力",
|
||||
"add.prompt.variables.tip": "利用可能な変数:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "利用可能な変数",
|
||||
"content": "{{date}}:\t日付\n{{time}}:\t時間\n{{datetime}}:\t日付と時間\n{{system}}:\tオペレーティングシステム\n{{arch}}:\tCPUアーキテクチャ\n{{language}}:\t言語\n{{model_name}}:\tモデル名"
|
||||
},
|
||||
"add.title": "エージェントを作成",
|
||||
"import": {
|
||||
"title": "外部からインポート",
|
||||
@@ -30,16 +33,7 @@
|
||||
"agent": "エージェントをエクスポート"
|
||||
},
|
||||
"delete.popup.content": "このエージェントを削除してもよろしいですか?",
|
||||
"edit.message.add.title": "追加",
|
||||
"edit.message.assistant.placeholder": "アシスタントのメッセージを入力",
|
||||
"edit.message.assistant.title": "アシスタント",
|
||||
"edit.message.empty.content": "会話の入力内容が空です",
|
||||
"edit.message.group.title": "メッセージグループ",
|
||||
"edit.message.title": "プリセットメッセージ",
|
||||
"edit.message.user.placeholder": "ユーザーメッセージを入力",
|
||||
"edit.message.user.title": "ユーザー",
|
||||
"edit.model.select.title": "モデルを選択",
|
||||
"edit.settings.hide_preset_messages": "プリセットメッセージを非表示",
|
||||
"edit.title": "エージェントを編集",
|
||||
"manage.title": "エージェントを管理",
|
||||
"my_agents": "マイエージェント",
|
||||
@@ -76,7 +70,6 @@
|
||||
"settings.default_model": "デフォルトモデル",
|
||||
"settings.knowledge_base": "ナレッジベース設定",
|
||||
"settings.model": "モデル設定",
|
||||
"settings.preset_messages": "プリセットメッセージ",
|
||||
"settings.prompt": "プロンプト設定",
|
||||
"settings.reasoning_effort": "思考連鎖の長さ",
|
||||
"settings.reasoning_effort.off": "オフ",
|
||||
@@ -268,6 +261,7 @@
|
||||
"topics.clear.title": "メッセージをクリア",
|
||||
"topics.copy.image": "画像としてコピー",
|
||||
"topics.copy.md": "Markdownとしてコピー",
|
||||
"topics.copy.plain_text": "プレーンテキストとしてコピー(Markdownを除去)",
|
||||
"topics.copy.title": "コピー",
|
||||
"topics.delete.shortcut": "{{key}}キーを押しながらで直接削除",
|
||||
"topics.edit.placeholder": "新しい名前を入力",
|
||||
@@ -322,6 +316,7 @@
|
||||
"translate": "翻訳",
|
||||
"topics.export.siyuan": "思源笔记にエクスポート",
|
||||
"topics.export.wait_for_title_naming": "タイトルを生成中...",
|
||||
"topics.export.obsidian_reasoning": "思考過程を含める",
|
||||
"topics.export.title_naming_success": "タイトルの生成に成功しました",
|
||||
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します",
|
||||
"input.translating": "翻訳中...",
|
||||
@@ -571,8 +566,12 @@
|
||||
"urls": "URL",
|
||||
"dimensions": "埋め込み次元",
|
||||
"dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど埋め込み次元も大きくなりますが、消費するトークンも増えます。",
|
||||
"dimensions_size_placeholder": "デフォルト値(変更はお勧めしません)",
|
||||
"dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。"
|
||||
"dimensions_size_placeholder": " 埋め込み次元のサイズ(例:1024)",
|
||||
"dimensions_auto_set": "埋め込み次元を自動設定",
|
||||
"dimensions_error_invalid": "埋め込み次元のサイズを入力してください",
|
||||
"dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。",
|
||||
"dimensions_set_right": "⚠️ モデルが設定した埋め込み次元のサイズをサポートしていることを確認してください",
|
||||
"dimensions_default": "モデルはデフォルトの埋め込み次元を使用します"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "アラビア語",
|
||||
@@ -586,7 +585,14 @@
|
||||
"korean": "韓国語",
|
||||
"portuguese": "ポルトガル語",
|
||||
"russian": "ロシア語",
|
||||
"spanish": "スペイン語"
|
||||
"spanish": "スペイン語",
|
||||
"polish": "ポーランド語",
|
||||
"turkish": "トルコ語",
|
||||
"thai": "タイ語",
|
||||
"vietnamese": "ベトナム語",
|
||||
"indonesian": "インドネシア語",
|
||||
"urdu": "ウルドゥー語",
|
||||
"malay": "マレー語"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
@@ -940,7 +946,23 @@
|
||||
},
|
||||
"rendering_speed": "レンダリング速度",
|
||||
"translating": "翻訳中...",
|
||||
"text_desc_required": "画像の説明を先に入力してください"
|
||||
"text_desc_required": "画像の説明を先に入力してください",
|
||||
"image_handle_required": "最初に画像をアップロードしてください。",
|
||||
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
|
||||
"req_error_token": "トークンの有効性を確認してください",
|
||||
"req_error_no_balance": "トークンの有効性を確認してください",
|
||||
"auto_create_paint": "画像を自動作成",
|
||||
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。",
|
||||
"select_model": "モデルを選択",
|
||||
"input_parameters": "パラメータ入力",
|
||||
"input_image": "入力画像",
|
||||
"generated_image": "生成画像",
|
||||
"pricing": "料金",
|
||||
"model_and_pricing": "モデルと料金",
|
||||
"per_image": "1枚あたり",
|
||||
"per_images": "複数枚あたり",
|
||||
"required_field": "必須項目",
|
||||
"uploaded_input": "アップロード済みの入力"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "この概念を説明してください",
|
||||
@@ -1091,7 +1113,9 @@
|
||||
"token": "Joplin 認証トークン",
|
||||
"token_placeholder": "Joplin 認証トークンを入力してください",
|
||||
"url": "Joplin 剪輯服務 URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
"url_placeholder": "http://127.0.0.1:41184/",
|
||||
"export_reasoning.title": "エクスポート時に思考過程を含める",
|
||||
"export_reasoning.help": "有効にすると、エクスポートされる内容にアシスタントが生成した思考過程(リースニングチェーン)が含まれます。"
|
||||
},
|
||||
"markdown_export.force_dollar_math.help": "有効にすると、Markdownにエクスポートする際にLaTeX数式を$$で強制的にマークします。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
|
||||
"markdown_export.force_dollar_math.title": "LaTeX数式に$$を強制使用",
|
||||
@@ -1100,29 +1124,12 @@
|
||||
"markdown_export.path_placeholder": "エクスポートパス",
|
||||
"markdown_export.select": "選択",
|
||||
"markdown_export.title": "Markdown エクスポート",
|
||||
"markdown_export.show_model_name.title": "エクスポート時にモデル名を使用",
|
||||
"markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
|
||||
"markdown_export.show_model_provider.title": "モデルプロバイダーを表示",
|
||||
"markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダー(例:OpenAI、Geminiなど)を表示します。",
|
||||
"minute_interval_one": "{{count}} 分",
|
||||
"minute_interval_other": "{{count}} 分",
|
||||
"notion.api_key": "Notion APIキー",
|
||||
"notion.api_key_placeholder": "Notion APIキーを入力してください",
|
||||
"notion.auto_split": "ダイアログをエクスポートすると自動ページ分割",
|
||||
"notion.auto_split_tip": "ダイアログが長い場合、Notionに自動的にページ分割してエクスポートします",
|
||||
"notion.check": {
|
||||
"button": "確認",
|
||||
"empty_api_key": "Api_keyが設定されていません",
|
||||
"empty_database_id": "Database_idが設定されていません",
|
||||
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
|
||||
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
|
||||
"success": "接続に成功しました。"
|
||||
},
|
||||
"notion.database_id": "Notion データベースID",
|
||||
"notion.database_id_placeholder": "Notion データベースIDを入力してください",
|
||||
"notion.help": "Notion 設定ドキュメント",
|
||||
"notion.page_name_key": "ページタイトルフィールド名",
|
||||
"notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です",
|
||||
"notion.split_size": "自動ページ分割サイズ",
|
||||
"notion.split_size_help": "Notion無料版ユーザーは90、有料版ユーザーは24990、デフォルトは90",
|
||||
"notion.split_size_placeholder": "ページごとのブロック数制限を入力してください(デフォルト90)",
|
||||
"notion.title": "Notion 設定",
|
||||
"title": "データ設定",
|
||||
"webdav": {
|
||||
"autoSync": "自動バックアップ",
|
||||
@@ -1242,7 +1249,25 @@
|
||||
"new_folder.button": "新しいフォルダー"
|
||||
},
|
||||
"message_title.use_topic_naming.title": "トピック命名モデルを使用してメッセージのタイトルを作成",
|
||||
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。"
|
||||
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。",
|
||||
"notion.api_key": "Notion APIキー",
|
||||
"notion.api_key_placeholder": "Notion APIキーを入力してください",
|
||||
"notion.check": {
|
||||
"button": "確認",
|
||||
"empty_api_key": "Api_keyが設定されていません",
|
||||
"empty_database_id": "Database_idが設定されていません",
|
||||
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
|
||||
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
|
||||
"success": "接続に成功しました。"
|
||||
},
|
||||
"notion.database_id": "Notion データベースID",
|
||||
"notion.database_id_placeholder": "Notion データベースIDを入力してください",
|
||||
"notion.help": "Notion 設定ドキュメント",
|
||||
"notion.page_name_key": "ページタイトルフィールド名",
|
||||
"notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です",
|
||||
"notion.title": "Notion 設定",
|
||||
"notion.export_reasoning.title": "エクスポート時に思考チェーンを含める",
|
||||
"notion.export_reasoning.help": "有効にすると、Notionにエクスポートする際に思考チェーンの内容が含まれます。"
|
||||
},
|
||||
"display.assistant.title": "アシスタント設定",
|
||||
"display.custom.css": "カスタムCSS",
|
||||
@@ -1483,7 +1508,9 @@
|
||||
"advancedSettings": "詳細設定"
|
||||
},
|
||||
"messages.prompt": "プロンプト表示",
|
||||
"messages.tokens": "トークン使用量を表示",
|
||||
"messages.divider": "メッセージ間に区切り線を表示",
|
||||
"messages.divider.tooltip": "バブルスタイルのメッセージには適用されません",
|
||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||
"messages.grid_popover_trigger": "グリッド詳細トリガー",
|
||||
"messages.grid_popover_trigger.click": "クリックで表示",
|
||||
@@ -1516,7 +1543,8 @@
|
||||
"models.add.model_id.select.placeholder": "モデルを選択",
|
||||
"models.add.model_id.tooltip": "例:gpt-3.5-turbo",
|
||||
"models.add.model_name": "モデル名",
|
||||
"models.add.model_name.placeholder": "例:GPT-3.5",
|
||||
"models.add.model_name.tooltip": "例:GPT-4",
|
||||
"models.add.model_name.placeholder": "例:GPT-4",
|
||||
"models.check.all": "すべて",
|
||||
"models.check.all_models_passed": "すべてのモデルチェックが成功しました",
|
||||
"models.check.button_caption": "健康チェック",
|
||||
@@ -1661,6 +1689,8 @@
|
||||
"exit_fullscreen": "フルスクリーンを終了",
|
||||
"key": "キー",
|
||||
"mini_window": "クイックアシスタント",
|
||||
"selection_assistant_toggle": "選択アシスタントを切り替え",
|
||||
"selection_assistant_select_text": "選択アシスタント:テキストを選択",
|
||||
"new_topic": "新しいトピック",
|
||||
"press_shortcut": "ショートカットを押す",
|
||||
"reset_defaults": "デフォルトのショートカットをリセット",
|
||||
@@ -1678,7 +1708,7 @@
|
||||
"zoom_out": "ズームアウト",
|
||||
"zoom_reset": "ズームをリセット"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.system": "システム",
|
||||
"theme.dark": "ダーク",
|
||||
"theme.light": "ライト",
|
||||
"theme.title": "テーマ",
|
||||
@@ -1732,6 +1762,8 @@
|
||||
"content_limit_tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます。"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "早期アクセス",
|
||||
"general.early_access.tooltip": "有効にすると、GitHub の最新バージョンを使用します。ダウンロード速度が遅く、不安定な場合があります。データを事前にバックアップしてください。",
|
||||
"quickPhrase": {
|
||||
"title": "クイックフレーズ",
|
||||
"add": "フレーズを追加",
|
||||
@@ -1790,6 +1822,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意の言語",
|
||||
"target_language": "目標言語",
|
||||
"alter_language": "備用言語",
|
||||
"button.translate": "翻訳",
|
||||
"close": "閉じる",
|
||||
"closed": "翻訳は閉じられました",
|
||||
@@ -1812,13 +1846,22 @@
|
||||
"input.placeholder": "翻訳するテキストを入力",
|
||||
"output.placeholder": "翻訳",
|
||||
"processing": "翻訳中...",
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"language.same": "ソース言語と目標言語が同じです",
|
||||
"language.not_pair": "ソース言語が設定された言語と異なります",
|
||||
"settings": {
|
||||
"title": "翻訳設定",
|
||||
"model": "モデル設定",
|
||||
"model_desc": "翻訳サービスで使用されるモデル",
|
||||
"bidirectional": "双方向翻訳設定",
|
||||
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
|
||||
"scroll_sync": "スクロール同期設定"
|
||||
},
|
||||
"title": "翻訳",
|
||||
"tooltip.newline": "改行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"detected.language": "自動検出"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "終了",
|
||||
@@ -1831,6 +1874,13 @@
|
||||
"show_window": "ウィンドウを表示",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新",
|
||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||
"later": "後で",
|
||||
"install": "今すぐインストール",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "テキスト選択ツール",
|
||||
"action": {
|
||||
@@ -1840,7 +1890,8 @@
|
||||
"summary": "要約",
|
||||
"search": "検索",
|
||||
"refine": "最適化",
|
||||
"copy": "コピー"
|
||||
"copy": "コピー",
|
||||
"quote": "引用"
|
||||
},
|
||||
"window": {
|
||||
"pin": "最前面に固定",
|
||||
@@ -1853,6 +1904,9 @@
|
||||
"esc_stop": "Escで停止",
|
||||
"c_copy": "Cでコピー",
|
||||
"r_regenerate": "Rで再生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1864,11 +1918,16 @@
|
||||
"toolbar": {
|
||||
"title": "ツールバー",
|
||||
"trigger_mode": {
|
||||
"title": "表示方法",
|
||||
"description": "テキスト選択時に即時表示、またはCtrlキー押下時のみ表示",
|
||||
"description_note": "一部のアプリはCtrlキーでのテキスト選択に対応していません。AHKなどでCtrlキーをリマップすると、選択できなくなる場合があります。",
|
||||
"title": "単語の取り出し方",
|
||||
"description": "テキスト選択後、取詞ツールバーを表示する方法",
|
||||
"description_note": "一部のアプリケーションでは、Ctrl キーでテキストを選択できません。AHK などのツールを使用して Ctrl キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。",
|
||||
"selected": "選択時",
|
||||
"ctrlkey": "Ctrlキー"
|
||||
"selected_note": "テキスト選択時に即時表示",
|
||||
"ctrlkey": "Ctrlキー",
|
||||
"ctrlkey_note": "テキスト選択後、Ctrlキーを押下して表示",
|
||||
"shortcut": "ショートカットキー",
|
||||
"shortcut_note": "テキスト選択後、ショートカットキーを押下して表示。ショートカットキーを設定するには、ショートカット設定ページで有効にしてください。",
|
||||
"shortcut_link": "ショートカット設定ページに移動"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "コンパクトモード",
|
||||
|
||||
@@ -8,19 +8,13 @@
|
||||
"add.name.placeholder": "Введите имя",
|
||||
"add.prompt": "Промпт",
|
||||
"add.prompt.placeholder": "Введите промпт",
|
||||
"add.prompt.variables.tip": "Доступные переменные: {{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "Доступные переменные",
|
||||
"content": "{{date}}:\tДата\n{{time}}:\tВремя\n{{datetime}}:\tДата и время\n{{system}}:\tОперационная система\n{{arch}}:\tАрхитектура процессора\n{{language}}:\tЯзык\n{{model_name}}:\tНазвание модели"
|
||||
},
|
||||
"add.title": "Создать агента",
|
||||
"delete.popup.content": "Вы уверены, что хотите удалить этого агента?",
|
||||
"edit.message.add.title": "Добавить",
|
||||
"edit.message.assistant.placeholder": "Введите сообщение ассистента",
|
||||
"edit.message.assistant.title": "Ассистент",
|
||||
"edit.message.empty.content": "Содержание вводимого сообщения не может быть пустым",
|
||||
"edit.message.group.title": "Группа сообщений",
|
||||
"edit.message.title": "Предустановленные сообщения",
|
||||
"edit.message.user.placeholder": "Введите сообщение пользователя",
|
||||
"edit.message.user.title": "Пользователь",
|
||||
"edit.model.select.title": "Выбрать модель",
|
||||
"edit.settings.hide_preset_messages": "Скрыть предустановленные сообщения",
|
||||
"edit.title": "Редактировать агента",
|
||||
"manage.title": "Редактировать агентов",
|
||||
"my_agents": "Мои агенты",
|
||||
@@ -76,7 +70,6 @@
|
||||
"settings.default_model": "Модель по умолчанию",
|
||||
"settings.knowledge_base": "Настройки базы знаний",
|
||||
"settings.model": "Настройки модели",
|
||||
"settings.preset_messages": "Предустановленные сообщения",
|
||||
"settings.prompt": "Настройки промптов",
|
||||
"settings.reasoning_effort.off": "Выключить",
|
||||
"settings.reasoning_effort.high": "Стараюсь думать",
|
||||
@@ -268,6 +261,7 @@
|
||||
"topics.clear.title": "Очистить сообщения",
|
||||
"topics.copy.image": "Скопировать как изображение",
|
||||
"topics.copy.md": "Скопировать как Markdown",
|
||||
"topics.copy.plain_text": "Копировать как обычный текст (удалить Markdown)",
|
||||
"topics.copy.title": "Скопировать",
|
||||
"topics.delete.shortcut": "Удерживайте {{key}} для мгновенного удаления",
|
||||
"topics.edit.placeholder": "Введите новый заголовок",
|
||||
@@ -322,6 +316,7 @@
|
||||
"translate": "Перевести",
|
||||
"topics.export.siyuan": "Экспорт в Siyuan Note",
|
||||
"topics.export.wait_for_title_naming": "Создание заголовка...",
|
||||
"topics.export.obsidian_reasoning": "Включить цепочку рассуждений",
|
||||
"topics.export.title_naming_success": "Заголовок успешно создан",
|
||||
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию",
|
||||
"input.translating": "Перевод...",
|
||||
@@ -571,8 +566,12 @@
|
||||
"urls": "URL-адреса",
|
||||
"dimensions": "векторное пространство",
|
||||
"dimensions_size_tooltip": "Размерность вложения, чем больше значение, тем больше размерность вложения, но и потребляемых токенов также становится больше.",
|
||||
"dimensions_size_placeholder": "Значение по умолчанию (не рекомендуется изменять)",
|
||||
"dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})"
|
||||
"dimensions_size_placeholder": " Размерность эмбеддинга, например 1024",
|
||||
"dimensions_auto_set": "Автоматическая установка размерности эмбеддинга",
|
||||
"dimensions_error_invalid": "Пожалуйста, введите размерность эмбеддинга",
|
||||
"dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ Убедитесь, что модель поддерживает заданный размер эмбеддинга",
|
||||
"dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Арабский",
|
||||
@@ -586,7 +585,14 @@
|
||||
"korean": "Корейский",
|
||||
"portuguese": "Португальский",
|
||||
"russian": "Русский",
|
||||
"spanish": "Испанский"
|
||||
"spanish": "Испанский",
|
||||
"polish": "Польский",
|
||||
"turkish": "Туркменский",
|
||||
"thai": "Тайский",
|
||||
"vietnamese": "Вьетнамский",
|
||||
"indonesian": "Индонезийский",
|
||||
"urdu": "Урду",
|
||||
"malay": "Малайзийский"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
@@ -940,7 +946,23 @@
|
||||
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов",
|
||||
"magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов"
|
||||
},
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения"
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения",
|
||||
"image_handle_required": "Пожалуйста, сначала загрузите изображение.",
|
||||
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
|
||||
"req_error_token": "Пожалуйста, проверьте действительность токена",
|
||||
"req_error_no_balance": "Пожалуйста, проверьте действительность токена",
|
||||
"auto_create_paint": "Автоматическое создание изображения",
|
||||
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое.",
|
||||
"select_model": "Выбрать модель",
|
||||
"input_parameters": "Ввести параметры",
|
||||
"input_image": "Входное изображение",
|
||||
"generated_image": "Сгенерированное изображение",
|
||||
"pricing": "Цены",
|
||||
"model_and_pricing": "Модель и цены",
|
||||
"per_image": "за изображение",
|
||||
"per_images": "за изображения",
|
||||
"required_field": "Обязательное поле",
|
||||
"uploaded_input": "Загруженный ввод"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
@@ -1091,7 +1113,9 @@
|
||||
"token": "Токен Joplin",
|
||||
"token_placeholder": "Введите токен Joplin",
|
||||
"url": "URL Joplin",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
"url_placeholder": "http://127.0.0.1:41184/",
|
||||
"export_reasoning.title": "Включить цепочку рассуждений при экспорте",
|
||||
"export_reasoning.help": "Если включено, экспортируемый контент будет содержать цепочку рассуждений, сгенерированную ассистентом."
|
||||
},
|
||||
"markdown_export.force_dollar_math.help": "Если включено, при экспорте в Markdown для обозначения формул LaTeX будет принудительно использоваться $$. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
|
||||
"markdown_export.force_dollar_math.title": "Принудительно использовать $$ для формул LaTeX",
|
||||
@@ -1100,12 +1124,14 @@
|
||||
"markdown_export.path_placeholder": "Путь экспорта",
|
||||
"markdown_export.select": "Выбрать",
|
||||
"markdown_export.title": "Экспорт в Markdown",
|
||||
"markdown_export.show_model_name.title": "Использовать имя модели при экспорте",
|
||||
"markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
|
||||
"markdown_export.show_model_provider.title": "Показать поставщика модели",
|
||||
"markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown",
|
||||
"minute_interval_one": "{{count}} минута",
|
||||
"minute_interval_other": "{{count}} минут",
|
||||
"notion.api_key": "Ключ API Notion",
|
||||
"notion.api_key_placeholder": "Введите ключ API Notion",
|
||||
"notion.auto_split": "Автоматическое разбиение на страницы при экспорте диалога",
|
||||
"notion.auto_split_tip": "Автоматическое разбиение на страницы при экспорте в Notion, если тема слишком длинная",
|
||||
"notion.check": {
|
||||
"button": "Проверить",
|
||||
"empty_api_key": "Не настроен API key",
|
||||
@@ -1119,10 +1145,9 @@
|
||||
"notion.help": "Документация по настройке Notion",
|
||||
"notion.page_name_key": "Название поля заголовка страницы",
|
||||
"notion.page_name_key_placeholder": "Введите название поля заголовка страницы, по умолчанию Name",
|
||||
"notion.split_size": "Размер автоматического разбиения",
|
||||
"notion.split_size_help": "Рекомендуется 90 для пользователей бесплатной версии Notion, 24990 для пользователей премиум-версии, значение по умолчанию — 90",
|
||||
"notion.split_size_placeholder": "Введите ограничение количества блоков на странице (по умолчанию 90)",
|
||||
"notion.title": "Настройки Notion",
|
||||
"notion.export_reasoning.title": "Включить цепочку рассуждений при экспорте",
|
||||
"notion.export_reasoning.help": "При включении, содержимое цепочки рассуждений будет включено при экспорте в Notion.",
|
||||
"title": "Настройки данных",
|
||||
"webdav": {
|
||||
"autoSync": "Автоматическое резервное копирование",
|
||||
@@ -1483,7 +1508,9 @@
|
||||
"advancedSettings": "Расширенные настройки"
|
||||
},
|
||||
"messages.prompt": "Показывать подсказки",
|
||||
"messages.tokens": "Показать использование токенов",
|
||||
"messages.divider": "Показывать разделитель между сообщениями",
|
||||
"messages.divider.tooltip": "Не применимо к сообщениям в стиле пузырей",
|
||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
|
||||
"messages.grid_popover_trigger.click": "Нажатие для отображения",
|
||||
@@ -1516,6 +1543,7 @@
|
||||
"models.add.model_id.select.placeholder": "Выберите модель",
|
||||
"models.add.model_id.tooltip": "Пример: gpt-3.5-turbo",
|
||||
"models.add.model_name": "Имя модели",
|
||||
"models.add.model_name.tooltip": "Необязательно, например, GPT-4",
|
||||
"models.add.model_name.placeholder": "Необязательно, например, GPT-4",
|
||||
"models.check.all": "Все",
|
||||
"models.check.all_models_passed": "Все модели прошли проверку",
|
||||
@@ -1661,6 +1689,8 @@
|
||||
"exit_fullscreen": "Выйти из полноэкранного режима",
|
||||
"key": "Клавиша",
|
||||
"mini_window": "Быстрый помощник",
|
||||
"selection_assistant_toggle": "Переключить помощник выделения",
|
||||
"selection_assistant_select_text": "Помощник выделения: выделить текст",
|
||||
"new_topic": "Новый топик",
|
||||
"press_shortcut": "Нажмите сочетание клавиш",
|
||||
"reset_defaults": "Сбросить настройки по умолчанию",
|
||||
@@ -1678,7 +1708,7 @@
|
||||
"zoom_out": "Уменьшить",
|
||||
"zoom_reset": "Сбросить масштаб"
|
||||
},
|
||||
"theme.auto": "Автоматически",
|
||||
"theme.system": "Системная",
|
||||
"theme.dark": "Темная",
|
||||
"theme.light": "Светлая",
|
||||
"theme.title": "Тема",
|
||||
@@ -1731,7 +1761,9 @@
|
||||
"content_limit": "Ограничение длины текста",
|
||||
"content_limit_tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан."
|
||||
},
|
||||
"general.auto_check_update.title": "Включить автообновление",
|
||||
"general.auto_check_update.title": "Автоматическое обновление",
|
||||
"general.early_access.title": "Ранний доступ",
|
||||
"general.early_access.tooltip": "Включить для использования последней версии из GitHub, что может быть медленнее и нестабильно. Пожалуйста, сделайте резервную копию данных заранее.",
|
||||
"quickPhrase": {
|
||||
"title": "Быстрые фразы",
|
||||
"add": "Добавить фразу",
|
||||
@@ -1790,6 +1822,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "Любой язык",
|
||||
"target_language": "Целевой язык",
|
||||
"alter_language": "Альтернативный язык",
|
||||
"button.translate": "Перевести",
|
||||
"close": "Закрыть",
|
||||
"closed": "Перевод закрыт",
|
||||
@@ -1812,13 +1846,22 @@
|
||||
"input.placeholder": "Введите текст для перевода",
|
||||
"output.placeholder": "Перевод",
|
||||
"processing": "Перевод в процессе...",
|
||||
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
|
||||
"scroll_sync.enable": "Включить синхронизацию прокрутки",
|
||||
"language.same": "Исходный и целевой языки совпадают",
|
||||
"language.not_pair": "Исходный язык отличается от настроенного",
|
||||
"settings": {
|
||||
"title": "Настройки перевода",
|
||||
"model": "Настройки модели",
|
||||
"model_desc": "Модель, используемая для службы перевода",
|
||||
"bidirectional": "Настройки двунаправленного перевода",
|
||||
"scroll_sync": "Настройки синхронизации прокрутки",
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
|
||||
},
|
||||
"title": "Перевод",
|
||||
"tooltip.newline": "Перевести",
|
||||
"menu": {
|
||||
"description": "Перевести содержимое текущего ввода"
|
||||
}
|
||||
},
|
||||
"detected.language": "Автоматическое обнаружение"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "Выйти",
|
||||
@@ -1831,6 +1874,13 @@
|
||||
"show_window": "Показать окно",
|
||||
"visualization": "Визуализация"
|
||||
},
|
||||
"update": {
|
||||
"title": "Обновление",
|
||||
"message": "Новая версия {{version}} готова, установить сейчас?",
|
||||
"later": "Позже",
|
||||
"install": "Установить",
|
||||
"noReleaseNotes": "Нет заметок об обновлении"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Помощник выбора",
|
||||
"action": {
|
||||
@@ -1840,7 +1890,8 @@
|
||||
"summary": "Суммаризировать",
|
||||
"search": "Поиск",
|
||||
"refine": "Уточнить",
|
||||
"copy": "Копировать"
|
||||
"copy": "Копировать",
|
||||
"quote": "Цитировать"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Закрепить",
|
||||
@@ -1853,6 +1904,9 @@
|
||||
"esc_stop": "Esc - остановить",
|
||||
"c_copy": "C - копировать",
|
||||
"r_regenerate": "R - перегенерировать"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1865,10 +1919,15 @@
|
||||
"title": "Панель инструментов",
|
||||
"trigger_mode": {
|
||||
"title": "Режим активации",
|
||||
"description": "Показывать панель сразу при выделении или только при удержании Ctrl.",
|
||||
"description": "Показывать панель сразу при выделении, или только при удержании Ctrl, или только при нажатии на сочетание клавиш",
|
||||
"description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
|
||||
"selected": "При выделении",
|
||||
"ctrlkey": "По Ctrl"
|
||||
"selected_note": "После выделения",
|
||||
"ctrlkey": "По Ctrl",
|
||||
"ctrlkey_note": "После выделения, удерживайте Ctrl для показа панели. Пожалуйста, установите Ctrl в настройках клавиатуры и активируйте его.",
|
||||
"shortcut": "По сочетанию клавиш",
|
||||
"shortcut_note": "После выделения, используйте сочетание клавиш для показа панели. Пожалуйста, установите сочетание клавиш в настройках клавиатуры и активируйте его.",
|
||||
"shortcut_link": "Перейти к настройкам клавиатуры"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "Компактный режим",
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"add.name.placeholder": "输入名称",
|
||||
"add.prompt": "提示词",
|
||||
"add.prompt.placeholder": "输入提示词",
|
||||
"add.prompt.variables.tip": "可用的变量:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "可用的变量",
|
||||
"content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称"
|
||||
},
|
||||
"add.title": "创建智能体",
|
||||
"import": {
|
||||
"title": "从外部导入",
|
||||
@@ -30,16 +33,7 @@
|
||||
"agent": "导出智能体"
|
||||
},
|
||||
"delete.popup.content": "确定要删除此智能体吗?",
|
||||
"edit.message.add.title": "添加",
|
||||
"edit.message.assistant.placeholder": "输入助手消息",
|
||||
"edit.message.assistant.title": "助手",
|
||||
"edit.message.empty.content": "会话输入内容不能为空",
|
||||
"edit.message.group.title": "消息组",
|
||||
"edit.message.title": "预设消息",
|
||||
"edit.message.user.placeholder": "输入用户消息",
|
||||
"edit.message.user.title": "用户",
|
||||
"edit.model.select.title": "选择模型",
|
||||
"edit.settings.hide_preset_messages": "隐藏预设消息",
|
||||
"edit.title": "编辑智能体",
|
||||
"manage.title": "管理智能体",
|
||||
"my_agents": "我的智能体",
|
||||
@@ -83,7 +77,6 @@
|
||||
"settings.tool_use_mode.function": "函数",
|
||||
"settings.tool_use_mode.prompt": "提示词",
|
||||
"settings.model": "模型设置",
|
||||
"settings.preset_messages": "预设消息",
|
||||
"settings.prompt": "提示词设置",
|
||||
"settings.reasoning_effort": "思维链长度",
|
||||
"settings.reasoning_effort.off": "关闭",
|
||||
@@ -286,6 +279,7 @@
|
||||
"topics.clear.title": "清空消息",
|
||||
"topics.copy.image": "复制为图片",
|
||||
"topics.copy.md": "复制为 Markdown",
|
||||
"topics.copy.plain_text": "复制为纯文本(去除 Markdown)",
|
||||
"topics.copy.title": "复制",
|
||||
"topics.delete.shortcut": "按住 {{key}} 可直接删除",
|
||||
"topics.edit.placeholder": "输入新名称",
|
||||
@@ -325,6 +319,7 @@
|
||||
"topics.export.obsidian_no_vault_selected": "请先选择一个保管库",
|
||||
"topics.export.obsidian_select_vault_first": "请先选择保管库",
|
||||
"topics.export.obsidian_root_directory": "根目录",
|
||||
"topics.export.obsidian_reasoning": "导出思维链",
|
||||
"topics.export.title": "导出",
|
||||
"topics.export.word": "导出为 Word",
|
||||
"topics.export.yuque": "导出到语雀",
|
||||
@@ -524,7 +519,11 @@
|
||||
"delete_confirm": "确定要删除此知识库吗?",
|
||||
"dimensions": "嵌入维度",
|
||||
"dimensions_size_tooltip": "嵌入维度大小,数值越大,嵌入维度越大,但消耗的 Token 也越多",
|
||||
"dimensions_size_placeholder": " 默认值(不建议修改)",
|
||||
"dimensions_set_right": "⚠️ 请确保模型支持所设置的嵌入维度大小",
|
||||
"dimensions_default": "模型将使用默认嵌入维度",
|
||||
"dimensions_size_placeholder": " 嵌入维度大小,如 1024",
|
||||
"dimensions_auto_set": "自动设置嵌入维度",
|
||||
"dimensions_error_invalid": "请输入嵌入维度大小",
|
||||
"dimensions_size_too_large": "嵌入维度不能超过模型上下文限制({{max_context}})",
|
||||
"directories": "目录",
|
||||
"directory_placeholder": "请输入目录路径",
|
||||
@@ -586,7 +585,14 @@
|
||||
"korean": "韩文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"polish": "波兰文",
|
||||
"turkish": "土耳其文",
|
||||
"thai": "泰文",
|
||||
"vietnamese": "越南文",
|
||||
"indonesian": "印尼文",
|
||||
"urdu": "乌尔都文",
|
||||
"malay": "马来文"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
||||
@@ -627,6 +633,7 @@
|
||||
"error.enter.api.key": "请输入您的 API 密钥",
|
||||
"error.enter.model": "请选择一个模型",
|
||||
"error.enter.name": "请输入知识库名称",
|
||||
"error.fetchTopicName": "话题命名失败",
|
||||
"error.get_embedding_dimensions": "获取嵌入维度失败",
|
||||
"error.invalid.api.host": "无效的 API 地址",
|
||||
"error.invalid.api.key": "无效的 API 密钥",
|
||||
@@ -646,7 +653,7 @@
|
||||
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
|
||||
"group.delete.title": "删除分组消息",
|
||||
"ignore.knowledge.base": "联网模式开启,忽略知识库",
|
||||
"info.notion.block_reach_limit": "对话过长,正在分页导出到Notion",
|
||||
"info.notion.block_reach_limit": "对话过长,正在分段导出到Notion",
|
||||
"loading.notion.exporting_progress": "正在导出到Notion ({{current}}/{{total}})...",
|
||||
"loading.notion.preparing": "正在准备导出到Notion...",
|
||||
"mention.title": "切换模型回答",
|
||||
@@ -940,7 +947,23 @@
|
||||
"seed_tip": "控制放大结果的随机性",
|
||||
"magic_prompt_option_tip": "智能优化放大提示词"
|
||||
},
|
||||
"text_desc_required": "请先输入图片描述"
|
||||
"text_desc_required": "请先输入图片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"req_error_token": "请检查令牌有效性",
|
||||
"req_error_no_balance": "请检查令牌有效性",
|
||||
"image_handle_required": "请先上传图片",
|
||||
"auto_create_paint": "自动新建图片",
|
||||
"auto_create_paint_tip": "在图片生成后,会自动新建图片",
|
||||
"select_model": "选择模型",
|
||||
"input_parameters": "输入参数",
|
||||
"input_image": "输入图片",
|
||||
"generated_image": "生成图片",
|
||||
"pricing": "定价",
|
||||
"model_and_pricing": "模型与定价",
|
||||
"per_image": "每张图片",
|
||||
"per_images": "每张图片",
|
||||
"required_field": "必填项",
|
||||
"uploaded_input": "已上传输入"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "帮我解释一下这个概念",
|
||||
@@ -1093,7 +1116,9 @@
|
||||
"token": "Joplin 授权令牌",
|
||||
"token_placeholder": "请输入 Joplin 授权令牌",
|
||||
"url": "Joplin 剪裁服务监听 URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
"url_placeholder": "http://127.0.0.1:41184/",
|
||||
"export_reasoning.title": "导出时包含思维链",
|
||||
"export_reasoning.help": "开启后,导出到Joplin时会包含思维链内容。"
|
||||
},
|
||||
"markdown_export.force_dollar_math.help": "开启后,导出Markdown时会将强制使用$$来标记LaTeX公式。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等",
|
||||
"markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式",
|
||||
@@ -1102,14 +1127,16 @@
|
||||
"markdown_export.path_placeholder": "导出路径",
|
||||
"markdown_export.select": "选择",
|
||||
"markdown_export.title": "Markdown 导出",
|
||||
"markdown_export.show_model_name.title": "导出时使用模型名称",
|
||||
"markdown_export.show_model_name.help": "开启后,使用话题命名模型为导出的消息创建标题。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。",
|
||||
"markdown_export.show_model_provider.title": "显示模型供应商",
|
||||
"markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等",
|
||||
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
|
||||
"message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式",
|
||||
"minute_interval_one": "{{count}} 分钟",
|
||||
"minute_interval_other": "{{count}} 分钟",
|
||||
"notion.api_key": "Notion 密钥",
|
||||
"notion.api_key_placeholder": "请输入Notion 密钥",
|
||||
"notion.auto_split": "导出对话时自动分页",
|
||||
"notion.auto_split_tip": "当要导出的话题过长时自动分页导出到Notion",
|
||||
"notion.check": {
|
||||
"button": "检测",
|
||||
"empty_api_key": "未配置 API key",
|
||||
@@ -1123,10 +1150,9 @@
|
||||
"notion.help": "Notion 配置文档",
|
||||
"notion.page_name_key": "页面标题字段名",
|
||||
"notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name",
|
||||
"notion.split_size": "自动分页大小",
|
||||
"notion.split_size_help": "Notion免费版用户建议设置为90,高级版用户建议设置为24990,默认值为90",
|
||||
"notion.split_size_placeholder": "请输入每页块数限制(默认90)",
|
||||
"notion.title": "Notion 配置",
|
||||
"notion.title": "Notion 设置",
|
||||
"notion.export_reasoning.title": "导出时包含思维链",
|
||||
"notion.export_reasoning.help": "开启后,导出到Notion时会包含思维链内容。",
|
||||
"title": "数据设置",
|
||||
"webdav": {
|
||||
"autoSync": "自动备份",
|
||||
@@ -1320,6 +1346,8 @@
|
||||
"general.emoji_picker": "表情选择器",
|
||||
"general.image_upload": "图片上传",
|
||||
"general.auto_check_update.title": "自动更新",
|
||||
"general.early_access.title": "抢先体验",
|
||||
"general.early_access.tooltip": "开启后,将使用 GitHub 的最新版本,下载速度可能较慢,请务必提前备份数据",
|
||||
"general.reset.button": "重置",
|
||||
"general.reset.title": "重置数据",
|
||||
"general.restore.button": "恢复",
|
||||
@@ -1487,7 +1515,9 @@
|
||||
"advancedSettings": "高级设置"
|
||||
},
|
||||
"messages.prompt": "显示提示词",
|
||||
"messages.tokens": "显示Token用量",
|
||||
"messages.divider": "消息分割线",
|
||||
"messages.divider.tooltip": "不适用于气泡样式消息",
|
||||
"messages.grid_columns": "消息网格展示列数",
|
||||
"messages.grid_popover_trigger": "网格详情触发",
|
||||
"messages.grid_popover_trigger.click": "点击显示",
|
||||
@@ -1520,7 +1550,8 @@
|
||||
"models.add.model_id.select.placeholder": "选择模型",
|
||||
"models.add.model_id.tooltip": "例如 gpt-3.5-turbo",
|
||||
"models.add.model_name": "模型名称",
|
||||
"models.add.model_name.placeholder": "例如 GPT-3.5",
|
||||
"models.add.model_name.placeholder": "例如 GPT-4",
|
||||
"models.add.model_name.tooltip": "例如 GPT-4",
|
||||
"models.check.all": "所有",
|
||||
"models.check.all_models_passed": "所有模型检测通过",
|
||||
"models.check.button_caption": "健康检测",
|
||||
@@ -1671,6 +1702,8 @@
|
||||
"exit_fullscreen": "退出全屏",
|
||||
"key": "按键",
|
||||
"mini_window": "快捷助手",
|
||||
"selection_assistant_toggle": "开关划词助手",
|
||||
"selection_assistant_select_text": "划词助手:取词",
|
||||
"new_topic": "新建话题",
|
||||
"press_shortcut": "按下快捷键",
|
||||
"reset_defaults": "重置默认快捷键",
|
||||
@@ -1680,7 +1713,7 @@
|
||||
"search_message_in_chat": "在当前对话中搜索消息",
|
||||
"show_app": "显示/隐藏应用",
|
||||
"show_settings": "打开设置",
|
||||
"title": "快捷方式",
|
||||
"title": "快捷键",
|
||||
"toggle_new_context": "清除上下文",
|
||||
"toggle_show_assistants": "切换助手显示",
|
||||
"toggle_show_topics": "切换话题显示",
|
||||
@@ -1688,7 +1721,7 @@
|
||||
"zoom_out": "缩小界面",
|
||||
"zoom_reset": "重置缩放"
|
||||
},
|
||||
"theme.auto": "自动",
|
||||
"theme.system": "系统",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "浅色",
|
||||
"theme.title": "主题",
|
||||
@@ -1790,6 +1823,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意语言",
|
||||
"target_language": "目标语言",
|
||||
"alter_language": "备用语言",
|
||||
"button.translate": "翻译",
|
||||
"close": "关闭",
|
||||
"closed": "翻译已关闭",
|
||||
@@ -1815,10 +1850,19 @@
|
||||
"input.placeholder": "输入文本进行翻译",
|
||||
"output.placeholder": "翻译",
|
||||
"processing": "翻译中...",
|
||||
"scroll_sync.disable": "关闭滚动同步",
|
||||
"scroll_sync.enable": "开启滚动同步",
|
||||
"language.same": "源语言和目标语言相同",
|
||||
"language.not_pair": "源语言与设置的语言不同",
|
||||
"settings": {
|
||||
"title": "翻译设置",
|
||||
"model": "模型设置",
|
||||
"model_desc": "翻译服务使用的模型",
|
||||
"bidirectional": "双向翻译设置",
|
||||
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
|
||||
"scroll_sync": "滚动同步设置"
|
||||
},
|
||||
"title": "翻译",
|
||||
"tooltip.newline": "换行"
|
||||
"tooltip.newline": "换行",
|
||||
"detected.language": "自动检测"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "退出",
|
||||
@@ -1831,6 +1875,13 @@
|
||||
"show_window": "显示窗口",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "发现新版本 {{version}},是否立即安装?",
|
||||
"later": "稍后",
|
||||
"install": "立即安装",
|
||||
"noReleaseNotes": "暂无更新日志"
|
||||
},
|
||||
"selection": {
|
||||
"name": "划词助手",
|
||||
"action": {
|
||||
@@ -1840,7 +1891,8 @@
|
||||
"summary": "总结",
|
||||
"search": "搜索",
|
||||
"refine": "优化",
|
||||
"copy": "复制"
|
||||
"copy": "复制",
|
||||
"quote": "引用"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置顶",
|
||||
@@ -1853,6 +1905,9 @@
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 复制",
|
||||
"r_regenerate": "R 重新生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1864,11 +1919,16 @@
|
||||
"toolbar": {
|
||||
"title": "工具栏",
|
||||
"trigger_mode": {
|
||||
"title": "触发方式",
|
||||
"description": "划词立即显示工具栏,或者划词后按住 Ctrl 键才显示工具栏。",
|
||||
"title": "取词方式",
|
||||
"description": "划词后,触发取词并显示工具栏的方式",
|
||||
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
|
||||
"selected": "划词",
|
||||
"ctrlkey": "Ctrl 键"
|
||||
"selected_note": "划词后立即显示工具栏",
|
||||
"ctrlkey": "Ctrl 键",
|
||||
"ctrlkey_note": "划词后,再 按住 Ctrl键,才显示工具栏",
|
||||
"shortcut": "快捷键",
|
||||
"shortcut_note": "划词后,使用快捷键显示工具栏。请在快捷键设置页面中设置取词快捷键并启用。",
|
||||
"shortcut_link": "前往快捷键设置"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "紧凑模式",
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"add.name.placeholder": "輸入名稱",
|
||||
"add.prompt": "提示詞",
|
||||
"add.prompt.placeholder": "輸入提示詞",
|
||||
"add.prompt.variables.tip": "可用的變數:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "可用的變數",
|
||||
"content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱"
|
||||
},
|
||||
"add.title": "建立智慧代理人",
|
||||
"import": {
|
||||
"title": "從外部導入",
|
||||
@@ -30,16 +33,7 @@
|
||||
"agent": "匯出智慧代理人"
|
||||
},
|
||||
"delete.popup.content": "確定要刪除此智慧代理人嗎?",
|
||||
"edit.message.add.title": "新增",
|
||||
"edit.message.assistant.placeholder": "輸入助手訊息",
|
||||
"edit.message.assistant.title": "助手",
|
||||
"edit.message.empty.content": "會話輸入內容不能為空",
|
||||
"edit.message.group.title": "訊息分組",
|
||||
"edit.message.title": "預設訊息",
|
||||
"edit.message.user.placeholder": "輸入使用者訊息",
|
||||
"edit.message.user.title": "使用者",
|
||||
"edit.model.select.title": "選擇模型",
|
||||
"edit.settings.hide_preset_messages": "隱藏預設訊息",
|
||||
"edit.title": "編輯智慧代理人",
|
||||
"manage.title": "管理智慧代理人",
|
||||
"my_agents": "我的智慧代理人",
|
||||
@@ -76,7 +70,6 @@
|
||||
"settings.default_model": "預設模型",
|
||||
"settings.knowledge_base": "知識庫設定",
|
||||
"settings.model": "模型設定",
|
||||
"settings.preset_messages": "預設訊息",
|
||||
"settings.prompt": "提示詞設定",
|
||||
"settings.reasoning_effort": "思維鏈長度",
|
||||
"settings.reasoning_effort.off": "關閉",
|
||||
@@ -268,6 +261,7 @@
|
||||
"topics.clear.title": "清空訊息",
|
||||
"topics.copy.image": "複製為圖片",
|
||||
"topics.copy.md": "複製為 Markdown",
|
||||
"topics.copy.plain_text": "複製為純文字(移除 Markdown)",
|
||||
"topics.copy.title": "複製",
|
||||
"topics.delete.shortcut": "按住 {{key}} 可直接刪除",
|
||||
"topics.edit.placeholder": "輸入新名稱",
|
||||
@@ -322,6 +316,7 @@
|
||||
"translate": "翻譯",
|
||||
"topics.export.siyuan": "匯出到思源筆記",
|
||||
"topics.export.wait_for_title_naming": "正在生成標題...",
|
||||
"topics.export.obsidian_reasoning": "包含思維鏈",
|
||||
"topics.export.title_naming_success": "標題生成成功",
|
||||
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題",
|
||||
"input.translating": "翻譯中...",
|
||||
@@ -571,8 +566,12 @@
|
||||
"urls": "網址",
|
||||
"dimensions": "嵌入維度",
|
||||
"dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多",
|
||||
"dimensions_size_placeholder": "預設值(不建議修改)",
|
||||
"dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})"
|
||||
"dimensions_size_placeholder": " 嵌入維度大小,例如 1024",
|
||||
"dimensions_auto_set": "自動設定嵌入維度",
|
||||
"dimensions_error_invalid": "請輸入嵌入維度大小",
|
||||
"dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小",
|
||||
"dimensions_default": "模型將使用預設嵌入維度"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "阿拉伯文",
|
||||
@@ -586,7 +585,14 @@
|
||||
"korean": "韓文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"polish": "波蘭文",
|
||||
"turkish": "土耳其文",
|
||||
"thai": "泰文",
|
||||
"vietnamese": "越南文",
|
||||
"indonesian": "印尼文",
|
||||
"urdu": "烏爾都文",
|
||||
"malay": "馬來文"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||
@@ -615,7 +621,7 @@
|
||||
"citations": "引用內容",
|
||||
"copied": "已複製!",
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.success": "已複製!",
|
||||
"copy.success": "複製成功",
|
||||
"delete.confirm.title": "刪除確認",
|
||||
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
||||
"delete.failed": "刪除失敗",
|
||||
@@ -627,6 +633,7 @@
|
||||
"error.enter.api.key": "請先輸入您的 API 金鑰",
|
||||
"error.enter.model": "請先選擇一個模型",
|
||||
"error.enter.name": "請先輸入知識庫名稱",
|
||||
"error.fetchTopicName": "話題命名失敗",
|
||||
"error.get_embedding_dimensions": "取得嵌入維度失敗",
|
||||
"error.invalid.api.host": "無效的 API 位址",
|
||||
"error.invalid.api.key": "無效的 API 金鑰",
|
||||
@@ -940,7 +947,23 @@
|
||||
"magic_prompt_option_tip": "智能優化放大提示詞"
|
||||
},
|
||||
"rendering_speed": "渲染速度",
|
||||
"text_desc_required": "請先輸入圖片描述"
|
||||
"text_desc_required": "請先輸入圖片描述",
|
||||
"image_handle_required": "請先上傳圖片。",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"req_error_token": "請檢查令牌的有效性",
|
||||
"req_error_no_balance": "請檢查令牌的有效性",
|
||||
"auto_create_paint": "自動新增圖片",
|
||||
"auto_create_paint_tip": "圖片生成後,會自動新增圖片",
|
||||
"select_model": "選擇模型",
|
||||
"input_parameters": "輸入參數",
|
||||
"input_image": "輸入圖片",
|
||||
"generated_image": "生成圖片",
|
||||
"pricing": "定價",
|
||||
"model_and_pricing": "模型與定價",
|
||||
"per_image": "每張圖片",
|
||||
"per_images": "每張圖片",
|
||||
"required_field": "必填欄位",
|
||||
"uploaded_input": "已上傳輸入"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "幫我解釋一下這個概念",
|
||||
@@ -1093,7 +1116,9 @@
|
||||
"token": "Joplin 授權Token",
|
||||
"token_placeholder": "請輸入 Joplin 授權Token",
|
||||
"url": "Joplin 剪輯服務 URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
"url_placeholder": "http://127.0.0.1:41184/",
|
||||
"export_reasoning.title": "匯出時包含思維鏈",
|
||||
"export_reasoning.help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。"
|
||||
},
|
||||
"markdown_export.force_dollar_math.help": "開啟後,匯出Markdown時會強制使用$$來標記LaTeX公式。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等",
|
||||
"markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$",
|
||||
@@ -1102,12 +1127,14 @@
|
||||
"markdown_export.path_placeholder": "匯出路徑",
|
||||
"markdown_export.select": "選擇",
|
||||
"markdown_export.title": "Markdown 匯出",
|
||||
"markdown_export.show_model_name.title": "匯出時使用模型名稱",
|
||||
"markdown_export.show_model_name.help": "啟用後,將以主題命名模型為匯出的訊息建立標題。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。",
|
||||
"markdown_export.show_model_provider.title": "顯示模型供應商",
|
||||
"markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等",
|
||||
"minute_interval_one": "{{count}} 分鐘",
|
||||
"minute_interval_other": "{{count}} 分鐘",
|
||||
"notion.api_key": "Notion 金鑰",
|
||||
"notion.api_key_placeholder": "請輸入 Notion 金鑰",
|
||||
"notion.auto_split": "匯出對話時自動分頁",
|
||||
"notion.auto_split_tip": "當要匯出的話題過長時自動分頁匯出到 Notion",
|
||||
"notion.check": {
|
||||
"button": "檢查",
|
||||
"empty_api_key": "未設定 API key",
|
||||
@@ -1121,10 +1148,9 @@
|
||||
"notion.help": "Notion 設定文件",
|
||||
"notion.page_name_key": "頁面標題欄位名稱",
|
||||
"notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name",
|
||||
"notion.split_size": "自動分頁大小",
|
||||
"notion.split_size_help": "Notion 免費版使用者建議設定為 90,進階版使用者建議設定為 24990,預設值為 90",
|
||||
"notion.split_size_placeholder": "請輸入每頁塊數限制 (預設 90)",
|
||||
"notion.title": "Notion 設定",
|
||||
"notion.export_reasoning.title": "匯出時包含思維鏈",
|
||||
"notion.export_reasoning.help": "啟用後,匯出到Notion時會包含思維鏈內容。",
|
||||
"title": "資料設定",
|
||||
"webdav": {
|
||||
"autoSync": "自動備份",
|
||||
@@ -1486,7 +1512,9 @@
|
||||
"advancedSettings": "高級設定"
|
||||
},
|
||||
"messages.prompt": "提示詞顯示",
|
||||
"messages.tokens": "Token用量顯示",
|
||||
"messages.divider": "訊息間顯示分隔線",
|
||||
"messages.divider.tooltip": "不適用於氣泡樣式消息",
|
||||
"messages.grid_columns": "訊息網格展示列數",
|
||||
"messages.grid_popover_trigger": "網格詳細資訊觸發",
|
||||
"messages.grid_popover_trigger.click": "點選顯示",
|
||||
@@ -1520,6 +1548,7 @@
|
||||
"models.add.model_id.tooltip": "例如 gpt-3.5-turbo",
|
||||
"models.add.model_name": "模型名稱",
|
||||
"models.add.model_name.placeholder": "選填,例如 GPT-4",
|
||||
"models.add.model_name.tooltip": "例如 GPT-4",
|
||||
"models.check.all": "所有",
|
||||
"models.check.all_models_passed": "所有模型檢查通過",
|
||||
"models.check.button_caption": "健康檢查",
|
||||
@@ -1663,6 +1692,8 @@
|
||||
"copy_last_message": "複製上一則訊息",
|
||||
"key": "按鍵",
|
||||
"mini_window": "快捷助手",
|
||||
"selection_assistant_toggle": "開關劃詞助手",
|
||||
"selection_assistant_select_text": "劃詞助手:取词",
|
||||
"new_topic": "新增話題",
|
||||
"press_shortcut": "按下快捷鍵",
|
||||
"reset_defaults": "重設預設快捷鍵",
|
||||
@@ -1672,7 +1703,7 @@
|
||||
"search_message_in_chat": "在當前對話中搜尋訊息",
|
||||
"show_app": "顯示/隱藏應用程式",
|
||||
"show_settings": "開啟設定",
|
||||
"title": "快速方式",
|
||||
"title": "快捷鍵",
|
||||
"toggle_new_context": "清除上下文",
|
||||
"toggle_show_assistants": "切換助手顯示",
|
||||
"toggle_show_topics": "切換話題顯示",
|
||||
@@ -1681,7 +1712,7 @@
|
||||
"zoom_reset": "重設縮放",
|
||||
"exit_fullscreen": "退出螢幕"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.system": "系統",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "淺色",
|
||||
"theme.title": "主題",
|
||||
@@ -1734,7 +1765,9 @@
|
||||
"content_limit": "內容長度限制",
|
||||
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷"
|
||||
},
|
||||
"general.auto_check_update.title": "啟用自動更新",
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "搶先體驗",
|
||||
"general.early_access.tooltip": "開啟後,將使用 GitHub 的最新版本,下載速度可能較慢,請務必提前備份數據",
|
||||
"quickPhrase": {
|
||||
"title": "快捷短語",
|
||||
"add": "新增短語",
|
||||
@@ -1790,6 +1823,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意語言",
|
||||
"target_language": "目標語言",
|
||||
"alter_language": "備用語言",
|
||||
"button.translate": "翻譯",
|
||||
"close": "關閉",
|
||||
"closed": "翻譯已關閉",
|
||||
@@ -1812,13 +1847,22 @@
|
||||
"input.placeholder": "輸入文字進行翻譯",
|
||||
"output.placeholder": "翻譯",
|
||||
"processing": "翻譯中...",
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"language.same": "源語言和目標語言相同",
|
||||
"language.not_pair": "源語言與設定的語言不同",
|
||||
"settings": {
|
||||
"title": "翻譯設定",
|
||||
"model": "模型設定",
|
||||
"model_desc": "翻譯服務使用的模型",
|
||||
"bidirectional": "雙向翻譯設定",
|
||||
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
|
||||
"scroll_sync": "滾動同步設定"
|
||||
},
|
||||
"title": "翻譯",
|
||||
"tooltip.newline": "換行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"detected.language": "自動檢測"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "結束",
|
||||
@@ -1831,6 +1875,13 @@
|
||||
"show_window": "顯示視窗",
|
||||
"visualization": "視覺化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||
"later": "稍後",
|
||||
"install": "立即安裝",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "劃詞助手",
|
||||
"action": {
|
||||
@@ -1840,7 +1891,8 @@
|
||||
"summary": "總結",
|
||||
"search": "搜尋",
|
||||
"refine": "優化",
|
||||
"copy": "複製"
|
||||
"copy": "複製",
|
||||
"quote": "引用"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置頂",
|
||||
@@ -1853,6 +1905,9 @@
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 複製",
|
||||
"r_regenerate": "R 重新生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1864,11 +1919,16 @@
|
||||
"toolbar": {
|
||||
"title": "工具列",
|
||||
"trigger_mode": {
|
||||
"title": "觸發方式",
|
||||
"description": "劃詞立即顯示工具列,或者劃詞後按住 Ctrl 鍵才顯示工具列。",
|
||||
"title": "取詞方式",
|
||||
"description": "劃詞後,觸發取詞並顯示工具列的方式",
|
||||
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。",
|
||||
"selected": "劃詞",
|
||||
"ctrlkey": "Ctrl 鍵"
|
||||
"selected_note": "劃詞後,立即顯示工具列",
|
||||
"ctrlkey": "Ctrl 鍵",
|
||||
"ctrlkey_note": "劃詞後,再 按住 Ctrl鍵,才顯示工具列",
|
||||
"shortcut": "快捷鍵",
|
||||
"shortcut_note": "劃詞後,使用快捷鍵顯示工具列。請在快捷鍵設定頁面中設置取詞快捷鍵並啟用。",
|
||||
"shortcut_link": "前往快捷鍵設定"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "緊湊模式",
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
"add.name.placeholder": "Εισαγάγετε όνομα",
|
||||
"add.prompt": "Φράση προκαλέσεως",
|
||||
"add.prompt.placeholder": "Εισαγάγετε φράση προκαλέσεως",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "Διαθέσιμες μεταβλητές",
|
||||
"content": "{{date}}:\tΗμερομηνία\n{{time}}:\tΏρα\n{{datetime}}:\tΗμερομηνία και ώρα\n{{system}}:\tΛειτουργικό σύστημα\n{{arch}}:\tΑρχιτεκτονική CPU\n{{language}}:\tΓλώσσα\n{{model_name}}:\tΌνομα μοντέλου"
|
||||
},
|
||||
"add.title": "Δημιουργία νέου ειδικού",
|
||||
"delete.popup.content": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτόν τον ειδικό;",
|
||||
"edit.message.add.title": "Προσθήκη",
|
||||
"edit.message.assistant.placeholder": "Εισαγάγετε μήνυμα βοηθού",
|
||||
"edit.message.assistant.title": "Βοηθός",
|
||||
"edit.message.empty.content": "Το περιεχόμενο του συνομιλητή δεν μπορεί να είναι κενό.",
|
||||
"edit.message.group.title": "Ομάδα μηνυμάτων",
|
||||
"edit.message.title": "Προεπιλογές μηνυμάτων",
|
||||
"edit.message.user.placeholder": "Εισαγάγετε μήνυμα χρήστη",
|
||||
"edit.message.user.title": "Χρήστης",
|
||||
"edit.model.select.title": "Επιλογή μοντέλου",
|
||||
"edit.settings.hide_preset_messages": "Απόκρυψη προεπιλογών μηνυμάτων",
|
||||
"edit.title": "Επεξεργασία ειδικού",
|
||||
"manage.title": "Διαχείριση ειδικών",
|
||||
"my_agents": "Οι ειδικοί μου",
|
||||
@@ -64,7 +59,6 @@
|
||||
"settings.default_model": "Προεπιλεγμένο μοντέλο",
|
||||
"settings.knowledge_base": "Ρυθμίσεις βάσης γνώσεων",
|
||||
"settings.model": "Ρυθμίσεις μοντέλου",
|
||||
"settings.preset_messages": "Προεπιλεγμένα μηνύματα",
|
||||
"settings.prompt": "Ρυθμίσεις προκαλύμματος",
|
||||
"settings.reasoning_effort": "Μήκος λογισμικού αλυσίδας",
|
||||
"settings.reasoning_effort.high": "Μεγάλο",
|
||||
@@ -204,6 +198,7 @@
|
||||
"topics.clear.title": "Καθαρισμός μηνυμάτων",
|
||||
"topics.copy.image": "Αντιγραφή ως εικόνα",
|
||||
"topics.copy.md": "Αντιγραφή ως Markdown",
|
||||
"topics.copy.plain_text": "Αντιγραφή ως απλό κείμενο (αφαίρεση Markdown)",
|
||||
"topics.copy.title": "Αντιγραφή",
|
||||
"topics.delete.shortcut": "Πατήστε {{key}} για να διαγράψετε αμέσως",
|
||||
"topics.edit.placeholder": "Εισαγάγετε το νέο όνομα",
|
||||
@@ -496,8 +491,12 @@
|
||||
"urls": "Διευθύνσεις",
|
||||
"dimensions": "Διαστάσεις ενσωμάτωσης",
|
||||
"dimensions_size_tooltip": "Το μέγεθος των διαστάσεων ενσωμάτωσης. Όσο μεγαλύτερη η τιμή, τόσο περισσότερες οι διαστάσεις ενσωμάτωσης, αλλά και οι απαιτούμενες μονάδες (Tokens).",
|
||||
"dimensions_size_placeholder": "Προεπιλεγμένη τιμή (δεν συνιστάται να τροποποιηθεί)",
|
||||
"dimensions_size_too_large": "Οι διαστάσεις ενσωμάτωσης δεν μπορούν να υπερβούν το όριο περιεχομένου του μοντέλου ({{max_context}})"
|
||||
"dimensions_size_placeholder": " Μέγεθος διαστάσεων ενσωμάτωσης, π.χ. 1024",
|
||||
"dimensions_auto_set": "Αυτόματη ρύθμιση διαστάσεων ενσωμάτωσης",
|
||||
"dimensions_error_invalid": "Παρακαλώ εισάγετε μέγεθος διαστάσεων ενσωμάτωσης",
|
||||
"dimensions_size_too_large": "Οι διαστάσεις ενσωμάτωσης δεν μπορούν να υπερβούν το όριο περιεχομένου του μοντέλου ({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ Βεβαιωθείτε ότι το μοντέλο υποστηρίζει το καθορισμένο μέγεθος διαστάσεων ενσωμάτωσης",
|
||||
"dimensions_default": "Το μοντέλο θα χρησιμοποιήσει τις προεπιλεγμένες διαστάσεις ενσωμάτωσης"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Αραβικά",
|
||||
@@ -1305,6 +1304,7 @@
|
||||
"advancedSettings": "Προχωρημένες Ρυθμίσεις"
|
||||
},
|
||||
"messages.divider": "Διαχωριστική γραμμή μηνυμάτων",
|
||||
"messages.divider.tooltip": "Δεν ισχύει για μηνύματα με στυλ φυσαλίδας",
|
||||
"messages.grid_columns": "Αριθμός στήλων γριλ μηνυμάτων",
|
||||
"messages.grid_popover_trigger": "Καταγραφή στοιχείων στο grid",
|
||||
"messages.grid_popover_trigger.click": "Εμφάνιση κλικ",
|
||||
@@ -1478,7 +1478,7 @@
|
||||
"zoom_out": "Σμικρύνση εμφάνισης",
|
||||
"zoom_reset": "Επαναφορά εμφάνισης"
|
||||
},
|
||||
"theme.auto": "Αυτόματο",
|
||||
"theme.system": "Σύστημα",
|
||||
"theme.dark": "Σκοτεινό",
|
||||
"theme.light": "Φωτεινό",
|
||||
"theme.title": "Θέμα",
|
||||
@@ -1657,6 +1657,13 @@
|
||||
"quit": "Έξοδος",
|
||||
"show_window": "Εμφάνιση Παραθύρου",
|
||||
"visualization": "προβολή"
|
||||
},
|
||||
"update": {
|
||||
"title": "Ενημέρωση",
|
||||
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
||||
"later": "Μετά",
|
||||
"install": "Εγκατάσταση",
|
||||
"noReleaseNotes": "Χωρίς σημειώσεις"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
"add.name.placeholder": "Ingrese el nombre",
|
||||
"add.prompt": "Palabra clave",
|
||||
"add.prompt.placeholder": "Ingrese la palabra clave",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "Variables disponibles",
|
||||
"content": "{{date}}:\tFecha\n{{time}}:\tHora\n{{datetime}}:\tFecha y hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitectura de CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNombre del modelo"
|
||||
},
|
||||
"add.title": "Crear agente inteligente",
|
||||
"delete.popup.content": "¿Está seguro de que desea eliminar este agente inteligente?",
|
||||
"edit.message.add.title": "Agregar",
|
||||
"edit.message.assistant.placeholder": "Ingrese el mensaje del asistente",
|
||||
"edit.message.assistant.title": "Asistente",
|
||||
"edit.message.empty.content": "El contenido de la sesión de chat no puede estar vacío",
|
||||
"edit.message.group.title": "Grupo de mensajes",
|
||||
"edit.message.title": "Mensaje predeterminado",
|
||||
"edit.message.user.placeholder": "Ingrese el mensaje del usuario",
|
||||
"edit.message.user.title": "Usuario",
|
||||
"edit.model.select.title": "Seleccionar modelo",
|
||||
"edit.settings.hide_preset_messages": "Ocultar mensajes predeterminados",
|
||||
"edit.title": "Editar agente inteligente",
|
||||
"manage.title": "Administrar agentes inteligentes",
|
||||
"my_agents": "Mis agentes inteligentes",
|
||||
@@ -64,7 +59,6 @@
|
||||
"settings.default_model": "Modelo Predeterminado",
|
||||
"settings.knowledge_base": "Configuración de Base de Conocimientos",
|
||||
"settings.model": "Configuración de Modelo",
|
||||
"settings.preset_messages": "Mensajes Preestablecidos",
|
||||
"settings.prompt": "Configuración de Palabras Clave",
|
||||
"settings.reasoning_effort": "Longitud de Cadena de Razonamiento",
|
||||
"settings.reasoning_effort.high": "Largo",
|
||||
@@ -205,6 +199,7 @@
|
||||
"topics.clear.title": "Limpiar mensajes",
|
||||
"topics.copy.image": "Copiar como imagen",
|
||||
"topics.copy.md": "Copiar como Markdown",
|
||||
"topics.copy.plain_text": "Copiar como texto sin formato (eliminar Markdown)",
|
||||
"topics.copy.title": "Copiar",
|
||||
"topics.delete.shortcut": "Mantén presionada {{key}} para eliminar directamente",
|
||||
"topics.edit.placeholder": "Introduce nuevo nombre",
|
||||
@@ -497,8 +492,12 @@
|
||||
"urls": "URLs",
|
||||
"dimensions": "Dimensión de incrustación",
|
||||
"dimensions_size_tooltip": "Tamaño de la dimensión de incrustación, cuanto mayor sea el valor, mayor será la dimensión de incrustación, pero también consumirá más Tokens",
|
||||
"dimensions_size_placeholder": "Valor predeterminado (no recomendado modificar)",
|
||||
"dimensions_size_too_large": "La dimensión de incrustación no puede exceder el límite del contexto del modelo ({{max_context}})"
|
||||
"dimensions_size_placeholder": " Tamaño de dimensión de incrustación, ej. 1024",
|
||||
"dimensions_auto_set": "Configuración automática de dimensiones de incrustación",
|
||||
"dimensions_error_invalid": "Por favor ingrese el tamaño de dimensión de incrustación",
|
||||
"dimensions_size_too_large": "La dimensión de incrustación no puede exceder el límite del contexto del modelo ({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ Asegúrese de que el modelo admita el tamaño de dimensión de incrustación establecido",
|
||||
"dimensions_default": "El modelo utilizará las dimensiones de incrustación predeterminadas"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Árabe",
|
||||
@@ -1304,6 +1303,7 @@
|
||||
"advancedSettings": "Configuración avanzada"
|
||||
},
|
||||
"messages.divider": "Separador de mensajes",
|
||||
"messages.divider.tooltip": "No aplicable para mensajes de estilo burbuja",
|
||||
"messages.grid_columns": "Número de columnas en la cuadrícula de mensajes",
|
||||
"messages.grid_popover_trigger": "Desencadenante de detalles de cuadrícula",
|
||||
"messages.grid_popover_trigger.click": "Mostrar al hacer clic",
|
||||
@@ -1477,7 +1477,7 @@
|
||||
"zoom_out": "Reducir interfaz",
|
||||
"zoom_reset": "Restablecer zoom"
|
||||
},
|
||||
"theme.auto": "Automático",
|
||||
"theme.system": "Sistema",
|
||||
"theme.dark": "Oscuro",
|
||||
"theme.light": "Claro",
|
||||
"theme.title": "Tema",
|
||||
@@ -1656,6 +1656,13 @@
|
||||
"quit": "Salir",
|
||||
"show_window": "Mostrar Ventana",
|
||||
"visualization": "Visualización"
|
||||
},
|
||||
"update": {
|
||||
"title": "Actualización",
|
||||
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
||||
"later": "Más tarde",
|
||||
"install": "Instalar",
|
||||
"noReleaseNotes": "Sin notas de la versión"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
"add.name.placeholder": "Entrer le nom",
|
||||
"add.prompt": "Mot-clé",
|
||||
"add.prompt.placeholder": "Entrer le mot-clé",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "Variables disponibles",
|
||||
"content": "{{date}}:\tDate\n{{time}}:\tHeure\n{{datetime}}:\tDate et heure\n{{system}}:\tSystème d'exploitation\n{{arch}}:\tArchitecture du processeur\n{{language}}:\tLangue\n{{model_name}}:\tNom du modèle"
|
||||
},
|
||||
"add.title": "Créer un agent intelligent",
|
||||
"delete.popup.content": "Êtes-vous sûr de vouloir supprimer cet agent intelligent ?",
|
||||
"edit.message.add.title": "Ajouter",
|
||||
"edit.message.assistant.placeholder": "Entrer le message de l'assistant",
|
||||
"edit.message.assistant.title": "Assistant",
|
||||
"edit.message.empty.content": "Le contenu de la session ne peut pas être vide",
|
||||
"edit.message.group.title": "Groupe de messages",
|
||||
"edit.message.title": "Messages prédéfinis",
|
||||
"edit.message.user.placeholder": "Entrer le message de l'utilisateur",
|
||||
"edit.message.user.title": "Utilisateur",
|
||||
"edit.model.select.title": "Sélectionner un modèle",
|
||||
"edit.settings.hide_preset_messages": "Masquer les messages prédéfinis",
|
||||
"edit.title": "Modifier l'agent intelligent",
|
||||
"manage.title": "Gérer les agents intelligents",
|
||||
"my_agents": "Mes agents intelligents",
|
||||
@@ -64,7 +59,6 @@
|
||||
"settings.default_model": "Modèle par défaut",
|
||||
"settings.knowledge_base": "Paramètres de la base de connaissances",
|
||||
"settings.model": "Paramètres du modèle",
|
||||
"settings.preset_messages": "Messages prédéfinis",
|
||||
"settings.prompt": "Paramètres de l'invite",
|
||||
"settings.reasoning_effort": "Longueur de la chaîne de raisonnement",
|
||||
"settings.reasoning_effort.high": "Long",
|
||||
@@ -204,6 +198,7 @@
|
||||
"topics.clear.title": "Effacer le message",
|
||||
"topics.copy.image": "Copier sous forme d'image",
|
||||
"topics.copy.md": "Copier sous forme de Markdown",
|
||||
"topics.copy.plain_text": "Copier en tant que texte brut (supprimer Markdown)",
|
||||
"topics.copy.title": "Copier",
|
||||
"topics.delete.shortcut": "Maintenez {{key}} pour supprimer directement",
|
||||
"topics.edit.placeholder": "Entrez un nouveau nom",
|
||||
@@ -496,8 +491,12 @@
|
||||
"urls": "URLs",
|
||||
"dimensions": "Размерность встраивания",
|
||||
"dimensions_size_tooltip": "Размерность встраивания. Чем больше значение, тем выше размерность, но тем больше токенов требуется",
|
||||
"dimensions_size_placeholder": "Значение по умолчанию (не рекомендуется изменять)",
|
||||
"dimensions_size_too_large": "Размерность встраивания не может превышать ограничение контекста модели ({{max_context}})"
|
||||
"dimensions_size_placeholder": " Taille de dimension d'incorporation, ex. 1024",
|
||||
"dimensions_auto_set": "Réglage automatique des dimensions d'incorporation",
|
||||
"dimensions_error_invalid": "Veuillez saisir la taille de dimension d'incorporation",
|
||||
"dimensions_size_too_large": "Размерность встраивания не может превышать ограничение контекста модели ({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ Assurez-vous que le modèle prend en charge la taille de dimension d'incorporation définie",
|
||||
"dimensions_default": "Le modèle utilisera les dimensions d'incorporation par défaut"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Arabe",
|
||||
@@ -1305,6 +1304,7 @@
|
||||
"advancedSettings": "Расширенные настройки"
|
||||
},
|
||||
"messages.divider": "Séparateur de messages",
|
||||
"messages.divider.tooltip": "Non applicable aux messages de style bulle",
|
||||
"messages.grid_columns": "Nombre de colonnes de la grille de messages",
|
||||
"messages.grid_popover_trigger": "Déclencheur de popover de la grille",
|
||||
"messages.grid_popover_trigger.click": "Afficher au clic",
|
||||
@@ -1478,7 +1478,7 @@
|
||||
"zoom_out": "Réduire l'interface",
|
||||
"zoom_reset": "Réinitialiser le zoom"
|
||||
},
|
||||
"theme.auto": "Automatique",
|
||||
"theme.system": "Système",
|
||||
"theme.dark": "Sombre",
|
||||
"theme.light": "Clair",
|
||||
"theme.title": "Thème",
|
||||
@@ -1657,6 +1657,13 @@
|
||||
"quit": "Quitter",
|
||||
"show_window": "Afficher la fenêtre",
|
||||
"visualization": "Visualisation"
|
||||
},
|
||||
"update": {
|
||||
"title": "Mise à jour",
|
||||
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
||||
"later": "Plus tard",
|
||||
"install": "Installer",
|
||||
"noReleaseNotes": "Aucune note de version"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
"add.name.placeholder": "Digite o Nome",
|
||||
"add.prompt": "Prompt",
|
||||
"add.prompt.placeholder": "Digite o Prompt",
|
||||
"add.prompt.variables.tip": {
|
||||
"title": "Variáveis disponíveis",
|
||||
"content": "{{date}}:\tData\n{{time}}:\tHora\n{{datetime}}:\tData e hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitetura da CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNome do modelo"
|
||||
},
|
||||
"add.title": "Criar Agente Inteligente",
|
||||
"delete.popup.content": "Tem certeza de que deseja excluir este agente inteligente?",
|
||||
"edit.message.add.title": "Adicionar",
|
||||
"edit.message.assistant.placeholder": "Digite a Mensagem do Assistente",
|
||||
"edit.message.assistant.title": "Assistente",
|
||||
"edit.message.empty.content": "O conteúdo da sessão não pode estar vazio",
|
||||
"edit.message.group.title": "Grupo de Mensagens",
|
||||
"edit.message.title": "Mensagens Padrão",
|
||||
"edit.message.user.placeholder": "Digite a Mensagem do Usuário",
|
||||
"edit.message.user.title": "Usuário",
|
||||
"edit.model.select.title": "Selecionar Modelo",
|
||||
"edit.settings.hide_preset_messages": "Ocultar Mensagens Padrão",
|
||||
"edit.title": "Editar Agente Inteligente",
|
||||
"manage.title": "Gerenciar Agentes Inteligentes",
|
||||
"my_agents": "Meus Agentes Inteligentes",
|
||||
@@ -64,7 +59,6 @@
|
||||
"settings.default_model": "Modelo Padrão",
|
||||
"settings.knowledge_base": "Configurações da Base de Conhecimento",
|
||||
"settings.model": "Configurações do Modelo",
|
||||
"settings.preset_messages": "Mensagens Pré-definidas",
|
||||
"settings.prompt": "Configurações de Prompt",
|
||||
"settings.reasoning_effort": "Comprimento da Cadeia de Raciocínio",
|
||||
"settings.reasoning_effort.high": "Longo",
|
||||
@@ -205,6 +199,7 @@
|
||||
"topics.clear.title": "Limpar mensagens",
|
||||
"topics.copy.image": "Copiar como imagem",
|
||||
"topics.copy.md": "Copiar como Markdown",
|
||||
"topics.copy.plain_text": "Copiar como texto simples (remover Markdown)",
|
||||
"topics.copy.title": "Copiar",
|
||||
"topics.delete.shortcut": "Pressione {{key}} para deletar diretamente",
|
||||
"topics.edit.placeholder": "Digite novo nome",
|
||||
@@ -498,8 +493,12 @@
|
||||
"urls": "URLs",
|
||||
"dimensions": "Dimensão de incorporação",
|
||||
"dimensions_size_tooltip": "Tamanho da dimensão de incorporação, quanto maior o valor, maior a dimensão de incorporação, mas também maior o consumo de tokens",
|
||||
"dimensions_size_placeholder": "Valor padrão (não recomendado alterar)",
|
||||
"dimensions_size_too_large": "A dimensão de incorporação não pode exceder o limite do contexto do modelo ({{max_context}})"
|
||||
"dimensions_size_placeholder": " Tamanho da dimensão de incorporação, ex. 1024",
|
||||
"dimensions_auto_set": "Definição automática de dimensões de incorporação",
|
||||
"dimensions_error_invalid": "Por favor insira o tamanho da dimensão de incorporação",
|
||||
"dimensions_size_too_large": "A dimensão de incorporação não pode exceder o limite do contexto do modelo ({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ Certifique-se de que o modelo suporta o tamanho da dimensão de incorporação definido",
|
||||
"dimensions_default": "O modelo utilizará as dimensões de incorporação padrão"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Árabe",
|
||||
@@ -1307,6 +1306,7 @@
|
||||
"advancedSettings": "Configurações Avançadas"
|
||||
},
|
||||
"messages.divider": "Divisor de mensagens",
|
||||
"messages.divider.tooltip": "Não aplicável a mensagens de estilo bolha",
|
||||
"messages.grid_columns": "Número de colunas da grade de mensagens",
|
||||
"messages.grid_popover_trigger": "Disparador de detalhes da grade",
|
||||
"messages.grid_popover_trigger.click": "Clique para mostrar",
|
||||
@@ -1480,7 +1480,7 @@
|
||||
"zoom_out": "Diminuir interface",
|
||||
"zoom_reset": "Redefinir zoom"
|
||||
},
|
||||
"theme.auto": "Automático",
|
||||
"theme.system": "Sistema",
|
||||
"theme.dark": "Escuro",
|
||||
"theme.light": "Claro",
|
||||
"theme.title": "Tema",
|
||||
@@ -1659,6 +1659,13 @@
|
||||
"quit": "Sair",
|
||||
"show_window": "Exibir Janela",
|
||||
"visualization": "Visualização"
|
||||
},
|
||||
"update": {
|
||||
"title": "Atualização",
|
||||
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
||||
"later": "Mais tarde",
|
||||
"install": "Instalar",
|
||||
"noReleaseNotes": "Sem notas de versão"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useSystemAgents() {
|
||||
|
||||
// 如果没有远程配置或获取失败,加载本地代理
|
||||
if (resourcesPath && _agents.length === 0) {
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json')
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { Navbar, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { Input } from 'antd'
|
||||
import { Search } from 'lucide-react'
|
||||
import React, { FC, useState } from 'react'
|
||||
import { Button, Input } from 'antd'
|
||||
import { Search, SettingsIcon, X } from 'lucide-react'
|
||||
import React, { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import App from './App'
|
||||
import MiniAppSettings from './MiniappSettings/MiniAppSettings'
|
||||
import NewAppButton from './NewAppButton'
|
||||
|
||||
const AppsPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
const { minapps } = useMinapps()
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
const filteredApps = search
|
||||
? minapps.filter(
|
||||
@@ -31,31 +35,51 @@ const AppsPage: FC = () => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsSettingsOpen(false)
|
||||
}, [location.key])
|
||||
|
||||
return (
|
||||
<Container onContextMenu={handleContextMenu}>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}>
|
||||
<NavbarMain>
|
||||
{t('minapp.title')}
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
className="nodrag"
|
||||
style={{ width: '30%', height: 28, borderRadius: 15 }}
|
||||
style={{
|
||||
width: '30%',
|
||||
height: 28,
|
||||
borderRadius: 15,
|
||||
position: 'absolute',
|
||||
left: '50vw',
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
size="small"
|
||||
variant="filled"
|
||||
suffix={<Search size={18} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={isSettingsOpen}
|
||||
/>
|
||||
<div style={{ width: 80 }} />
|
||||
</NavbarCenter>
|
||||
<Button
|
||||
type="text"
|
||||
className="nodrag"
|
||||
icon={isSettingsOpen ? <X size={18} /> : <SettingsIcon size={18} color="var(--color-text-2)" />}
|
||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||
/>
|
||||
</NavbarMain>
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<AppsContainer style={{ height: containerHeight }}>
|
||||
{filteredApps.map((app) => (
|
||||
<App key={app.id} app={app} />
|
||||
))}
|
||||
<NewAppButton />
|
||||
</AppsContainer>
|
||||
{isSettingsOpen && <MiniAppSettings />}
|
||||
{!isSettingsOpen && (
|
||||
<AppsContainer style={{ height: containerHeight }}>
|
||||
{filteredApps.map((app) => (
|
||||
<App key={app.id} app={app} />
|
||||
))}
|
||||
<NewAppButton />
|
||||
</AppsContainer>
|
||||
)}
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UndoOutlined } from '@ant-design/icons' // 导入重置图标
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { SettingDescription, SettingDivider, SettingRowTitle, SettingTitle } from '@renderer/pages/settings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setMaxKeepAliveMinapps,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
import { Button, message, Slider, Switch, Tooltip } from 'antd'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDescription, SettingDivider, SettingGroup, SettingRowTitle, SettingTitle } from '..'
|
||||
import MiniAppIconsManager from './MiniAppIconsManager'
|
||||
|
||||
// 默认小程序缓存数量
|
||||
@@ -22,10 +22,10 @@ const DEFAULT_MAX_KEEPALIVE = 3
|
||||
|
||||
const MiniAppSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { maxKeepAliveMinapps, showOpenedMinappsInSidebar, minappsOpenLinkExternal } = useSettings()
|
||||
const { minapps, disabled, updateMinapps, updateDisabledMinapps } = useMinapps()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [visibleMiniApps, setVisibleMiniApps] = useState(minapps)
|
||||
const [disabledMiniApps, setDisabledMiniApps] = useState(disabled || [])
|
||||
@@ -72,83 +72,87 @@ const MiniAppSettings: FC = () => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<Container>
|
||||
{contextHolder} {/* 添加消息上下文 */}
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.miniapps.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
|
||||
<SettingTitle
|
||||
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{t('settings.miniapps.display_title')}</span>
|
||||
<ResetButtonWrapper>
|
||||
<Button onClick={handleResetMinApps}>{t('common.reset')}</Button>
|
||||
</ResetButtonWrapper>
|
||||
</SettingTitle>
|
||||
<BorderedContainer>
|
||||
<MiniAppIconsManager
|
||||
visibleMiniApps={visibleMiniApps}
|
||||
disabledMiniApps={disabledMiniApps}
|
||||
setVisibleMiniApps={setVisibleMiniApps}
|
||||
setDisabledMiniApps={setDisabledMiniApps}
|
||||
/>
|
||||
</BorderedContainer>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ height: 40, alignItems: 'center' }}>
|
||||
<SettingLabelGroup>
|
||||
<SettingRowTitle>{t('settings.miniapps.open_link_external.title')}</SettingRowTitle>
|
||||
</SettingLabelGroup>
|
||||
<Switch
|
||||
checked={minappsOpenLinkExternal}
|
||||
onChange={(checked) => dispatch(setMinappsOpenLinkExternal(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
|
||||
{/* 缓存小程序数量设置 */}
|
||||
<SettingRow>
|
||||
<SettingLabelGroup>
|
||||
<SettingRowTitle>{t('settings.miniapps.cache_title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('settings.miniapps.cache_description')}</SettingDescription>
|
||||
</SettingLabelGroup>
|
||||
<CacheSettingControls>
|
||||
<SliderWithResetContainer>
|
||||
<Tooltip title={t('settings.miniapps.reset_tooltip')} placement="top">
|
||||
<ResetButton onClick={handleResetCacheLimit}>
|
||||
<UndoOutlined />
|
||||
</ResetButton>
|
||||
</Tooltip>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxKeepAliveMinapps}
|
||||
onChange={handleCacheChange}
|
||||
marks={{
|
||||
1: '1',
|
||||
5: '5',
|
||||
10: 'Max'
|
||||
}}
|
||||
tooltip={{ formatter: (value) => `${value}` }}
|
||||
/>
|
||||
</SliderWithResetContainer>
|
||||
</CacheSettingControls>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingLabelGroup>
|
||||
<SettingRowTitle>{t('settings.miniapps.sidebar_title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('settings.miniapps.sidebar_description')}</SettingDescription>
|
||||
</SettingLabelGroup>
|
||||
<Switch
|
||||
checked={showOpenedMinappsInSidebar}
|
||||
onChange={(checked) => dispatch(setShowOpenedMinappsInSidebar(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
<SettingTitle
|
||||
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{t('settings.miniapps.display_title')}</span>
|
||||
<ResetButtonWrapper>
|
||||
<Button onClick={handleResetMinApps}>{t('common.reset')}</Button>
|
||||
</ResetButtonWrapper>
|
||||
</SettingTitle>
|
||||
<BorderedContainer>
|
||||
<MiniAppIconsManager
|
||||
visibleMiniApps={visibleMiniApps}
|
||||
disabledMiniApps={disabledMiniApps}
|
||||
setVisibleMiniApps={setVisibleMiniApps}
|
||||
setDisabledMiniApps={setDisabledMiniApps}
|
||||
/>
|
||||
</BorderedContainer>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ height: 40, alignItems: 'center' }}>
|
||||
<SettingLabelGroup>
|
||||
<SettingRowTitle>{t('settings.miniapps.open_link_external.title')}</SettingRowTitle>
|
||||
</SettingLabelGroup>
|
||||
<Switch
|
||||
checked={minappsOpenLinkExternal}
|
||||
onChange={(checked) => dispatch(setMinappsOpenLinkExternal(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
{/* 缓存小程序数量设置 */}
|
||||
<SettingRow>
|
||||
<SettingLabelGroup>
|
||||
<SettingRowTitle>{t('settings.miniapps.cache_title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('settings.miniapps.cache_description')}</SettingDescription>
|
||||
</SettingLabelGroup>
|
||||
<CacheSettingControls>
|
||||
<SliderWithResetContainer>
|
||||
<Tooltip title={t('settings.miniapps.reset_tooltip')} placement="top">
|
||||
<ResetButton onClick={handleResetCacheLimit}>
|
||||
<UndoOutlined />
|
||||
</ResetButton>
|
||||
</Tooltip>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxKeepAliveMinapps}
|
||||
onChange={handleCacheChange}
|
||||
marks={{
|
||||
1: '1',
|
||||
5: '5',
|
||||
10: 'Max'
|
||||
}}
|
||||
tooltip={{ formatter: (value) => `${value}` }}
|
||||
/>
|
||||
</SliderWithResetContainer>
|
||||
</CacheSettingControls>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingLabelGroup>
|
||||
<SettingRowTitle>{t('settings.miniapps.sidebar_title')}</SettingRowTitle>
|
||||
<SettingDescription>{t('settings.miniapps.sidebar_description')}</SettingDescription>
|
||||
</SettingLabelGroup>
|
||||
<Switch
|
||||
checked={showOpenedMinappsInSidebar}
|
||||
onChange={(checked) => dispatch(setShowOpenedMinappsInSidebar(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => navigate('/apps')}>{t('common.close')}</Button>
|
||||
</SettingRow>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
// 修改和新增样式
|
||||
const SettingRow = styled.div`
|
||||
display: flex;
|
||||
@@ -18,7 +18,7 @@ import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
|
||||
@@ -33,8 +33,10 @@ import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -408,7 +410,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const topic = getDefaultTopic(assistant.id)
|
||||
|
||||
await db.topics.add({ id: topic.id, messages: [] })
|
||||
await addAssistantMessagesToTopic({ assistant, topic })
|
||||
|
||||
// Clear previous state
|
||||
// Reset to assistant default model
|
||||
@@ -420,6 +421,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
|
||||
}, [addTopic, assistant, setActiveTopic, setModel])
|
||||
|
||||
const onQuote = useCallback(
|
||||
(text: string) => {
|
||||
const quotedText = formatQuotedText(text)
|
||||
setText((prevText) => {
|
||||
const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n`
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
return newText
|
||||
})
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
[resizeTextArea]
|
||||
)
|
||||
|
||||
const onPause = async () => {
|
||||
await pauseMessages()
|
||||
}
|
||||
@@ -624,21 +638,25 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
_setEstimateTokenCount(tokensCount)
|
||||
setContextCount({ current: contextCount.current, max: contextCount.max }) // 现在contextCount是一个对象而不是单个数值
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic),
|
||||
EventEmitter.on(EVENT_NAMES.QUOTE_TEXT, (quotedText: string) => {
|
||||
setText((prevText) => {
|
||||
const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n`
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
return newText
|
||||
})
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic)
|
||||
]
|
||||
return () => unsubscribes.forEach((unsub) => unsub())
|
||||
}, [addNewTopic, resizeTextArea])
|
||||
|
||||
// 监听引用事件
|
||||
const quoteFromAnywhereRemover = window.electron?.ipcRenderer.on(
|
||||
IpcChannel.App_QuoteToMain,
|
||||
(_, selectedText: string) => onQuote(selectedText)
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsub) => unsub())
|
||||
quoteFromAnywhereRemover?.()
|
||||
}
|
||||
}, [addNewTopic, onQuote])
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
if (!document.querySelector('.topview-fullscreen-container')) {
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}, [assistant, topic])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -723,50 +741,28 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}, [])
|
||||
|
||||
const onToggleExpended = () => {
|
||||
if (textareaHeight) {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
textArea.style.height = 'auto'
|
||||
setTextareaHeight(undefined)
|
||||
setTimeout(() => {
|
||||
textArea.style.height = `${textArea.scrollHeight}px`
|
||||
}, 200)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isExpended = !expended
|
||||
setExpend(isExpended)
|
||||
const currentlyExpanded = expended || !!textareaHeight
|
||||
const shouldExpand = !currentlyExpanded
|
||||
setExpend(shouldExpand)
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
|
||||
if (textArea) {
|
||||
if (isExpended) {
|
||||
textArea.style.height = '70vh'
|
||||
} else {
|
||||
resetHeight()
|
||||
}
|
||||
if (!textArea) return
|
||||
if (shouldExpand) {
|
||||
textArea.style.height = '70vh'
|
||||
setTextareaHeight(window.innerHeight * 0.7)
|
||||
} else {
|
||||
textArea.style.height = 'auto'
|
||||
setTextareaHeight(undefined)
|
||||
requestAnimationFrame(() => {
|
||||
if (textArea) {
|
||||
const contentHeight = textArea.scrollHeight
|
||||
textArea.style.height = contentHeight > 400 ? '400px' : `${contentHeight}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const resetHeight = () => {
|
||||
if (expended) {
|
||||
setExpend(false)
|
||||
}
|
||||
|
||||
setTextareaHeight(undefined)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
textArea.style.height = 'auto'
|
||||
const contentHeight = textArea.scrollHeight
|
||||
textArea.style.height = contentHeight > 400 ? '400px' : `${contentHeight}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isExpended = expended || !!textareaHeight
|
||||
const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)
|
||||
|
||||
@@ -949,11 +945,11 @@ const Textarea = styled(TextArea)`
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: height 0.2s ease;
|
||||
&.ant-input {
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -968,6 +964,9 @@ const Toolbar = styled.div`
|
||||
margin-bottom: 4px;
|
||||
height: 30px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const ToolbarMenu = styled.div`
|
||||
|
||||
@@ -74,29 +74,33 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
|
||||
}
|
||||
|
||||
providers.forEach((p) => {
|
||||
const providerModels = p.models
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
|
||||
.map((m) => ({
|
||||
label: (
|
||||
<>
|
||||
<ProviderName>{p.isSystem ? t(`provider.${p.id}`) : p.name}</ProviderName>
|
||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
||||
</>
|
||||
),
|
||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.id)} size={20}>
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
const providerModels = sortBy(
|
||||
p.models
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
.filter((m) => !pinnedModels.includes(getModelUniqId(m))),
|
||||
['group', 'name']
|
||||
)
|
||||
|
||||
if (providerModels.length > 0) {
|
||||
items.push(...sortBy(providerModels, ['label']))
|
||||
const providerModelItems = providerModels.map((m) => ({
|
||||
label: (
|
||||
<>
|
||||
<ProviderName>{p.isSystem ? t(`provider.${p.id}`) : p.name}</ProviderName>
|
||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
||||
</>
|
||||
),
|
||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.id)} size={20}>
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
|
||||
if (providerModelItems.length > 0) {
|
||||
items.push(...providerModelItems)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { Tooltip } from 'antd'
|
||||
import React from 'react'
|
||||
import React, { memo, useCallback, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CitationTooltipProps {
|
||||
@@ -13,56 +13,62 @@ interface CitationTooltipProps {
|
||||
}
|
||||
|
||||
const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation }) => {
|
||||
let hostname = ''
|
||||
try {
|
||||
hostname = new URL(citation.url).hostname
|
||||
} catch {
|
||||
hostname = citation.url
|
||||
}
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(citation.url).hostname
|
||||
} catch {
|
||||
return citation.url
|
||||
}
|
||||
}, [citation.url])
|
||||
|
||||
const sourceTitle = useMemo(() => {
|
||||
return citation.title?.trim() || hostname
|
||||
}, [citation.title, hostname])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
window.open(citation.url, '_blank', 'noopener,noreferrer')
|
||||
}, [citation.url])
|
||||
|
||||
// 自定义悬浮卡片内容
|
||||
const tooltipContent = (
|
||||
<TooltipContentWrapper>
|
||||
<TooltipHeader onClick={() => window.open(citation.url, '_blank')}>
|
||||
<Favicon hostname={hostname} alt={citation.title || hostname} />
|
||||
<TooltipTitle title={citation.title || hostname}>{citation.title || hostname}</TooltipTitle>
|
||||
</TooltipHeader>
|
||||
{citation.content && <TooltipBody>{citation.content}</TooltipBody>}
|
||||
<TooltipFooter onClick={() => window.open(citation.url, '_blank')}>{hostname}</TooltipFooter>
|
||||
</TooltipContentWrapper>
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<div>
|
||||
<TooltipHeader role="button" aria-label={`Open ${sourceTitle} in new tab`} onClick={handleClick}>
|
||||
<Favicon hostname={hostname} alt={sourceTitle} />
|
||||
<TooltipTitle role="heading" aria-level={3} title={sourceTitle}>
|
||||
{sourceTitle}
|
||||
</TooltipTitle>
|
||||
</TooltipHeader>
|
||||
{citation.content?.trim() && (
|
||||
<TooltipBody role="article" aria-label="Citation content">
|
||||
{citation.content}
|
||||
</TooltipBody>
|
||||
)}
|
||||
<TooltipFooter role="button" aria-label={`Visit ${hostname}`} onClick={handleClick}>
|
||||
{hostname}
|
||||
</TooltipFooter>
|
||||
</div>
|
||||
),
|
||||
[citation.content, hostname, handleClick, sourceTitle]
|
||||
)
|
||||
|
||||
return (
|
||||
<StyledTooltip
|
||||
title={tooltipContent}
|
||||
<Tooltip
|
||||
overlay={tooltipContent}
|
||||
placement="top"
|
||||
arrow={false}
|
||||
overlayInnerStyle={{
|
||||
backgroundColor: 'var(--color-background-mute)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: 0,
|
||||
borderRadius: '8px'
|
||||
color="var(--color-background-mute)"
|
||||
styles={{
|
||||
body: {
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</StyledTooltip>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// 使用styled-components来自定义Tooltip的样式,包括箭头
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
.ant-tooltip-arrow {
|
||||
.ant-tooltip-arrow-content {
|
||||
background-color: var(--color-background-1);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TooltipContentWrapper = styled.div`
|
||||
padding: 12px;
|
||||
background-color: var(--color-background-soft);
|
||||
border-radius: 8px;
|
||||
`
|
||||
|
||||
const TooltipHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -108,4 +114,4 @@ const TooltipFooter = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default CitationTooltip
|
||||
export default memo(CitationTooltip)
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css'
|
||||
import 'katex/dist/contrib/copy-tex'
|
||||
import 'katex/dist/contrib/mhchem'
|
||||
|
||||
import ImageViewer from '@renderer/components/ImageViewer'
|
||||
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
@@ -12,7 +13,7 @@ import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown
|
||||
import { isEmpty } from 'lodash'
|
||||
import { type FC, memo, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
// @ts-ignore rehype-mathjax is not typed
|
||||
import rehypeMathjax from 'rehype-mathjax'
|
||||
@@ -22,7 +23,6 @@ import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import Link from './Link'
|
||||
|
||||
const ALLOWED_ELEMENTS =
|
||||
@@ -83,19 +83,25 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
code: (props: any) => (
|
||||
<CodeBlock {...props} id={getCodeBlockId(props?.node?.position?.start)} onSave={onSaveCodeBlock} />
|
||||
),
|
||||
img: ImagePreview,
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />
|
||||
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
|
||||
p: (props) => {
|
||||
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
|
||||
if (hasImage) return <div {...props} />
|
||||
return <p {...props} />
|
||||
}
|
||||
} as Partial<Components>
|
||||
}, [onSaveCodeBlock])
|
||||
|
||||
// if (role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
// return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||
// }
|
||||
|
||||
if (messageContent.includes('<style>')) {
|
||||
components.style = MarkdownShadowDOMRenderer as any
|
||||
}
|
||||
|
||||
const urlTransform = useCallback((value: string) => {
|
||||
if (value.startsWith('data:image/png') || value.startsWith('data:image/jpeg')) return value
|
||||
return defaultUrlTransform(value)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
@@ -103,6 +109,7 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
className="markdown"
|
||||
components={components}
|
||||
disallowedElements={DISALLOWED_ELEMENTS}
|
||||
urlTransform={urlTransform}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CitationTooltip from '../CitationTooltip'
|
||||
|
||||
// Mock dependencies
|
||||
const mockWindowOpen = vi.fn()
|
||||
|
||||
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <div data-testid="mock-favicon" {...props} />
|
||||
}))
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tooltip: ({ children, overlay, title, placement, color, styles, ...props }: any) => (
|
||||
<div
|
||||
data-testid="tooltip-wrapper"
|
||||
data-placement={placement}
|
||||
data-color={color}
|
||||
data-styles={JSON.stringify(styles)}
|
||||
{...props}>
|
||||
{children}
|
||||
<div data-testid="tooltip-content">{overlay || title}</div>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
const originalWindowOpen = window.open
|
||||
|
||||
describe('CitationTooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: mockWindowOpen,
|
||||
writable: true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Test data factory
|
||||
const createCitationData = (overrides = {}) => ({
|
||||
url: 'https://example.com/article',
|
||||
title: 'Example Article',
|
||||
content: 'This is the article content for testing purposes.',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const renderCitationTooltip = (citation: any, children = <span>Trigger</span>) => {
|
||||
return render(<CitationTooltip citation={citation}>{children}</CitationTooltip>)
|
||||
}
|
||||
|
||||
const expectWindowOpenCalled = (url: string) => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const getTooltipContent = () => screen.getByTestId('tooltip-content')
|
||||
|
||||
const getCitationHeaderButton = () => screen.getByRole('button', { name: /open .* in new tab/i })
|
||||
const getCitationFooterButton = () => screen.getByRole('button', { name: /visit .*/i })
|
||||
const getCitationTitle = () => screen.getByRole('heading', { level: 3 })
|
||||
const getCitationContent = () => screen.queryByRole('article', { name: /citation content/i })
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render children and basic tooltip structure', () => {
|
||||
const citation = createCitationData()
|
||||
renderCitationTooltip(citation, <span>Click me</span>)
|
||||
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip-wrapper')).toBeInTheDocument()
|
||||
expect(getTooltipContent()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Favicon with correct props', () => {
|
||||
const citation = createCitationData({
|
||||
url: 'https://example.com',
|
||||
title: 'Example Title'
|
||||
})
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const favicon = screen.getByTestId('mock-favicon')
|
||||
expect(favicon).toHaveAttribute('hostname', 'example.com')
|
||||
expect(favicon).toHaveAttribute('alt', 'Example Title')
|
||||
})
|
||||
|
||||
it('should pass correct props to Tooltip component', () => {
|
||||
const citation = createCitationData()
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip-wrapper')
|
||||
expect(tooltip).toHaveAttribute('data-placement', 'top')
|
||||
expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)')
|
||||
|
||||
const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}')
|
||||
expect(styles.body).toEqual({
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px'
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const citation = createCitationData()
|
||||
const { container } = render(
|
||||
<CitationTooltip citation={citation}>
|
||||
<span>Test content</span>
|
||||
</CitationTooltip>
|
||||
)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL processing and hostname extraction', () => {
|
||||
it('should extract hostname from valid URLs', () => {
|
||||
const testCases = [
|
||||
{ url: 'https://www.example.com/path/to/page?query=1', expected: 'www.example.com' },
|
||||
{ url: 'http://test.com', expected: 'test.com' },
|
||||
{ url: 'https://api.v2.example.com/endpoint', expected: 'api.v2.example.com' },
|
||||
{ url: 'ftp://files.domain.net', expected: 'files.domain.net' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ url, expected }) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ url }))
|
||||
expect(screen.getByText(expected)).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle URLs with ports correctly', () => {
|
||||
const citation = createCitationData({ url: 'https://localhost:3000/api/data' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
// URL.hostname strips the port
|
||||
expect(screen.getByText('localhost')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to original URL when parsing fails', () => {
|
||||
const testCases = ['not-a-valid-url', '', 'http://']
|
||||
|
||||
testCases.forEach((invalidUrl) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ url: invalidUrl }))
|
||||
const favicon = screen.getByTestId('mock-favicon')
|
||||
expect(favicon).toHaveAttribute('hostname', invalidUrl)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('content display and title logic', () => {
|
||||
it('should display citation title when provided', () => {
|
||||
const citation = createCitationData({ title: 'Custom Article Title' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('Custom Article Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument() // hostname in footer
|
||||
})
|
||||
|
||||
it('should fallback to hostname when title is empty or whitespace', () => {
|
||||
const testCases = [
|
||||
{ title: undefined, url: 'https://fallback-test.com' },
|
||||
{ title: '', url: 'https://empty-title.com' },
|
||||
{ title: ' ', url: 'https://whitespace-title.com' },
|
||||
{ title: '\n\t \n', url: 'https://mixed-whitespace.com' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ title, url }) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ title, url }))
|
||||
const titleElement = getCitationTitle()
|
||||
const expectedHostname = new URL(url).hostname
|
||||
expect(titleElement).toHaveTextContent(expectedHostname)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display content when provided and meaningful', () => {
|
||||
const citation = createCitationData({ content: 'Meaningful article content' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('Meaningful article content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content section when content is empty or whitespace', () => {
|
||||
const testCases = [undefined, null, '', ' ', '\n\t \n']
|
||||
|
||||
testCases.forEach((content) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ content }))
|
||||
expect(getCitationContent()).not.toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle long content with proper styling', () => {
|
||||
const longContent =
|
||||
'This is a very long content that should be clamped to three lines using CSS line-clamp property for better visual presentation in the tooltip interface.'
|
||||
const citation = createCitationData({ content: longContent })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const contentElement = screen.getByText(longContent)
|
||||
expect(contentElement).toHaveStyle({
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in title and content', () => {
|
||||
const citation = createCitationData({
|
||||
title: 'Article with Special: <>{}[]()&"\'`',
|
||||
content: 'Content with chars: <>{}[]()&"\'`'
|
||||
})
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('Article with Special: <>{}[]()&"\'`')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content with chars: <>{}[]()&"\'`')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('should open URL when header is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ url: 'https://header-click.com' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const header = getCitationHeaderButton()
|
||||
await user.click(header)
|
||||
|
||||
expectWindowOpenCalled('https://header-click.com')
|
||||
})
|
||||
|
||||
it('should open URL when footer is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ url: 'https://footer-click.com' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const footer = getCitationFooterButton()
|
||||
await user.click(footer)
|
||||
|
||||
expectWindowOpenCalled('https://footer-click.com')
|
||||
})
|
||||
|
||||
it('should not trigger click when content area is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ content: 'Non-clickable content' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const content = screen.getByText('Non-clickable content')
|
||||
await user.click(content)
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle invalid URLs gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ url: 'invalid-url' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const footer = getCitationFooterButton()
|
||||
await user.click(footer)
|
||||
|
||||
expectWindowOpenCalled('invalid-url')
|
||||
})
|
||||
})
|
||||
|
||||
describe('real-world usage scenarios', () => {
|
||||
it('should work with actual citation link structure', () => {
|
||||
const citation = createCitationData({
|
||||
url: 'https://research.example.com/study',
|
||||
title: 'Research Study on AI',
|
||||
content:
|
||||
'This study demonstrates significant improvements in AI capabilities through novel training methodologies and evaluation frameworks.'
|
||||
})
|
||||
|
||||
const citationLink = (
|
||||
<a href="https://research.example.com/study" target="_blank" rel="noreferrer">
|
||||
<sup>1</sup>
|
||||
</a>
|
||||
)
|
||||
|
||||
renderCitationTooltip(citation, citationLink)
|
||||
|
||||
// Should display all citation information
|
||||
expect(screen.getByText('Research Study on AI')).toBeInTheDocument()
|
||||
expect(screen.getByText('research.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText(/This study demonstrates/)).toBeInTheDocument()
|
||||
|
||||
// Should contain the sup element
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle truncated content as used in real implementation', () => {
|
||||
const fullContent = 'A'.repeat(250) // Longer than typical 200 char limit
|
||||
const citation = createCitationData({ content: fullContent })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText(fullContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing title with hostname fallback in real scenario', () => {
|
||||
const citation = createCitationData({
|
||||
url: 'https://docs.python.org/3/library/urllib.html',
|
||||
title: undefined, // Common case when title extraction fails
|
||||
content: 'urllib.request module documentation for Python 3'
|
||||
})
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const titleElement = getCitationTitle()
|
||||
expect(titleElement).toHaveTextContent('docs.python.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle malformed URLs', () => {
|
||||
const malformedUrls = ['http://', 'https://', '://missing-protocol']
|
||||
|
||||
malformedUrls.forEach((url) => {
|
||||
expect(() => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ url }))
|
||||
unmount()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing children gracefully', () => {
|
||||
const citation = createCitationData()
|
||||
|
||||
expect(() => {
|
||||
render(<CitationTooltip citation={citation}>{null}</CitationTooltip>)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle extremely long URLs without breaking', () => {
|
||||
const longUrl = 'https://extremely-long-domain-name.example.com/' + 'a'.repeat(500)
|
||||
const citation = createCitationData({ url: longUrl })
|
||||
|
||||
expect(() => {
|
||||
renderCitationTooltip(citation)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance', () => {
|
||||
it('should memoize calculations correctly', () => {
|
||||
const citation = createCitationData({ url: 'https://memoize-test.com' })
|
||||
const { rerender } = renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('memoize-test.com')).toBeInTheDocument()
|
||||
|
||||
// Re-render with same props should work correctly
|
||||
rerender(
|
||||
<CitationTooltip citation={citation}>
|
||||
<span>Trigger</span>
|
||||
</CitationTooltip>
|
||||
)
|
||||
expect(screen.getByText('memoize-test.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when citation data changes', () => {
|
||||
const citation1 = createCitationData({ url: 'https://first.com' })
|
||||
const { rerender } = renderCitationTooltip(citation1)
|
||||
|
||||
expect(screen.getByText('first.com')).toBeInTheDocument()
|
||||
|
||||
const citation2 = createCitationData({ url: 'https://second.com' })
|
||||
rerender(
|
||||
<CitationTooltip citation={citation2}>
|
||||
<span>Trigger</span>
|
||||
</CitationTooltip>
|
||||
)
|
||||
|
||||
expect(screen.getByText('second.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText('first.com')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
368
src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx
Normal file
368
src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Markdown from '../Markdown'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseTranslation = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation()
|
||||
}))
|
||||
|
||||
// Mock services
|
||||
vi.mock('@renderer/services/EventService', () => ({
|
||||
EVENT_NAMES: {
|
||||
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK'
|
||||
},
|
||||
EventEmitter: {
|
||||
emit: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock utilities
|
||||
vi.mock('@renderer/utils', () => ({
|
||||
parseJSON: vi.fn((str) => {
|
||||
try {
|
||||
return JSON.parse(str || '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/formats', () => ({
|
||||
escapeBrackets: vi.fn((str) => str),
|
||||
removeSvgEmptyLines: vi.fn((str) => str)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/markdown', () => ({
|
||||
findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'),
|
||||
getCodeBlockId: vi.fn(() => 'code-block-1')
|
||||
}))
|
||||
|
||||
// Mock components with more realistic behavior
|
||||
vi.mock('../CodeBlock', () => ({
|
||||
__esModule: true,
|
||||
default: ({ id, onSave, children }: any) => (
|
||||
<div data-testid="code-block" data-id={id}>
|
||||
<code>{children}</code>
|
||||
<button type="button" onClick={() => onSave(id, 'new content')}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../ImagePreview', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img data-testid="image-preview" {...props} />
|
||||
}))
|
||||
|
||||
vi.mock('../Link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ citationData, children, ...props }: any) => (
|
||||
<a data-testid="citation-link" data-citation={citationData} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any) => <div data-testid="shadow-dom">{children}</div>
|
||||
}))
|
||||
|
||||
// Mock plugins
|
||||
vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() }))
|
||||
|
||||
// Mock ReactMarkdown with realistic rendering
|
||||
vi.mock('react-markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, components, className }: any) => (
|
||||
<div data-testid="markdown-content" className={className}>
|
||||
{children}
|
||||
{/* Simulate component rendering */}
|
||||
{components?.a && <span data-testid="has-link-component">link</span>}
|
||||
{components?.code && (
|
||||
<div data-testid="has-code-component">
|
||||
{components.code({ children: 'test code', node: { position: { start: { line: 1 } } } })}
|
||||
</div>
|
||||
)}
|
||||
{components?.img && <span data-testid="has-img-component">img</span>}
|
||||
{components?.style && <span data-testid="has-style-component">style</span>}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('Markdown', () => {
|
||||
let mockEventEmitter: any
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default settings
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key)
|
||||
})
|
||||
|
||||
// Get mocked EventEmitter
|
||||
const { EventEmitter } = await import('@renderer/services/EventService')
|
||||
mockEventEmitter = EventEmitter
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Test data helpers
|
||||
const createMainTextBlock = (overrides: Partial<MainTextMessageBlock> = {}): MainTextMessageBlock => ({
|
||||
id: 'test-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.MAIN_TEXT,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: '# Test Markdown\n\nThis is **bold** text.',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render markdown content with correct structure', () => {
|
||||
const block = createMainTextBlock({ content: 'Test content' })
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
expect(markdown).toHaveClass('markdown')
|
||||
expect(markdown).toHaveTextContent('Test content')
|
||||
})
|
||||
|
||||
it('should handle empty content gracefully', () => {
|
||||
const block = createMainTextBlock({ content: '' })
|
||||
|
||||
expect(() => render(<Markdown block={block} />)).not.toThrow()
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show paused message when content is empty and status is paused', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: '',
|
||||
status: MessageBlockStatus.PAUSED
|
||||
})
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toHaveTextContent('Paused')
|
||||
})
|
||||
|
||||
it('should prioritize actual content over paused status', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Real content',
|
||||
status: MessageBlockStatus.PAUSED
|
||||
})
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toHaveTextContent('Real content')
|
||||
expect(markdown).not.toHaveTextContent('Paused')
|
||||
})
|
||||
|
||||
it('should process content through format utilities', async () => {
|
||||
const { escapeBrackets, removeSvgEmptyLines } = await import('@renderer/utils/formats')
|
||||
const content = 'Content with [brackets] and SVG'
|
||||
|
||||
render(<Markdown block={createMainTextBlock({ content })} />)
|
||||
|
||||
expect(escapeBrackets).toHaveBeenCalledWith(content)
|
||||
expect(removeSvgEmptyLines).toHaveBeenCalledWith(content)
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const { container } = render(<Markdown block={createMainTextBlock()} />)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('block type support', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'MainTextMessageBlock',
|
||||
block: createMainTextBlock({ content: 'Main text content' }),
|
||||
expectedContent: 'Main text content'
|
||||
},
|
||||
{
|
||||
name: 'ThinkingMessageBlock',
|
||||
block: {
|
||||
id: 'thinking-1',
|
||||
messageId: 'msg-1',
|
||||
type: MessageBlockType.THINKING,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'Thinking content',
|
||||
thinking_millsec: 5000
|
||||
} as ThinkingMessageBlock,
|
||||
expectedContent: 'Thinking content'
|
||||
},
|
||||
{
|
||||
name: 'TranslationMessageBlock',
|
||||
block: {
|
||||
id: 'translation-1',
|
||||
messageId: 'msg-1',
|
||||
type: MessageBlockType.TRANSLATION,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'Translated content',
|
||||
targetLanguage: 'en'
|
||||
} as TranslationMessageBlock,
|
||||
expectedContent: 'Translated content'
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ name, block, expectedContent }) => {
|
||||
it(`should handle ${name} correctly`, () => {
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
expect(markdown).toHaveTextContent(expectedContent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('math engine configuration', () => {
|
||||
it('should configure KaTeX when mathEngine is KaTeX', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Component should render successfully with KaTeX configuration
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should configure MathJax when mathEngine is MathJax', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Component should render successfully with MathJax configuration
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not load math plugins when mathEngine is none', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'none' })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Component should render successfully without math plugins
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom components', () => {
|
||||
it('should integrate Link component for citations', () => {
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('has-link-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should integrate CodeBlock component with edit functionality', () => {
|
||||
const block = createMainTextBlock({ id: 'test-block-123' })
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
expect(screen.getByTestId('has-code-component')).toBeInTheDocument()
|
||||
|
||||
// Test code block edit event
|
||||
const saveButton = screen.getByText('Save')
|
||||
saveButton.click()
|
||||
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith('EDIT_CODE_BLOCK', {
|
||||
msgBlockId: 'test-block-123',
|
||||
codeBlockId: 'code-block-1',
|
||||
newContent: 'new content'
|
||||
})
|
||||
})
|
||||
|
||||
it('should integrate ImagePreview component', () => {
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('has-img-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle style tags with Shadow DOM', () => {
|
||||
const block = createMainTextBlock({ content: '<style>body { color: red; }</style>' })
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
expect(screen.getByTestId('has-style-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML content support', () => {
|
||||
it('should handle mixed markdown and HTML content', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: '# Header\n<div>HTML content</div>\n**Bold text**'
|
||||
})
|
||||
|
||||
expect(() => render(<Markdown block={block} />)).not.toThrow()
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
expect(markdown).toHaveTextContent('# Header')
|
||||
expect(markdown).toHaveTextContent('HTML content')
|
||||
expect(markdown).toHaveTextContent('**Bold text**')
|
||||
})
|
||||
|
||||
it('should handle malformed content gracefully', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: '<unclosed-tag>content\n# Invalid markdown **unclosed'
|
||||
})
|
||||
|
||||
expect(() => render(<Markdown block={block} />)).not.toThrow()
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('component behavior', () => {
|
||||
it('should re-render when content changes', () => {
|
||||
const { rerender } = render(<Markdown block={createMainTextBlock({ content: 'Initial' })} />)
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Initial')
|
||||
|
||||
rerender(<Markdown block={createMainTextBlock({ content: 'Updated' })} />)
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Updated')
|
||||
})
|
||||
|
||||
it('should re-render when math engine changes', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
const { rerender } = render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
|
||||
rerender(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Should still render correctly with new math engine
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c0:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
color: var(--color-text-1);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.c3 {
|
||||
font-size: 12px;
|
||||
color: var(--color-link);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c3:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
<div
|
||||
data-color="var(--color-background-mute)"
|
||||
data-placement="top"
|
||||
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
|
||||
data-testid="tooltip-wrapper"
|
||||
>
|
||||
<span>
|
||||
Test content
|
||||
</span>
|
||||
<div
|
||||
data-testid="tooltip-content"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Open Example Article in new tab"
|
||||
class="c0"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
alt="Example Article"
|
||||
data-testid="mock-favicon"
|
||||
hostname="example.com"
|
||||
/>
|
||||
<div
|
||||
aria-level="3"
|
||||
class="c1"
|
||||
role="heading"
|
||||
title="Example Article"
|
||||
>
|
||||
Example Article
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Citation content"
|
||||
class="c2"
|
||||
role="article"
|
||||
>
|
||||
This is the article content for testing purposes.
|
||||
</div>
|
||||
<div
|
||||
aria-label="Visit example.com"
|
||||
class="c3"
|
||||
role="button"
|
||||
>
|
||||
example.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,39 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Markdown > rendering > should match snapshot 1`] = `
|
||||
<div
|
||||
class="markdown"
|
||||
data-testid="markdown-content"
|
||||
>
|
||||
# Test Markdown
|
||||
|
||||
This is **bold** text.
|
||||
<span
|
||||
data-testid="has-link-component"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<div
|
||||
data-testid="has-code-component"
|
||||
>
|
||||
<div
|
||||
data-id="code-block-1"
|
||||
data-testid="code-block"
|
||||
>
|
||||
<code>
|
||||
test code
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-testid="has-img-component"
|
||||
>
|
||||
img
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -53,6 +53,16 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
||||
|
||||
const SearchEntryPoint = styled.div`
|
||||
margin: 10px 2px;
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
.carousel {
|
||||
white-space: normal;
|
||||
.chip {
|
||||
margin: 0;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default React.memo(CitationBlock)
|
||||
|
||||
@@ -31,7 +31,7 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock }> = ({ block }) =>
|
||||
}
|
||||
|
||||
const Alert = styled(AntdAlert)`
|
||||
margin: 15px 0 8px;
|
||||
margin: 0.5rem 0;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import ImageViewer from '@renderer/components/ImageViewer'
|
||||
import type { ImageMessageBlock } from '@renderer/types/newMessage'
|
||||
import React from 'react'
|
||||
|
||||
import MessageImage from '../MessageImage'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
block: ImageMessageBlock
|
||||
}
|
||||
|
||||
const ImageBlock: React.FC<Props> = ({ block }) => {
|
||||
return block.status === 'success' ? <MessageImage block={block} /> : <SvgSpinners180Ring />
|
||||
if (block.status !== 'success') return <SvgSpinners180Ring />
|
||||
const images = block.metadata?.generateImageResponse?.images?.length
|
||||
? block.metadata?.generateImageResponse?.images
|
||||
: block?.file?.path
|
||||
? [`file://${block?.file?.path}`]
|
||||
: []
|
||||
return (
|
||||
<Container style={{ marginBottom: 8 }}>
|
||||
{images.map((src, index) => (
|
||||
<ImageViewer
|
||||
src={src}
|
||||
key={`image-${index}`}
|
||||
style={{ maxWidth: 500, maxHeight: 500, padding: 5, borderRadius: 8 }}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
export default React.memo(ImageBlock)
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
|
||||
import { type Model, WebSearchSource } from '@renderer/types'
|
||||
import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage'
|
||||
import { cleanMarkdownContent } from '@renderer/utils/formats'
|
||||
import { cleanMarkdownContent, encodeHTML } from '@renderer/utils/formats'
|
||||
import { Flex } from 'antd'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@@ -13,18 +13,6 @@ import styled from 'styled-components'
|
||||
|
||||
import Markdown from '../../Markdown/Markdown'
|
||||
|
||||
// HTML实体编码辅助函数
|
||||
const encodeHTML = (str: string): string => {
|
||||
const entities: { [key: string]: string } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return str.replace(/[&<>"']/g, (match) => entities[match])
|
||||
}
|
||||
|
||||
interface Props {
|
||||
block: MainTextMessageBlock
|
||||
citationBlockId?: string
|
||||
@@ -163,7 +151,7 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
|
||||
</Flex>
|
||||
)}
|
||||
{role === 'user' && !renderInputMessageAsMarkdown ? (
|
||||
<p className="markdown" style={{ marginBottom: 5, whiteSpace: 'pre-line' }}>
|
||||
<p className="markdown" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{block.content}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import type { Model } from '@renderer/types'
|
||||
import { WebSearchSource } from '@renderer/types'
|
||||
import type { MainTextMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MainTextBlock from '../MainTextBlock'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseSelector = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', async () => {
|
||||
const actual = await import('react-redux')
|
||||
return {
|
||||
...actual,
|
||||
useSelector: () => mockUseSelector(),
|
||||
useDispatch: () => vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock store to avoid withTypes issues
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useAppSelector: vi.fn(),
|
||||
useAppDispatch: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
// Mock store selectors
|
||||
vi.mock('@renderer/store/messageBlock', async () => {
|
||||
const actual = await import('@renderer/store/messageBlock')
|
||||
return {
|
||||
...actual,
|
||||
selectFormattedCitationsByBlockId: vi.fn(() => [])
|
||||
}
|
||||
})
|
||||
|
||||
// Mock utilities
|
||||
vi.mock('@renderer/utils/formats', () => ({
|
||||
cleanMarkdownContent: vi.fn((content: string) => content),
|
||||
encodeHTML: vi.fn((content: string) => content.replace(/"/g, '"'))
|
||||
}))
|
||||
|
||||
// Mock services
|
||||
vi.mock('@renderer/services/ModelService', () => ({
|
||||
getModelUniqId: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock Markdown component
|
||||
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ block }: any) => (
|
||||
<div data-testid="mock-markdown" data-content={block.content}>
|
||||
Markdown: {block.content}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('MainTextBlock', () => {
|
||||
// Get references to mocked modules
|
||||
let mockGetModelUniqId: any
|
||||
let mockCleanMarkdownContent: any
|
||||
|
||||
// Create a mock store for Provider
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
messageBlocks: (state = {}) => state
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Get the mocked functions
|
||||
const { getModelUniqId } = await import('@renderer/services/ModelService')
|
||||
const { cleanMarkdownContent } = await import('@renderer/utils/formats')
|
||||
mockGetModelUniqId = getModelUniqId as any
|
||||
mockCleanMarkdownContent = cleanMarkdownContent as any
|
||||
|
||||
// Default mock implementations
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
mockUseSelector.mockReturnValue([]) // Empty citations by default
|
||||
mockGetModelUniqId.mockImplementation((model: Model) => `${model.id}-${model.name}`)
|
||||
})
|
||||
|
||||
// Test data factory functions
|
||||
const createMainTextBlock = (overrides: Partial<MainTextMessageBlock> = {}): MainTextMessageBlock => ({
|
||||
id: 'test-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.MAIN_TEXT,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'Test content',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model =>
|
||||
({
|
||||
id: 'test-model-1',
|
||||
name: 'Test Model',
|
||||
provider: 'test-provider',
|
||||
...overrides
|
||||
}) as Model
|
||||
|
||||
// Helper functions
|
||||
const renderMainTextBlock = (props: {
|
||||
block: MainTextMessageBlock
|
||||
role: 'user' | 'assistant'
|
||||
mentions?: Model[]
|
||||
citationBlockId?: string
|
||||
}) => {
|
||||
return render(
|
||||
<Provider store={mockStore}>
|
||||
<MainTextBlock {...props} />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// User-focused query helpers
|
||||
const getRenderedMarkdown = () => screen.queryByTestId('mock-markdown')
|
||||
const getRenderedPlainText = () => screen.queryByRole('paragraph')
|
||||
const getMentionElements = () => screen.queryAllByText(/@/)
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render in markdown mode for assistant messages', () => {
|
||||
const block = createMainTextBlock({ content: 'Assistant response' })
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
// User should see markdown-rendered content
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown: Assistant response')).toBeInTheDocument()
|
||||
expect(getRenderedPlainText()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in plain text mode for user messages when setting disabled', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
const block = createMainTextBlock({ content: 'User message\nWith line breaks' })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
// User should see plain text with preserved formatting
|
||||
expect(getRenderedPlainText()).toBeInTheDocument()
|
||||
expect(getRenderedPlainText()!.textContent).toBe('User message\nWith line breaks')
|
||||
expect(getRenderedMarkdown()).not.toBeInTheDocument()
|
||||
|
||||
// Check preserved whitespace
|
||||
const textElement = getRenderedPlainText()!
|
||||
expect(textElement).toHaveStyle({ whiteSpace: 'pre-wrap' })
|
||||
})
|
||||
|
||||
it('should render user messages as markdown when setting enabled', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
|
||||
const block = createMainTextBlock({ content: 'User **bold** content' })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown: User **bold** content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve complex formatting in plain text mode', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
const complexContent = `Line 1
|
||||
Indented line
|
||||
**Bold not parsed**
|
||||
- List not parsed`
|
||||
|
||||
const block = createMainTextBlock({ content: complexContent })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
const textElement = getRenderedPlainText()!
|
||||
expect(textElement.textContent).toBe(complexContent)
|
||||
expect(textElement).toHaveClass('markdown')
|
||||
})
|
||||
|
||||
it('should handle empty content gracefully', () => {
|
||||
const block = createMainTextBlock({ content: '' })
|
||||
expect(() => {
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mentions functionality', () => {
|
||||
it('should display model mentions when provided', () => {
|
||||
const block = createMainTextBlock({ content: 'Content with mentions' })
|
||||
const mentions = [
|
||||
createModel({ id: 'model-1', name: 'deepseek-r1' }),
|
||||
createModel({ id: 'model-2', name: 'claude-sonnet-4' })
|
||||
]
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions })
|
||||
|
||||
// User should see mention tags
|
||||
expect(screen.getByText('@deepseek-r1')).toBeInTheDocument()
|
||||
expect(screen.getByText('@claude-sonnet-4')).toBeInTheDocument()
|
||||
|
||||
// Service should be called for model processing
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledTimes(2)
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[0])
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[1])
|
||||
})
|
||||
|
||||
it('should not display mentions when none provided', () => {
|
||||
const block = createMainTextBlock({ content: 'No mentions content' })
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions: [] })
|
||||
expect(getMentionElements()).toHaveLength(0)
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions: undefined })
|
||||
expect(getMentionElements()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should style mentions correctly for user visibility', () => {
|
||||
const block = createMainTextBlock({ content: 'Styled mentions test' })
|
||||
const mentions = [createModel({ id: 'model-1', name: 'Test Model' })]
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions })
|
||||
|
||||
const mentionElement = screen.getByText('@Test Model')
|
||||
expect(mentionElement).toHaveStyle({ color: 'var(--color-link)' })
|
||||
|
||||
// Check container layout
|
||||
const container = mentionElement.closest('[style*="gap"]')
|
||||
expect(container).toHaveStyle({
|
||||
gap: '8px',
|
||||
marginBottom: '10px'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('content processing', () => {
|
||||
it('should filter tool_use tags from content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'single tool_use tag',
|
||||
content: 'Before <tool_use>tool content</tool_use> after',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiple tool_use tags',
|
||||
content: 'Start <tool_use>tool1</tool_use> middle <tool_use>tool2</tool_use> end',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiline tool_use',
|
||||
content: `Text before
|
||||
<tool_use>
|
||||
multiline
|
||||
tool content
|
||||
</tool_use>
|
||||
text after`,
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'malformed tool_use',
|
||||
content: 'Before <tool_use>unclosed tag',
|
||||
expectsFiltering: false // Should preserve malformed tags
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ content, expectsFiltering }) => {
|
||||
const block = createMainTextBlock({ content })
|
||||
const { unmount } = renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
const renderedContent = getRenderedMarkdown()
|
||||
expect(renderedContent).toBeInTheDocument()
|
||||
|
||||
if (expectsFiltering) {
|
||||
// Check that tool_use content is not visible to user
|
||||
expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should process content through format utilities', () => {
|
||||
const block = createMainTextBlock({ content: 'Content to process' })
|
||||
mockUseSelector.mockReturnValue([{ id: '1', content: 'Citation content', number: 1 }])
|
||||
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'test-citations'
|
||||
})
|
||||
|
||||
// Verify utility functions are called
|
||||
expect(mockCleanMarkdownContent).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('citation integration', () => {
|
||||
it('should display content normally when no citations are present', () => {
|
||||
const block = createMainTextBlock({ content: 'Content without citations' })
|
||||
mockUseSelector.mockReturnValue([])
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
expect(screen.getByText('Markdown: Content without citations')).toBeInTheDocument()
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with citation system when citations exist', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Content with citation [1]',
|
||||
citationReferences: [{ citationBlockSource: WebSearchSource.OPENAI }]
|
||||
})
|
||||
|
||||
const mockCitations = [
|
||||
{
|
||||
id: '1',
|
||||
number: 1,
|
||||
url: 'https://example.com',
|
||||
title: 'Example Citation',
|
||||
content: 'Citation content'
|
||||
}
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(mockCitations)
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'citation-test'
|
||||
})
|
||||
|
||||
// Verify citation integration works
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
|
||||
// Verify content processing occurred
|
||||
expect(mockCleanMarkdownContent).toHaveBeenCalledWith('Citation content')
|
||||
})
|
||||
|
||||
it('should handle different citation sources correctly', () => {
|
||||
const testSources = [WebSearchSource.OPENAI, 'DEFAULT' as any, 'CUSTOM' as any]
|
||||
|
||||
testSources.forEach((source) => {
|
||||
const block = createMainTextBlock({
|
||||
content: `Citation test for ${source}`,
|
||||
citationReferences: [{ citationBlockSource: source }]
|
||||
})
|
||||
|
||||
mockUseSelector.mockReturnValue([{ id: '1', number: 1, url: 'https://test.com', title: 'Test' }])
|
||||
|
||||
const { unmount } = renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: `test-${source}`
|
||||
})
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple citations gracefully', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Multiple citations [1] and [2]',
|
||||
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
|
||||
})
|
||||
|
||||
const multipleCitations = [
|
||||
{ id: '1', number: 1, url: 'https://first.com', title: 'First' },
|
||||
{ id: '2', number: 2, url: 'https://second.com', title: 'Second' }
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(multipleCitations)
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'multi-test' })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings integration', () => {
|
||||
it('should respond to markdown rendering setting changes', () => {
|
||||
const block = createMainTextBlock({ content: 'Settings test content' })
|
||||
|
||||
// Test with markdown enabled
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
|
||||
const { unmount } = renderMainTextBlock({ block, role: 'user' })
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test with markdown disabled
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
expect(getRenderedPlainText()).toBeInTheDocument()
|
||||
expect(getRenderedMarkdown()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and robustness', () => {
|
||||
it('should handle large content without performance issues', () => {
|
||||
const largeContent = 'A'.repeat(1000) + ' with citations [1]'
|
||||
const block = createMainTextBlock({ content: largeContent })
|
||||
|
||||
const largeCitations = [
|
||||
{
|
||||
id: '1',
|
||||
number: 1,
|
||||
url: 'https://large.com',
|
||||
title: 'Large',
|
||||
content: 'B'.repeat(500)
|
||||
}
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(largeCitations)
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'large-test'
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters and Unicode gracefully', () => {
|
||||
const specialContent = '测试内容 🚀 📝 ✨ <>&"\'` [1]'
|
||||
const block = createMainTextBlock({ content: specialContent })
|
||||
|
||||
mockUseSelector.mockReturnValue([{ id: '1', number: 1, title: '特殊字符测试', content: '内容 with 🎉' }])
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'unicode-test'
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null and undefined values gracefully', () => {
|
||||
const block = createMainTextBlock({ content: 'Null safety test' })
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
mentions: undefined,
|
||||
citationBlockId: undefined
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should integrate properly with Redux store', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Redux integration test',
|
||||
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
|
||||
})
|
||||
|
||||
mockUseSelector.mockReturnValue([])
|
||||
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'redux-test' })
|
||||
|
||||
// Verify Redux integration
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ThinkingBlock from '../ThinkingBlock'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseTranslation = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation()
|
||||
}))
|
||||
|
||||
// Mock antd components
|
||||
vi.mock('antd', () => ({
|
||||
Collapse: ({ activeKey, onChange, items, className, size, expandIconPosition }: any) => (
|
||||
<div
|
||||
data-testid="collapse-container"
|
||||
className={className}
|
||||
data-active-key={activeKey}
|
||||
data-size={size}
|
||||
data-expand-icon-position={expandIconPosition}>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
|
||||
<div data-testid={`collapse-header-${item.key}`} onClick={() => onChange()}>
|
||||
{item.label}
|
||||
</div>
|
||||
{activeKey === item.key && <div data-testid={`collapse-content-${item.key}`}>{item.children}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ title, children, mouseEnterDelay }: any) => (
|
||||
<div data-testid="tooltip" title={title} data-mouse-enter-delay={mouseEnterDelay}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
CheckOutlined: ({ style }: any) => (
|
||||
<span data-testid="check-icon" style={style}>
|
||||
✓
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Lightbulb: ({ size }: any) => (
|
||||
<span data-testid="lightbulb-icon" data-size={size}>
|
||||
💡
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
|
||||
// Mock motion
|
||||
vi.mock('motion/react', () => ({
|
||||
motion: {
|
||||
span: ({ children, variants, animate, initial, style }: any) => (
|
||||
<span
|
||||
data-testid="motion-span"
|
||||
data-variants={JSON.stringify(variants)}
|
||||
data-animate={animate}
|
||||
data-initial={initial}
|
||||
style={style}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock motion variants
|
||||
vi.mock('@renderer/utils/motionVariants', () => ({
|
||||
lightbulbVariants: {
|
||||
active: { rotate: 10, scale: 1.1 },
|
||||
idle: { rotate: 0, scale: 1 }
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Markdown component
|
||||
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ block }: any) => (
|
||||
<div data-testid="mock-markdown" data-block-id={block.id}>
|
||||
Markdown: {block.content}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('ThinkingBlock', () => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Default mock implementations
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === 'chat.thinking' && params?.seconds) {
|
||||
return `Thinking... ${params.seconds}s`
|
||||
}
|
||||
if (key === 'chat.deeply_thought' && params?.seconds) {
|
||||
return `Thought for ${params.seconds}s`
|
||||
}
|
||||
if (key === 'message.copied') return 'Copied!'
|
||||
if (key === 'message.copy.failed') return 'Copy failed'
|
||||
if (key === 'common.copy') return 'Copy'
|
||||
return key
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Test data factory functions
|
||||
const createThinkingBlock = (overrides: Partial<ThinkingMessageBlock> = {}): ThinkingMessageBlock => ({
|
||||
id: 'test-thinking-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.THINKING,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'I need to think about this carefully...',
|
||||
thinking_millsec: 5000,
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const renderThinkingBlock = (block: ThinkingMessageBlock) => {
|
||||
return render(<ThinkingBlock block={block} />)
|
||||
}
|
||||
|
||||
const getThinkingContent = () => screen.queryByText(/markdown:/i)
|
||||
const getCopyButton = () => screen.queryByRole('button', { name: /copy/i })
|
||||
const getThinkingTimeText = () => screen.getByText(/thinking|thought/i)
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render thinking content when provided', () => {
|
||||
const block = createThinkingBlock({ content: 'Deep thoughts about AI' })
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// User should see the thinking content
|
||||
expect(screen.getByText('Markdown: Deep thoughts about AI')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('lightbulb-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when content is empty', () => {
|
||||
const testCases = ['', undefined]
|
||||
|
||||
testCases.forEach((content) => {
|
||||
const block = createThinkingBlock({ content: content as any })
|
||||
const { container, unmount } = renderThinkingBlock(block)
|
||||
expect(container.firstChild).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show copy button only when thinking is complete', () => {
|
||||
// When thinking (streaming)
|
||||
const thinkingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(thinkingBlock)
|
||||
|
||||
expect(getCopyButton()).not.toBeInTheDocument()
|
||||
|
||||
// When thinking is complete
|
||||
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
expect(getCopyButton()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const block = createThinkingBlock()
|
||||
const { container } = renderThinkingBlock(block)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinking time display', () => {
|
||||
it('should display appropriate time messages based on status', () => {
|
||||
// Completed thinking
|
||||
const completedBlock = createThinkingBlock({
|
||||
thinking_millsec: 3500,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const { unmount } = renderThinkingBlock(completedBlock)
|
||||
|
||||
const timeText = getThinkingTimeText()
|
||||
expect(timeText).toHaveTextContent('3.5s')
|
||||
expect(timeText).toHaveTextContent('Thought for')
|
||||
unmount()
|
||||
|
||||
// Active thinking
|
||||
const thinkingBlock = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
renderThinkingBlock(thinkingBlock)
|
||||
|
||||
const activeTimeText = getThinkingTimeText()
|
||||
expect(activeTimeText).toHaveTextContent('1.0s')
|
||||
expect(activeTimeText).toHaveTextContent('Thinking...')
|
||||
})
|
||||
|
||||
it('should update thinking time in real-time when active', () => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// Initial state
|
||||
expect(getThinkingTimeText()).toHaveTextContent('1.0s')
|
||||
|
||||
// After time passes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(getThinkingTimeText()).toHaveTextContent('1.5s')
|
||||
})
|
||||
|
||||
it('should handle extreme thinking times correctly', () => {
|
||||
const testCases = [
|
||||
{ thinking_millsec: 0, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: undefined, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day
|
||||
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
|
||||
]
|
||||
|
||||
testCases.forEach(({ thinking_millsec, expectedTime }) => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
expect(getThinkingTimeText()).toHaveTextContent(expectedTime)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop timer when thinking status changes to completed', () => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
const { rerender } = renderThinkingBlock(block)
|
||||
|
||||
// Advance timer while thinking
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(getThinkingTimeText()).toHaveTextContent('2.0s')
|
||||
|
||||
// Complete thinking
|
||||
const completedBlock = createThinkingBlock({
|
||||
thinking_millsec: 1000, // Original time doesn't matter
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
// Timer should stop - text should change from "Thinking..." to "Thought for"
|
||||
const timeText = getThinkingTimeText()
|
||||
expect(timeText).toHaveTextContent('Thought for')
|
||||
expect(timeText).toHaveTextContent('2.0s')
|
||||
|
||||
// Further time advancement shouldn't change the display
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(timeText).toHaveTextContent('2.0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapse behavior', () => {
|
||||
it('should respect auto-collapse setting for initial state', () => {
|
||||
// Test expanded by default (auto-collapse disabled)
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
const block = createThinkingBlock()
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
// Content should be visible when expanded
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test collapsed by default (auto-collapse enabled)
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: true
|
||||
})
|
||||
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// Content should not be visible when collapsed
|
||||
expect(getThinkingContent()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should auto-collapse when thinking completes if setting enabled', () => {
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: true
|
||||
})
|
||||
|
||||
const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(streamingBlock)
|
||||
|
||||
// Should be expanded while thinking
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
|
||||
// Stop thinking
|
||||
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
// Should be collapsed after thinking completes
|
||||
expect(getThinkingContent()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('font and styling', () => {
|
||||
it('should apply font settings to thinking content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
settings: { messageFont: 'serif', fontSize: 16 },
|
||||
expectedFont: 'var(--font-family-serif)',
|
||||
expectedSize: '16px'
|
||||
},
|
||||
{
|
||||
settings: { messageFont: 'sans-serif', fontSize: 14 },
|
||||
expectedFont: 'var(--font-family)',
|
||||
expectedSize: '14px'
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ settings, expectedFont, expectedSize }) => {
|
||||
mockUseSettings.mockReturnValue({
|
||||
...settings,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
const block = createThinkingBlock()
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
// Find the styled content container
|
||||
const contentContainer = screen.getByTestId('collapse-content-thought')
|
||||
const styledDiv = contentContainer.querySelector('div')
|
||||
|
||||
expect(styledDiv).toHaveStyle({
|
||||
fontFamily: expectedFont,
|
||||
fontSize: expectedSize
|
||||
})
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration and edge cases', () => {
|
||||
it('should handle content updates correctly', () => {
|
||||
const block1 = createThinkingBlock({ content: 'Original thought' })
|
||||
const { rerender } = renderThinkingBlock(block1)
|
||||
|
||||
expect(screen.getByText('Markdown: Original thought')).toBeInTheDocument()
|
||||
|
||||
const block2 = createThinkingBlock({ content: 'Updated thought' })
|
||||
rerender(<ThinkingBlock block={block2} />)
|
||||
|
||||
expect(screen.getByText('Markdown: Updated thought')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Markdown: Original thought')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clean up timer on unmount', () => {
|
||||
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
|
||||
unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid status changes gracefully', () => {
|
||||
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(block)
|
||||
|
||||
// Rapidly toggle between states
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.STREAMING })} />)
|
||||
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.SUCCESS })} />)
|
||||
}
|
||||
|
||||
// Should still render correctly
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
expect(getCopyButton()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.c3 {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.c3:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.c3:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.c3 .iconfont {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 message-thought-container"
|
||||
data-active-key="thought"
|
||||
data-expand-icon-position="end"
|
||||
data-size="small"
|
||||
data-testid="collapse-container"
|
||||
>
|
||||
<div
|
||||
data-testid="collapse-item-thought"
|
||||
>
|
||||
<div
|
||||
data-testid="collapse-header-thought"
|
||||
>
|
||||
<div
|
||||
class="c1"
|
||||
>
|
||||
<span
|
||||
data-animate="idle"
|
||||
data-initial="idle"
|
||||
data-testid="motion-span"
|
||||
data-variants="{"active":{"rotate":10,"scale":1.1},"idle":{"rotate":0,"scale":1}}"
|
||||
style="height: 18px;"
|
||||
>
|
||||
<span
|
||||
data-size="18"
|
||||
data-testid="lightbulb-icon"
|
||||
>
|
||||
💡
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="c2"
|
||||
>
|
||||
Thought for 5.0s
|
||||
</span>
|
||||
<div
|
||||
data-mouse-enter-delay="0.8"
|
||||
data-testid="tooltip"
|
||||
title="Copy"
|
||||
>
|
||||
<button
|
||||
aria-label="Copy"
|
||||
class="c3 message-action-button"
|
||||
>
|
||||
<i
|
||||
class="iconfont icon-copy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="collapse-content-thought"
|
||||
>
|
||||
<div
|
||||
style="font-family: var(--font-family); font-size: 14px;"
|
||||
>
|
||||
<div
|
||||
data-block-id="test-thinking-block-1"
|
||||
data-testid="mock-markdown"
|
||||
>
|
||||
Markdown:
|
||||
I need to think about this carefully...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -42,6 +42,7 @@ const blockWrapperVariants = {
|
||||
const AnimatedBlockWrapper: React.FC<AnimatedBlockWrapperProps> = ({ children, enableAnimation }) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="block-wrapper"
|
||||
variants={blockWrapperVariants}
|
||||
initial={enableAnimation ? 'hidden' : 'static'}
|
||||
animate={enableAnimation ? 'visible' : 'static'}>
|
||||
|
||||
@@ -199,7 +199,7 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<any>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { userName } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { settedTheme } = useTheme()
|
||||
|
||||
const topicId = conversationId
|
||||
|
||||
@@ -491,7 +491,7 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="react-flow-container"
|
||||
colorMode={theme === 'auto' ? 'system' : theme}>
|
||||
colorMode={settedTheme}>
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap
|
||||
nodeStrokeWidth={3}
|
||||
|
||||
@@ -18,7 +18,17 @@ import styled from 'styled-components'
|
||||
import ChatFlowHistory from './ChatFlowHistory'
|
||||
|
||||
// Exclude some areas from the navigation
|
||||
const EXCLUDED_SELECTORS = ['.MessageFooter', '.code-toolbar', '.ant-collapse-header', '.group-menu-bar', '.code-block']
|
||||
const EXCLUDED_SELECTORS = [
|
||||
'.MessageFooter',
|
||||
'.code-toolbar',
|
||||
'.ant-collapse-header',
|
||||
'.group-menu-bar',
|
||||
'.code-block',
|
||||
'.message-editor'
|
||||
]
|
||||
|
||||
// Gap between the navigation bar and the right element
|
||||
const RIGHT_GAP = 16
|
||||
|
||||
interface ChatNavigationProps {
|
||||
containerId: string
|
||||
@@ -264,10 +274,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
const triggerWidth = 60 // Same as the width in styled component
|
||||
|
||||
// Safe way to calculate position when using calc expressions
|
||||
let rightOffset = 16 // Default right offset
|
||||
let rightOffset = RIGHT_GAP // Default right offset
|
||||
if (showRightTopics) {
|
||||
// When topics are shown on right, we need to account for topic list width
|
||||
rightOffset = 16 + 300 // Assuming topic list width is 300px, adjust if different
|
||||
rightOffset += 275 // --topic-list-width
|
||||
}
|
||||
|
||||
const rightPosition = window.innerWidth - rightOffset - triggerWidth
|
||||
@@ -280,7 +290,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
const isInTriggerArea =
|
||||
!isInExcludedArea &&
|
||||
e.clientX > rightPosition &&
|
||||
e.clientX < rightPosition + triggerWidth &&
|
||||
e.clientX < rightPosition + triggerWidth + RIGHT_GAP &&
|
||||
e.clientY > topPosition &&
|
||||
e.clientY < topPosition + height
|
||||
|
||||
@@ -412,7 +422,7 @@ interface NavigationContainerProps {
|
||||
|
||||
const NavigationContainer = styled.div<NavigationContainerProps>`
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
right: ${RIGHT_GAP}px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(${(props) => (props.$isVisible ? 0 : '100%')});
|
||||
z-index: 999;
|
||||
|
||||
@@ -139,6 +139,7 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
<WebSearchCard>
|
||||
<ContextMenu>
|
||||
<WebSearchCardHeader>
|
||||
<CitationIndex>{citation.number}</CitationIndex>
|
||||
{citation.showFavicon && citation.url && (
|
||||
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
|
||||
)}
|
||||
@@ -162,6 +163,7 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
<WebSearchCard>
|
||||
<ContextMenu>
|
||||
<WebSearchCardHeader>
|
||||
<CitationIndex>{citation.number}</CitationIndex>
|
||||
{citation.showFavicon && <FileSearch width={16} />}
|
||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title}
|
||||
@@ -210,6 +212,13 @@ const PreviewIcon = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const CitationIndex = styled.div`
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-2);
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const CitationLink = styled.a`
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user