Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36f56ba9aa | ||
|
|
022b11cf6c | ||
|
|
8d6662cb48 | ||
|
|
a59a45f109 | ||
|
|
6337561f65 | ||
|
|
fbbc94028d | ||
|
|
93d955c4b9 | ||
|
|
1c71e6d474 | ||
|
|
b2d10b7a6b | ||
|
|
1215bcb046 | ||
|
|
9195a0324e | ||
|
|
acbec213e8 | ||
|
|
e2a08e31e8 | ||
|
|
e479ee3dbc | ||
|
|
f6462ef998 | ||
|
|
dcdf49a5ce | ||
|
|
74f72fa5b6 | ||
|
|
36f33fed75 | ||
|
|
eb7c05fd4c | ||
|
|
cb746fd722 | ||
|
|
0449bc359a | ||
|
|
d3e51ffb1c | ||
|
|
77eb70626c | ||
|
|
345c4f096e | ||
|
|
a4aab3fd4e | ||
|
|
ecf770e183 | ||
|
|
d58911ac60 | ||
|
|
bb0a35b920 | ||
|
|
403649f2ea | ||
|
|
958f8387d0 | ||
|
|
9c89676030 | ||
|
|
34ec018840 | ||
|
|
1be103a249 | ||
|
|
f83f8bb789 | ||
|
|
cc2810b117 | ||
|
|
be1dae7ef0 | ||
|
|
446d26d8dc | ||
|
|
7724b49ec4 | ||
|
|
ecbd283779 | ||
|
|
389f750d7b | ||
|
|
23eaae80c8 | ||
|
|
8f8c2f852e | ||
|
|
13f7269e36 | ||
|
|
0cd62a07fb | ||
|
|
20b55693cb | ||
|
|
74cccf2c09 | ||
|
|
54d20aa99b | ||
|
|
2c8086f078 | ||
|
|
ea061a3ba6 | ||
|
|
28a6ba1b5d | ||
|
|
8b793a9ca9 | ||
|
|
fe1cf5d605 | ||
|
|
f0335b5aaa | ||
|
|
6c394ec375 | ||
|
|
9f49ce6dc9 | ||
|
|
0df331cf8a | ||
|
|
a5a04e1df7 | ||
|
|
170d1a3a9c | ||
|
|
ce941b6532 | ||
|
|
c5fc7df258 | ||
|
|
30844b8e21 | ||
|
|
99b00cedb4 | ||
|
|
63242384d6 | ||
|
|
e83d31a232 | ||
|
|
65c7b720de | ||
|
|
77ecfbac9f | ||
|
|
1a090a7c51 | ||
|
|
a88bf104df | ||
|
|
c9caa5f46b | ||
|
|
96ae5df1f1 | ||
|
|
6048f42740 | ||
|
|
5b199aa736 | ||
|
|
a6bb58bb45 | ||
|
|
a78db10798 | ||
|
|
479b3ccfb7 | ||
|
|
f916002a71 | ||
|
|
c5208eeaef | ||
|
|
2e8cbdc4aa | ||
|
|
77b0dfc8d3 | ||
|
|
c5c5681cfd | ||
|
|
808afa053f | ||
|
|
cb75d01fd3 | ||
|
|
3ae7bbf304 | ||
|
|
fc3d536433 | ||
|
|
36abf3f099 | ||
|
|
3d7fd5a30c | ||
|
|
f83d9fc03c | ||
|
|
94e6ba759e | ||
|
|
c8c30f327b | ||
|
|
72fae1af25 | ||
|
|
98f8bacdc8 | ||
|
|
06f6da725d | ||
|
|
d24eabb97c | ||
|
|
eca3f1d71e | ||
|
|
87d178773a | ||
|
|
02cb005668 | ||
|
|
cf1d5c098f | ||
|
|
65273b055c | ||
|
|
f171839830 | ||
|
|
8f9a5642f2 | ||
|
|
e906d5db25 | ||
|
|
80c09a07dc | ||
|
|
af6145600a | ||
|
|
42bda59392 | ||
|
|
e73f6505e9 | ||
|
|
332aa45618 | ||
|
|
253075e332 | ||
|
|
737b8f02b1 | ||
|
|
2a996e2c9a | ||
|
|
c77d627077 | ||
|
|
11daf93094 | ||
|
|
44b07ee35d | ||
|
|
b24de23219 | ||
|
|
431e2aaa13 | ||
|
|
9896c75a2e | ||
|
|
94cec70737 | ||
|
|
2ba4e51e93 | ||
|
|
665a62080b | ||
|
|
a05a7e45cc | ||
|
|
f8e9216270 |
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
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -113,5 +113,5 @@ jobs:
|
||||
allowUpdates: true
|
||||
makeLatest: false
|
||||
tag: ${{ steps.get-tag.outputs.tag }}
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -45,10 +45,15 @@ stats.html
|
||||
local
|
||||
.aider*
|
||||
.cursorrules
|
||||
.cursor/rules
|
||||
.cursor/*
|
||||
|
||||
# test
|
||||
# vitest
|
||||
coverage
|
||||
.vitest-cache
|
||||
vitest.config.*.timestamp-*
|
||||
|
||||
# playwright
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
YOUR_MEMORY_FILE_PATH
|
||||
|
||||
71
.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch
vendored
Normal file
71
.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs
|
||||
index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644
|
||||
--- a/dist/utils/tiktoken.cjs
|
||||
+++ b/dist/utils/tiktoken.cjs
|
||||
@@ -1,25 +1,14 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encodingForModel = exports.getEncoding = void 0;
|
||||
-const lite_1 = require("js-tiktoken/lite");
|
||||
const async_caller_js_1 = require("./async_caller.cjs");
|
||||
const cache = {};
|
||||
const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({});
|
||||
async function getEncoding(encoding) {
|
||||
- if (!(encoding in cache)) {
|
||||
- cache[encoding] = caller
|
||||
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
|
||||
- .then((res) => res.json())
|
||||
- .then((data) => new lite_1.Tiktoken(data))
|
||||
- .catch((e) => {
|
||||
- delete cache[encoding];
|
||||
- throw e;
|
||||
- });
|
||||
- }
|
||||
- return await cache[encoding];
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
exports.getEncoding = getEncoding;
|
||||
async function encodingForModel(model) {
|
||||
- return getEncoding((0, lite_1.getEncodingNameForModel)(model));
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
exports.encodingForModel = encodingForModel;
|
||||
diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js
|
||||
index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644
|
||||
--- a/dist/utils/tiktoken.js
|
||||
+++ b/dist/utils/tiktoken.js
|
||||
@@ -1,20 +1,9 @@
|
||||
-import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite";
|
||||
import { AsyncCaller } from "./async_caller.js";
|
||||
const cache = {};
|
||||
const caller = /* #__PURE__ */ new AsyncCaller({});
|
||||
export async function getEncoding(encoding) {
|
||||
- if (!(encoding in cache)) {
|
||||
- cache[encoding] = caller
|
||||
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
|
||||
- .then((res) => res.json())
|
||||
- .then((data) => new Tiktoken(data))
|
||||
- .catch((e) => {
|
||||
- delete cache[encoding];
|
||||
- throw e;
|
||||
- });
|
||||
- }
|
||||
- return await cache[encoding];
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
export async function encodingForModel(model) {
|
||||
- return getEncoding(getEncodingNameForModel(model));
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
diff --git a/package.json b/package.json
|
||||
index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -37,7 +37,6 @@
|
||||
"ansi-styles": "^5.0.0",
|
||||
"camelcase": "6",
|
||||
"decamelize": "1.2.0",
|
||||
- "js-tiktoken": "^1.0.12",
|
||||
"langsmith": ">=0.2.8 <0.4.0",
|
||||
"mustache": "^4.2.0",
|
||||
"p-queue": "^6.6.2",
|
||||
85
.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch
vendored
85
.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch
vendored
@@ -1,85 +0,0 @@
|
||||
diff --git a/core.js b/core.js
|
||||
index 862d66101f441fb4f47dfc8cff5e2d39e1f5a11e..6464bebbf696c39d35f0368f061ea4236225c162 100644
|
||||
--- a/core.js
|
||||
+++ b/core.js
|
||||
@@ -159,7 +159,7 @@ class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
...this.authHeaders(opts),
|
||||
};
|
||||
}
|
||||
diff --git a/core.mjs b/core.mjs
|
||||
index 05dbc6cfde51589a2b100d4e4b5b3c1a33b32b89..789fbb4985eb952a0349b779fa83b1a068af6e7e 100644
|
||||
--- a/core.mjs
|
||||
+++ b/core.mjs
|
||||
@@ -152,7 +152,7 @@ export class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
...this.authHeaders(opts),
|
||||
};
|
||||
}
|
||||
diff --git a/error.mjs b/error.mjs
|
||||
index 7d19f5578040afa004bc887aab1725e8703d2bac..59ec725b6142299a62798ac4bdedb63ba7d9932c 100644
|
||||
--- a/error.mjs
|
||||
+++ b/error.mjs
|
||||
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: castToError(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/resources/embeddings.js b/resources/embeddings.js
|
||||
index aae578404cb2d09a39ac33fc416f1c215c45eecd..25c54b05bdae64d5c3b36fbb30dc7c8221b14034 100644
|
||||
--- a/resources/embeddings.js
|
||||
+++ b/resources/embeddings.js
|
||||
@@ -36,6 +36,9 @@ class Embeddings extends resource_1.APIResource {
|
||||
// No encoding_format specified, defaulting to base64 for performance reasons
|
||||
// See https://github.com/openai/openai-node/pull/1312
|
||||
let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
+ if (body.model.includes('jina')) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
if (hasUserProvidedEncodingFormat) {
|
||||
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
|
||||
}
|
||||
@@ -47,7 +50,7 @@ class Embeddings extends resource_1.APIResource {
|
||||
...options,
|
||||
});
|
||||
// if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
|
||||
return response;
|
||||
}
|
||||
// in this stage, we are sure the user did not specify an encoding_format
|
||||
diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
|
||||
index 0df3c6cc79a520e54acb4c2b5f77c43b774035ff..aa488b8a11b2c413c0a663d9a6059d286d7b5faf 100644
|
||||
--- a/resources/embeddings.mjs
|
||||
+++ b/resources/embeddings.mjs
|
||||
@@ -10,6 +10,9 @@ export class Embeddings extends APIResource {
|
||||
// No encoding_format specified, defaulting to base64 for performance reasons
|
||||
// See https://github.com/openai/openai-node/pull/1312
|
||||
let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
+ if (body.model.includes('jina')) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
if (hasUserProvidedEncodingFormat) {
|
||||
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
|
||||
}
|
||||
@@ -21,7 +24,7 @@ export class Embeddings extends APIResource {
|
||||
...options,
|
||||
});
|
||||
// if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
|
||||
return response;
|
||||
}
|
||||
// in this stage, we are sure the user did not specify an encoding_format
|
||||
279
.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
vendored
Normal file
279
.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
diff --git a/client.js b/client.js
|
||||
index 33b4ff6309d5f29187dab4e285d07dac20340bab..8f568637ee9e4677585931fb0284c8165a933f69 100644
|
||||
--- a/client.js
|
||||
+++ b/client.js
|
||||
@@ -433,7 +433,7 @@ class OpenAI {
|
||||
'User-Agent': this.getUserAgent(),
|
||||
'X-Stainless-Retry-Count': String(retryCount),
|
||||
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
|
||||
- ...(0, detect_platform_1.getPlatformHeaders)(),
|
||||
+ // ...(0, detect_platform_1.getPlatformHeaders)(),
|
||||
'OpenAI-Organization': this.organization,
|
||||
'OpenAI-Project': this.project,
|
||||
},
|
||||
diff --git a/client.mjs b/client.mjs
|
||||
index c34c18213073540ebb296ea540b1d1ad39527906..1ce1a98256d7e90e26ca963582f235b23e996e73 100644
|
||||
--- a/client.mjs
|
||||
+++ b/client.mjs
|
||||
@@ -430,7 +430,7 @@ export class OpenAI {
|
||||
'User-Agent': this.getUserAgent(),
|
||||
'X-Stainless-Retry-Count': String(retryCount),
|
||||
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
'OpenAI-Organization': this.organization,
|
||||
'OpenAI-Project': this.project,
|
||||
},
|
||||
diff --git a/core/error.js b/core/error.js
|
||||
index a12d9d9ccd242050161adeb0f82e1b98d9e78e20..fe3a5462480558bc426deea147f864f12b36f9bd 100644
|
||||
--- a/core/error.js
|
||||
+++ b/core/error.js
|
||||
@@ -40,7 +40,7 @@ class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: (0, errors_1.castToError)(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/core/error.mjs b/core/error.mjs
|
||||
index 83cefbaffeb8c657536347322d8de9516af479a2..63334b7972ec04882aa4a0800c1ead5982345045 100644
|
||||
--- a/core/error.mjs
|
||||
+++ b/core/error.mjs
|
||||
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: castToError(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/resources/embeddings.js b/resources/embeddings.js
|
||||
index 2404264d4ba0204322548945ebb7eab3bea82173..8f1bc45cc45e0797d50989d96b51147b90ae6790 100644
|
||||
--- a/resources/embeddings.js
|
||||
+++ b/resources/embeddings.js
|
||||
@@ -5,52 +5,64 @@ exports.Embeddings = void 0;
|
||||
const resource_1 = require("../core/resource.js");
|
||||
const utils_1 = require("../internal/utils.js");
|
||||
class Embeddings extends resource_1.APIResource {
|
||||
- /**
|
||||
- * Creates an embedding vector representing the input text.
|
||||
- *
|
||||
- * @example
|
||||
- * ```ts
|
||||
- * const createEmbeddingResponse =
|
||||
- * await client.embeddings.create({
|
||||
- * input: 'The quick brown fox jumped over the lazy dog',
|
||||
- * model: 'text-embedding-3-small',
|
||||
- * });
|
||||
- * ```
|
||||
- */
|
||||
- create(body, options) {
|
||||
- const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
- // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
- // See https://github.com/openai/openai-node/pull/1312
|
||||
- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- (0, utils_1.loggerFor)(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
|
||||
- }
|
||||
- const response = this._client.post('/embeddings', {
|
||||
- body: {
|
||||
- ...body,
|
||||
- encoding_format: encoding_format,
|
||||
- },
|
||||
- ...options,
|
||||
- });
|
||||
- // if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- return response;
|
||||
- }
|
||||
- // in this stage, we are sure the user did not specify an encoding_format
|
||||
- // and we defaulted to base64 for performance reasons
|
||||
- // we are sure then that the response is base64 encoded, let's decode it
|
||||
- // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
- (0, utils_1.loggerFor)(this._client).debug('embeddings/decoding base64 embeddings from base64');
|
||||
- return response._thenUnwrap((response) => {
|
||||
- if (response && response.data) {
|
||||
- response.data.forEach((embeddingBase64Obj) => {
|
||||
- const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
- embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(embeddingBase64Str);
|
||||
- });
|
||||
- }
|
||||
- return response;
|
||||
- });
|
||||
- }
|
||||
+ /**
|
||||
+ * Creates an embedding vector representing the input text.
|
||||
+ *
|
||||
+ * @example
|
||||
+ * ```ts
|
||||
+ * const createEmbeddingResponse =
|
||||
+ * await client.embeddings.create({
|
||||
+ * input: 'The quick brown fox jumped over the lazy dog',
|
||||
+ * model: 'text-embedding-3-small',
|
||||
+ * });
|
||||
+ * ```
|
||||
+ */
|
||||
+ create(body, options) {
|
||||
+ const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
+ // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
+ // See https://github.com/openai/openai-node/pull/1312
|
||||
+ let encoding_format = hasUserProvidedEncodingFormat
|
||||
+ ? body.encoding_format
|
||||
+ : "base64";
|
||||
+ if (body.model.includes("jina")) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
+ if (hasUserProvidedEncodingFormat) {
|
||||
+ (0, utils_1.loggerFor)(this._client).debug(
|
||||
+ "embeddings/user defined encoding_format:",
|
||||
+ body.encoding_format
|
||||
+ );
|
||||
+ }
|
||||
+ const response = this._client.post("/embeddings", {
|
||||
+ body: {
|
||||
+ ...body,
|
||||
+ encoding_format: encoding_format,
|
||||
+ },
|
||||
+ ...options,
|
||||
+ });
|
||||
+ // if the user specified an encoding_format, return the response as-is
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
|
||||
+ return response;
|
||||
+ }
|
||||
+ // in this stage, we are sure the user did not specify an encoding_format
|
||||
+ // and we defaulted to base64 for performance reasons
|
||||
+ // we are sure then that the response is base64 encoded, let's decode it
|
||||
+ // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
+ (0, utils_1.loggerFor)(this._client).debug(
|
||||
+ "embeddings/decoding base64 embeddings from base64"
|
||||
+ );
|
||||
+ return response._thenUnwrap((response) => {
|
||||
+ if (response && response.data) {
|
||||
+ response.data.forEach((embeddingBase64Obj) => {
|
||||
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
+ embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(
|
||||
+ embeddingBase64Str
|
||||
+ );
|
||||
+ });
|
||||
+ }
|
||||
+ return response;
|
||||
+ });
|
||||
+ }
|
||||
}
|
||||
exports.Embeddings = Embeddings;
|
||||
//# sourceMappingURL=embeddings.js.map
|
||||
diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
|
||||
index 19dcaef578c194a89759c4360073cfd4f7dd2cbf..0284e9cc615c900eff508eb595f7360a74bd9200 100644
|
||||
--- a/resources/embeddings.mjs
|
||||
+++ b/resources/embeddings.mjs
|
||||
@@ -2,51 +2,61 @@
|
||||
import { APIResource } from "../core/resource.mjs";
|
||||
import { loggerFor, toFloat32Array } from "../internal/utils.mjs";
|
||||
export class Embeddings extends APIResource {
|
||||
- /**
|
||||
- * Creates an embedding vector representing the input text.
|
||||
- *
|
||||
- * @example
|
||||
- * ```ts
|
||||
- * const createEmbeddingResponse =
|
||||
- * await client.embeddings.create({
|
||||
- * input: 'The quick brown fox jumped over the lazy dog',
|
||||
- * model: 'text-embedding-3-small',
|
||||
- * });
|
||||
- * ```
|
||||
- */
|
||||
- create(body, options) {
|
||||
- const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
- // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
- // See https://github.com/openai/openai-node/pull/1312
|
||||
- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- loggerFor(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
|
||||
- }
|
||||
- const response = this._client.post('/embeddings', {
|
||||
- body: {
|
||||
- ...body,
|
||||
- encoding_format: encoding_format,
|
||||
- },
|
||||
- ...options,
|
||||
- });
|
||||
- // if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- return response;
|
||||
- }
|
||||
- // in this stage, we are sure the user did not specify an encoding_format
|
||||
- // and we defaulted to base64 for performance reasons
|
||||
- // we are sure then that the response is base64 encoded, let's decode it
|
||||
- // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
- loggerFor(this._client).debug('embeddings/decoding base64 embeddings from base64');
|
||||
- return response._thenUnwrap((response) => {
|
||||
- if (response && response.data) {
|
||||
- response.data.forEach((embeddingBase64Obj) => {
|
||||
- const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
- embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
|
||||
- });
|
||||
- }
|
||||
- return response;
|
||||
- });
|
||||
- }
|
||||
+ /**
|
||||
+ * Creates an embedding vector representing the input text.
|
||||
+ *
|
||||
+ * @example
|
||||
+ * ```ts
|
||||
+ * const createEmbeddingResponse =
|
||||
+ * await client.embeddings.create({
|
||||
+ * input: 'The quick brown fox jumped over the lazy dog',
|
||||
+ * model: 'text-embedding-3-small',
|
||||
+ * });
|
||||
+ * ```
|
||||
+ */
|
||||
+ create(body, options) {
|
||||
+ const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
+ // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
+ // See https://github.com/openai/openai-node/pull/1312
|
||||
+ let encoding_format = hasUserProvidedEncodingFormat
|
||||
+ ? body.encoding_format
|
||||
+ : "base64";
|
||||
+ if (body.model.includes("jina")) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
+ if (hasUserProvidedEncodingFormat) {
|
||||
+ loggerFor(this._client).debug(
|
||||
+ "embeddings/user defined encoding_format:",
|
||||
+ body.encoding_format
|
||||
+ );
|
||||
+ }
|
||||
+ const response = this._client.post("/embeddings", {
|
||||
+ body: {
|
||||
+ ...body,
|
||||
+ encoding_format: encoding_format,
|
||||
+ },
|
||||
+ ...options,
|
||||
+ });
|
||||
+ // if the user specified an encoding_format, return the response as-is
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
|
||||
+ return response;
|
||||
+ }
|
||||
+ // in this stage, we are sure the user did not specify an encoding_format
|
||||
+ // and we defaulted to base64 for performance reasons
|
||||
+ // we are sure then that the response is base64 encoded, let's decode it
|
||||
+ // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
+ loggerFor(this._client).debug(
|
||||
+ "embeddings/decoding base64 embeddings from base64"
|
||||
+ );
|
||||
+ return response._thenUnwrap((response) => {
|
||||
+ if (response && response.data) {
|
||||
+ response.data.forEach((embeddingBase64Obj) => {
|
||||
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
+ embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
|
||||
+ });
|
||||
+ }
|
||||
+ return response;
|
||||
+ });
|
||||
+ }
|
||||
}
|
||||
//# sourceMappingURL=embeddings.mjs.map
|
||||
@@ -12,30 +12,43 @@ electronLanguages:
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!{.vscode,.yarn,.github}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '**/*'
|
||||
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}}'
|
||||
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!**/{.editorconfig,.jekyll-metadata}'
|
||||
- '!src'
|
||||
- '!scripts'
|
||||
- '!local'
|
||||
- '!docs'
|
||||
- '!packages'
|
||||
- '!.swc'
|
||||
- '!.bin'
|
||||
- '!._*'
|
||||
- '!*.log'
|
||||
- '!stats.html'
|
||||
- '!*.md'
|
||||
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
|
||||
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
|
||||
- '!**/{test,tests,__tests__,coverage}/**'
|
||||
- '!**/{test,tests,__tests__,powered-test,coverage}/**'
|
||||
- '!**/{example,examples}/**'
|
||||
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||
- '!**/*.min.*.map'
|
||||
- '!**/*.d.ts'
|
||||
- '!**/{.DS_Store,Thumbs.db}'
|
||||
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}'
|
||||
- '!**/dist/es6/**'
|
||||
- '!**/dist/demo/**'
|
||||
- '!**/amd/**'
|
||||
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
|
||||
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
|
||||
- '!node_modules/rollup-plugin-visualizer'
|
||||
- '!node_modules/js-tiktoken'
|
||||
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
|
||||
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
@@ -94,10 +107,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
⚠️ 注意:升级前请备份数据,否则将无法降级
|
||||
文生图新增服务商 DMXAPI(限时免费)
|
||||
输入框按钮支持拖拽排序
|
||||
修复知识库搜索结果 100% 问题
|
||||
修复拖拽多选消息相关问题
|
||||
修复翻译回复内容导致内存异常问题
|
||||
常规错误修复和优化
|
||||
新增划词助手
|
||||
助手支持分组
|
||||
支持主题颜色切换
|
||||
划词助手支持应用过滤
|
||||
翻译模块功能改进
|
||||
|
||||
@@ -9,25 +9,7 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@cherrystudio/embedjs',
|
||||
'@cherrystudio/embedjs-openai',
|
||||
'@cherrystudio/embedjs-loader-web',
|
||||
'@cherrystudio/embedjs-loader-markdown',
|
||||
'@cherrystudio/embedjs-loader-msoffice',
|
||||
'@cherrystudio/embedjs-loader-xml',
|
||||
'@cherrystudio/embedjs-loader-pdf',
|
||||
'@cherrystudio/embedjs-loader-sitemap',
|
||||
'@cherrystudio/embedjs-libsql',
|
||||
'@cherrystudio/embedjs-loader-image',
|
||||
'p-queue',
|
||||
'webdav'
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('main')
|
||||
],
|
||||
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
@@ -37,7 +19,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client']
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate']
|
||||
},
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
},
|
||||
@@ -89,7 +71,9 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
package.json
59
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.3.12",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -22,7 +22,7 @@
|
||||
"dev": "electron-vite dev",
|
||||
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
|
||||
"build:check": "yarn typecheck && yarn check:i18n && yarn test",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
@@ -38,19 +38,20 @@
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||
"generate:agents": "yarn workspace @cherry-studio/database agents",
|
||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"check:i18n": "node scripts/check-i18n.js",
|
||||
"test": "yarn test:renderer",
|
||||
"test:coverage": "yarn test:renderer:coverage",
|
||||
"test:node": "npx -y tsx --test src/**/*.test.ts",
|
||||
"test:renderer": "vitest run",
|
||||
"test:renderer:ui": "vitest --ui",
|
||||
"test:renderer:coverage": "vitest run --coverage",
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
"test:renderer": "vitest run --project renderer",
|
||||
"test:update": "yarn test:renderer --update",
|
||||
"test:coverage": "vitest run --coverage --silent",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "yarn playwright test",
|
||||
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
@@ -69,14 +70,12 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tanstack/react-query": "^5.27.0",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"electron-log": "^5.1.5",
|
||||
@@ -84,22 +83,19 @@
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"got-scraping": "^4.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"selection-hook": "^0.9.21",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"webdav": "^5.8.0",
|
||||
"ws": "^8.18.1",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -112,18 +108,23 @@
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@eslint-react/eslint-plugin": "^1.36.1",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "^0.13.0",
|
||||
"@google/genai": "^1.0.1",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
@@ -137,17 +138,19 @@
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/ws": "^8",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.12",
|
||||
"@uiw/codemirror-themes-all": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"@vitest/web-worker": "^3.1.3",
|
||||
"@vitest/browser": "^3.1.4",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"@vitest/ui": "^3.1.4",
|
||||
"@vitest/web-worker": "^3.1.4",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "^5.22.5",
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"color": "^5.0.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
@@ -155,7 +158,6 @@
|
||||
"electron": "35.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^3.1.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
@@ -163,9 +165,11 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fast-diff": "^1.3.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
@@ -174,8 +178,9 @@
|
||||
"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",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react": "^19.0.0",
|
||||
@@ -207,19 +212,19 @@
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.1"
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"node-gyp": "^9.1.0",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch"
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -11,7 +11,6 @@ export enum IpcChannel {
|
||||
App_SetLaunchToTray = 'app:set-launch-to-tray',
|
||||
App_SetTray = 'app:set-tray',
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_RestartTray = 'app:restart-tray',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
@@ -111,6 +110,7 @@ export enum IpcChannel {
|
||||
File_WriteWithId = 'file:writeWithId',
|
||||
File_SaveImage = 'file:saveImage',
|
||||
File_Base64Image = 'file:base64Image',
|
||||
File_SaveBase64Image = 'file:saveBase64Image',
|
||||
File_Download = 'file:download',
|
||||
File_Copy = 'file:copy',
|
||||
File_BinaryImage = 'file:binaryImage',
|
||||
@@ -144,7 +144,7 @@ export enum IpcChannel {
|
||||
|
||||
// events
|
||||
BackupProgress = 'backup-progress',
|
||||
ThemeChange = 'theme:change',
|
||||
ThemeUpdated = 'theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
@@ -176,5 +176,23 @@ export enum IpcChannel {
|
||||
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
|
||||
|
||||
// Provider
|
||||
Provider_AddKey = 'provider:add-key'
|
||||
Provider_AddKey = 'provider:add-key',
|
||||
|
||||
//Selection Assistant
|
||||
Selection_TextSelected = 'selection:text-selected',
|
||||
Selection_ToolbarHide = 'selection:toolbar-hide',
|
||||
Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change',
|
||||
Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size',
|
||||
Selection_WriteToClipboard = 'selection:write-to-clipboard',
|
||||
Selection_SetEnabled = 'selection:set-enabled',
|
||||
Selection_SetTriggerMode = 'selection:set-trigger-mode',
|
||||
Selection_SetFilterMode = 'selection:set-filter-mode',
|
||||
Selection_SetFilterList = 'selection:set-filter-list',
|
||||
Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
|
||||
Selection_SetRemeberWinSize = 'selection:set-remeber-win-size',
|
||||
Selection_ActionWindowClose = 'selection:action-window-close',
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
Selection_ProcessAction = 'selection:process-action',
|
||||
Selection_UpdateActionData = 'selection:update-action-data'
|
||||
}
|
||||
|
||||
@@ -4,135 +4,368 @@ export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const thirdPartyApplicationExts = ['.draftsExport']
|
||||
export const bookExts = ['.epub']
|
||||
export const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
'.md', // Markdown 文件
|
||||
'.mdx', // Markdown 文件
|
||||
'.html', // HTML 文件
|
||||
'.htm', // HTML 文件的另一种扩展名
|
||||
'.xml', // XML 文件
|
||||
'.json', // JSON 文件
|
||||
'.yaml', // YAML 文件
|
||||
'.yml', // YAML 文件的另一种扩展名
|
||||
'.csv', // 逗号分隔值文件
|
||||
'.tsv', // 制表符分隔值文件
|
||||
'.ini', // 配置文件
|
||||
'.log', // 日志文件
|
||||
'.rtf', // 富文本格式文件
|
||||
'.org', // org-mode 文件
|
||||
'.wiki', // VimWiki 文件
|
||||
'.tex', // LaTeX 文件
|
||||
'.bib', // BibTeX 文件
|
||||
'.srt', // 字幕文件
|
||||
'.xhtml', // XHTML 文件
|
||||
'.nfo', // 信息文件(主要用于场景发布)
|
||||
'.conf', // 配置文件
|
||||
'.config', // 配置文件
|
||||
'.env', // 环境变量文件
|
||||
'.rst', // reStructuredText 文件
|
||||
'.php', // PHP 脚本文件,包含嵌入的 HTML
|
||||
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
|
||||
'.ts', // TypeScript 文件
|
||||
'.jsp', // JavaServer Pages 文件
|
||||
'.aspx', // ASP.NET 文件
|
||||
'.bat', // Windows 批处理文件
|
||||
'.sh', // Unix/Linux Shell 脚本文件
|
||||
'.py', // Python 脚本文件
|
||||
'.ipynb', // Jupyter 笔记本格式
|
||||
'.rb', // Ruby 脚本文件
|
||||
'.pl', // Perl 脚本文件
|
||||
'.sql', // SQL 脚本文件
|
||||
'.css', // Cascading Style Sheets 文件
|
||||
'.less', // Less CSS 预处理器文件
|
||||
'.scss', // Sass CSS 预处理器文件
|
||||
'.sass', // Sass 文件
|
||||
'.styl', // Stylus CSS 预处理器文件
|
||||
'.coffee', // CoffeeScript 文件
|
||||
'.ino', // Arduino 代码文件
|
||||
'.asm', // Assembly 语言文件
|
||||
'.go', // Go 语言文件
|
||||
'.scala', // Scala 语言文件
|
||||
'.swift', // Swift 语言文件
|
||||
'.kt', // Kotlin 语言文件
|
||||
'.rs', // Rust 语言文件
|
||||
'.lua', // Lua 语言文件
|
||||
'.groovy', // Groovy 语言文件
|
||||
'.dart', // Dart 语言文件
|
||||
'.hs', // Haskell 语言文件
|
||||
'.clj', // Clojure 语言文件
|
||||
'.cljs', // ClojureScript 语言文件
|
||||
'.elm', // Elm 语言文件
|
||||
'.erl', // Erlang 语言文件
|
||||
'.ex', // Elixir 语言文件
|
||||
'.exs', // Elixir 脚本文件
|
||||
'.pug', // Pug (formerly Jade) 模板文件
|
||||
'.haml', // Haml 模板文件
|
||||
'.slim', // Slim 模板文件
|
||||
'.tpl', // 模板文件(通用)
|
||||
'.ejs', // Embedded JavaScript 模板文件
|
||||
'.hbs', // Handlebars 模板文件
|
||||
'.mustache', // Mustache 模板文件
|
||||
'.jade', // Jade 模板文件 (已重命名为 Pug)
|
||||
'.twig', // Twig 模板文件
|
||||
'.blade', // Blade 模板文件 (Laravel)
|
||||
'.vue', // Vue.js 单文件组件
|
||||
'.jsx', // React JSX 文件
|
||||
'.tsx', // React TSX 文件
|
||||
'.graphql', // GraphQL 查询语言文件
|
||||
'.gql', // GraphQL 查询语言文件
|
||||
'.proto', // Protocol Buffers 文件
|
||||
'.thrift', // Thrift 文件
|
||||
'.toml', // TOML 配置文件
|
||||
'.edn', // Clojure 数据表示文件
|
||||
'.cake', // CakePHP 配置文件
|
||||
'.ctp', // CakePHP 视图文件
|
||||
'.cfm', // ColdFusion 标记语言文件
|
||||
'.cfc', // ColdFusion 组件文件
|
||||
'.m', // Objective-C 或 MATLAB 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
'.kts', // Kotlin Script 文件
|
||||
'.java', // Java 代码文件
|
||||
'.cs', // C# 代码文件
|
||||
'.cpp', // C++ 代码文件
|
||||
'.c', // C++ 代码文件
|
||||
'.h', // C++ 头文件
|
||||
'.hpp', // C++ 头文件
|
||||
'.cc', // C++ 源文件
|
||||
'.cxx', // C++ 源文件
|
||||
'.cppm', // C++20 模块接口文件
|
||||
'.ipp', // 模板实现文件
|
||||
'.ixx', // C++20 模块实现文件
|
||||
'.f90', // Fortran 90 源文件
|
||||
'.f', // Fortran 固定格式源代码文件
|
||||
'.f03', // Fortran 2003+ 源代码文件
|
||||
'.ahk', // AutoHotKey 语言文件
|
||||
'.tcl', // Tcl 脚本
|
||||
'.do', // Questa 或 Modelsim Tcl 脚本
|
||||
'.v', // Verilog 源文件
|
||||
'.sv', // SystemVerilog 源文件
|
||||
'.svh', // SystemVerilog 头文件
|
||||
'.vhd', // VHDL 源文件
|
||||
'.vhdl', // VHDL 源文件
|
||||
'.lef', // Library Exchange Format
|
||||
'.def', // Design Exchange Format
|
||||
'.edif', // Electronic Design Interchange Format
|
||||
'.sdf', // Standard Delay Format
|
||||
'.sdc', // Synopsys Design Constraints
|
||||
'.xdc', // Xilinx Design Constraints
|
||||
'.rpt', // 报告文件
|
||||
'.lisp', // Lisp 脚本
|
||||
'.il', // Cadence SKILL 脚本
|
||||
'.ils', // Cadence SKILL++ 脚本
|
||||
'.sp', // SPICE netlist 文件
|
||||
'.spi', // SPICE netlist 文件
|
||||
'.cir', // SPICE netlist 文件
|
||||
'.net', // SPICE netlist 文件
|
||||
'.scs', // Spectre netlist 文件
|
||||
'.asc', // LTspice netlist schematic 文件
|
||||
'.tf' // Technology File
|
||||
]
|
||||
const textExtsByCategory = new Map([
|
||||
[
|
||||
'language',
|
||||
[
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx', // JavaScript/TypeScript
|
||||
'.py', // Python
|
||||
'.java', // Java
|
||||
'.cs', // C#
|
||||
'.cpp',
|
||||
'.c',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.cc',
|
||||
'.cxx',
|
||||
'.cppm',
|
||||
'.ipp',
|
||||
'.ixx', // C/C++
|
||||
'.php', // PHP
|
||||
'.rb', // Ruby
|
||||
'.pl', // Perl
|
||||
'.go', // Go
|
||||
'.rs', // Rust
|
||||
'.swift', // Swift
|
||||
'.kt',
|
||||
'.kts', // Kotlin
|
||||
'.scala', // Scala
|
||||
'.lua', // Lua
|
||||
'.groovy', // Groovy
|
||||
'.dart', // Dart
|
||||
'.hs', // Haskell
|
||||
'.clj',
|
||||
'.cljs', // Clojure
|
||||
'.elm', // Elm
|
||||
'.erl', // Erlang
|
||||
'.ex',
|
||||
'.exs', // Elixir
|
||||
'.ml',
|
||||
'.mli', // OCaml
|
||||
'.fs', // F#
|
||||
'.r',
|
||||
'.R', // R
|
||||
'.sol', // Solidity
|
||||
'.awk', // AWK
|
||||
'.cob', // COBOL
|
||||
'.asm',
|
||||
'.s', // Assembly
|
||||
'.lisp',
|
||||
'.lsp', // Lisp
|
||||
'.coffee', // CoffeeScript
|
||||
'.ino', // Arduino
|
||||
'.jl', // Julia
|
||||
'.nim', // Nim
|
||||
'.zig', // Zig
|
||||
'.d', // D语言
|
||||
'.pas', // Pascal
|
||||
'.vb', // Visual Basic
|
||||
'.rkt', // Racket
|
||||
'.scm', // Scheme
|
||||
'.hx', // Haxe
|
||||
'.as', // ActionScript
|
||||
'.pde', // Processing
|
||||
'.f90',
|
||||
'.f',
|
||||
'.f03',
|
||||
'.for',
|
||||
'.f95', // Fortran
|
||||
'.adb',
|
||||
'.ads', // Ada
|
||||
'.pro', // Prolog
|
||||
'.m',
|
||||
'.mm', // Objective-C/MATLAB
|
||||
'.rpy', // Ren'Py
|
||||
'.ets', // OpenHarmony,
|
||||
'.uniswap', // DeFi
|
||||
'.vy', // Vyper
|
||||
'.shader',
|
||||
'.glsl',
|
||||
'.frag',
|
||||
'.vert',
|
||||
'.gd' // Godot
|
||||
]
|
||||
],
|
||||
[
|
||||
'script',
|
||||
[
|
||||
'.sh', // Shell
|
||||
'.bat',
|
||||
'.cmd', // Windows批处理
|
||||
'.ps1', // PowerShell
|
||||
'.tcl',
|
||||
'.do', // Tcl
|
||||
'.ahk', // AutoHotkey
|
||||
'.zsh', // Zsh
|
||||
'.fish', // Fish shell
|
||||
'.csh', // C shell
|
||||
'.vbs', // VBScript
|
||||
'.applescript', // AppleScript
|
||||
'.au3', // AutoIt
|
||||
'.bash',
|
||||
'.nu'
|
||||
]
|
||||
],
|
||||
[
|
||||
'style',
|
||||
[
|
||||
'.css', // CSS
|
||||
'.less', // Less
|
||||
'.scss',
|
||||
'.sass', // Sass
|
||||
'.styl', // Stylus
|
||||
'.pcss', // PostCSS
|
||||
'.postcss' // PostCSS
|
||||
]
|
||||
],
|
||||
[
|
||||
'template',
|
||||
[
|
||||
'.vue', // Vue.js
|
||||
'.pug',
|
||||
'.jade', // Pug/Jade
|
||||
'.haml', // Haml
|
||||
'.slim', // Slim
|
||||
'.tpl', // 通用模板
|
||||
'.ejs', // EJS
|
||||
'.hbs', // Handlebars
|
||||
'.mustache', // Mustache
|
||||
'.twig', // Twig
|
||||
'.blade', // Blade (Laravel)
|
||||
'.liquid', // Liquid
|
||||
'.jinja',
|
||||
'.jinja2',
|
||||
'.j2', // Jinja
|
||||
'.erb', // ERB
|
||||
'.vm', // Velocity
|
||||
'.ftl', // FreeMarker
|
||||
'.svelte', // Svelte
|
||||
'.astro' // Astro
|
||||
]
|
||||
],
|
||||
[
|
||||
'config',
|
||||
[
|
||||
'.ini', // INI配置
|
||||
'.conf',
|
||||
'.config', // 通用配置
|
||||
'.env', // 环境变量
|
||||
'.toml', // TOML
|
||||
'.cfg', // 通用配置
|
||||
'.properties', // Java属性
|
||||
'.desktop', // Linux桌面文件
|
||||
'.service', // systemd服务
|
||||
'.rc',
|
||||
'.bashrc',
|
||||
'.zshrc', // Shell配置
|
||||
'.fishrc', // Fish shell配置
|
||||
'.vimrc', // Vim配置
|
||||
'.htaccess', // Apache配置
|
||||
'.robots', // robots.txt
|
||||
'.editorconfig', // EditorConfig
|
||||
'.eslintrc', // ESLint
|
||||
'.prettierrc', // Prettier
|
||||
'.babelrc', // Babel
|
||||
'.npmrc', // npm
|
||||
'.dockerignore', // Docker ignore
|
||||
'.npmignore',
|
||||
'.yarnrc',
|
||||
'.prettierignore',
|
||||
'.eslintignore',
|
||||
'.browserslistrc',
|
||||
'.json5',
|
||||
'.tfvars'
|
||||
]
|
||||
],
|
||||
[
|
||||
'document',
|
||||
[
|
||||
'.txt',
|
||||
'.text', // 纯文本
|
||||
'.md',
|
||||
'.mdx', // Markdown
|
||||
'.html',
|
||||
'.htm',
|
||||
'.xhtml', // HTML
|
||||
'.xml', // XML
|
||||
'.org', // Org-mode
|
||||
'.wiki', // Wiki
|
||||
'.tex',
|
||||
'.bib', // LaTeX
|
||||
'.rst', // reStructuredText
|
||||
'.rtf', // 富文本
|
||||
'.nfo', // 信息文件
|
||||
'.adoc',
|
||||
'.asciidoc', // AsciiDoc
|
||||
'.pod', // Perl文档
|
||||
'.1',
|
||||
'.2',
|
||||
'.3',
|
||||
'.4',
|
||||
'.5',
|
||||
'.6',
|
||||
'.7',
|
||||
'.8',
|
||||
'.9', // man页面
|
||||
'.man', // man页面
|
||||
'.texi',
|
||||
'.texinfo', // Texinfo
|
||||
'.readme',
|
||||
'.me', // README
|
||||
'.changelog', // 变更日志
|
||||
'.license', // 许可证
|
||||
'.authors', // 作者文件
|
||||
'.po',
|
||||
'.pot'
|
||||
]
|
||||
],
|
||||
[
|
||||
'data',
|
||||
[
|
||||
'.json', // JSON
|
||||
'.jsonc', // JSON with comments
|
||||
'.yaml',
|
||||
'.yml', // YAML
|
||||
'.csv',
|
||||
'.tsv', // 分隔值文件
|
||||
'.edn', // Clojure数据
|
||||
'.jsonl',
|
||||
'.ndjson', // 换行分隔JSON
|
||||
'.geojson', // GeoJSON
|
||||
'.gpx', // GPS Exchange
|
||||
'.kml', // Keyhole Markup
|
||||
'.rss',
|
||||
'.atom', // Feed格式
|
||||
'.vcf', // vCard
|
||||
'.ics', // iCalendar
|
||||
'.ldif', // LDAP数据交换
|
||||
'.pbtxt',
|
||||
'.map'
|
||||
]
|
||||
],
|
||||
[
|
||||
'build',
|
||||
[
|
||||
'.gradle', // Gradle
|
||||
'.make',
|
||||
'.mk', // Make
|
||||
'.cmake', // CMake
|
||||
'.sbt', // SBT
|
||||
'.rake', // Rake
|
||||
'.spec', // RPM spec
|
||||
'.pom',
|
||||
'.build', // Meson
|
||||
'.bazel' // Bazel
|
||||
]
|
||||
],
|
||||
[
|
||||
'database',
|
||||
[
|
||||
'.sql', // SQL
|
||||
'.ddl',
|
||||
'.dml', // DDL/DML
|
||||
'.plsql', // PL/SQL
|
||||
'.psql', // PostgreSQL
|
||||
'.cypher', // Cypher
|
||||
'.sparql' // SPARQL
|
||||
]
|
||||
],
|
||||
[
|
||||
'web',
|
||||
[
|
||||
'.graphql',
|
||||
'.gql', // GraphQL
|
||||
'.proto', // Protocol Buffers
|
||||
'.thrift', // Thrift
|
||||
'.wsdl', // WSDL
|
||||
'.raml', // RAML
|
||||
'.swagger',
|
||||
'.openapi' // API文档
|
||||
]
|
||||
],
|
||||
[
|
||||
'version',
|
||||
[
|
||||
'.gitignore', // Git ignore
|
||||
'.gitattributes', // Git attributes
|
||||
'.gitconfig', // Git config
|
||||
'.hgignore', // Mercurial ignore
|
||||
'.bzrignore', // Bazaar ignore
|
||||
'.svnignore', // SVN ignore
|
||||
'.githistory' // Git history
|
||||
]
|
||||
],
|
||||
[
|
||||
'subtitle',
|
||||
[
|
||||
'.srt',
|
||||
'.sub',
|
||||
'.ass' // 字幕格式
|
||||
]
|
||||
],
|
||||
[
|
||||
'log',
|
||||
[
|
||||
'.log',
|
||||
'.rpt' // 日志和报告 (移除了.out,因为通常是二进制可执行文件)
|
||||
]
|
||||
],
|
||||
[
|
||||
'eda',
|
||||
[
|
||||
'.v',
|
||||
'.sv',
|
||||
'.svh', // Verilog/SystemVerilog
|
||||
'.vhd',
|
||||
'.vhdl', // VHDL
|
||||
'.lef',
|
||||
'.def', // LEF/DEF
|
||||
'.edif', // EDIF
|
||||
'.sdf', // SDF
|
||||
'.sdc',
|
||||
'.xdc', // 约束文件
|
||||
'.sp',
|
||||
'.spi',
|
||||
'.cir',
|
||||
'.net', // SPICE
|
||||
'.scs', // Spectre
|
||||
'.asc', // LTspice
|
||||
'.tf', // Technology File
|
||||
'.il',
|
||||
'.ils' // SKILL
|
||||
]
|
||||
],
|
||||
[
|
||||
'game',
|
||||
[
|
||||
'.mtl', // Material Template Library
|
||||
'.x3d', // X3D文件
|
||||
'.gltf', // glTF JSON
|
||||
'.prefab', // Unity预制体 (YAML格式)
|
||||
'.meta' // Unity元数据文件 (YAML格式)
|
||||
]
|
||||
],
|
||||
[
|
||||
'other',
|
||||
[
|
||||
'.mcfunction', // Minecraft函数
|
||||
'.jsp', // JSP
|
||||
'.aspx', // ASP.NET
|
||||
'.ipynb', // Jupyter Notebook
|
||||
'.cake',
|
||||
'.ctp', // CakePHP
|
||||
'.cfm',
|
||||
'.cfc' // ColdFusion
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
export const textExts = Array.from(textExtsByCategory.values()).flat()
|
||||
|
||||
export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]
|
||||
|
||||
|
||||
42
playwright.config.ts
Normal file
42
playwright.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
// Look for test files, relative to this configuration file.
|
||||
testDir: './tests/e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
]
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
})
|
||||
57
src/main/configs/SelectionConfig.ts
Normal file
57
src/main/configs/SelectionConfig.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
interface IFilterList {
|
||||
WINDOWS: string[]
|
||||
MAC?: string[]
|
||||
}
|
||||
|
||||
interface IFinetunedList {
|
||||
EXCLUDE_CLIPBOARD_CURSOR_DETECT: IFilterList
|
||||
INCLUDE_CLIPBOARD_DELAY_READ: IFilterList
|
||||
}
|
||||
|
||||
/*************************************************************************
|
||||
* 注意:请不要修改此配置,除非你非常清楚其含义、影响和行为的目的
|
||||
* Note: Do not modify this configuration unless you fully understand its meaning, implications, and intended behavior.
|
||||
* -----------------------------------------------------------------------
|
||||
* A predefined application filter list to include commonly used software
|
||||
* that does not require text selection but may conflict with it, and disable them in advance.
|
||||
* Only available in the selected mode.
|
||||
*
|
||||
* Specification: must be all lowercase, need to accurately find the actual running program name
|
||||
*************************************************************************/
|
||||
export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
|
||||
WINDOWS: [
|
||||
// Screenshot
|
||||
'snipaste.exe',
|
||||
'pixpin.exe',
|
||||
'sharex.exe',
|
||||
// Office
|
||||
'excel.exe',
|
||||
'powerpnt.exe',
|
||||
// Image Editor
|
||||
'photoshop.exe',
|
||||
'illustrator.exe',
|
||||
// Video Editor
|
||||
'adobe premiere pro.exe',
|
||||
'afterfx.exe',
|
||||
// Audio Editor
|
||||
'adobe audition.exe',
|
||||
// 3D Editor
|
||||
'blender.exe',
|
||||
'3dsmax.exe',
|
||||
'maya.exe',
|
||||
// CAD
|
||||
'acad.exe',
|
||||
'sldworks.exe',
|
||||
// Remote Desktop
|
||||
'mstsc.exe'
|
||||
]
|
||||
}
|
||||
|
||||
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
|
||||
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
|
||||
},
|
||||
INCLUDE_CLIPBOARD_DELAY_READ: {
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { isDev } from './constant'
|
||||
import { isDev, isWin } from './constant'
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
registerProtocolClient,
|
||||
setupAppImageDeepLink
|
||||
} from './services/ProtocolClient'
|
||||
import selectionService, { initSelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
@@ -23,6 +24,16 @@ import { setUserDataDir } from './utils/file'
|
||||
|
||||
Logger.initialize()
|
||||
|
||||
/**
|
||||
* Disable chromium's window animations
|
||||
* main purpose for this is to avoid the transparent window flashing when it is shown
|
||||
* (especially on Windows for SelectionAssistant Toolbar)
|
||||
* Know Issue: https://github.com/electron/electron/issues/12130#issuecomment-627198990
|
||||
*/
|
||||
if (isWin) {
|
||||
app.commandLine.appendSwitch('wm-window-animations-disabled')
|
||||
}
|
||||
|
||||
// in production mode, handle uncaught exception and unhandled rejection globally
|
||||
if (!isDev) {
|
||||
// handle uncaught exception
|
||||
@@ -84,6 +95,9 @@ if (!app.requestSingleInstanceLock()) {
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
|
||||
//start selection assistant service
|
||||
initSelectionService()
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
@@ -110,6 +124,11 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.isQuitting = true
|
||||
|
||||
// quit selection service
|
||||
if (selectionService) {
|
||||
selectionService.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
|
||||
@@ -6,11 +6,10 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
@@ -18,7 +17,6 @@ import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileService from './services/FileService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import { GeminiService } from './services/GeminiService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
import NotificationService from './services/NotificationService'
|
||||
@@ -26,9 +24,10 @@ import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
@@ -113,10 +112,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
|
||||
configManager.set(key, value)
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
|
||||
@@ -125,34 +122,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// theme
|
||||
ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => {
|
||||
const updateTitleBarOverlay = () => {
|
||||
if (!mainWindow?.setTitleBarOverlay) return
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
mainWindow.setTitleBarOverlay(isDark ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
}
|
||||
|
||||
const broadcastThemeChange = () => {
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
const effectiveTheme = isDark ? ThemeMode.dark : ThemeMode.light
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.webContents.send(IpcChannel.ThemeChange, effectiveTheme))
|
||||
}
|
||||
|
||||
const notifyThemeChange = () => {
|
||||
updateTitleBarOverlay()
|
||||
broadcastThemeChange()
|
||||
}
|
||||
|
||||
if (theme === ThemeMode.auto) {
|
||||
nativeTheme.themeSource = 'system'
|
||||
nativeTheme.on('updated', notifyThemeChange)
|
||||
} else {
|
||||
nativeTheme.themeSource = theme
|
||||
nativeTheme.off('updated', notifyThemeChange)
|
||||
}
|
||||
|
||||
updateTitleBarOverlay()
|
||||
configManager.setTheme(theme)
|
||||
notifyThemeChange()
|
||||
themeService.setTheme(theme)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => {
|
||||
@@ -249,6 +219,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
|
||||
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
||||
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
|
||||
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image)
|
||||
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
@@ -297,13 +268,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
// gemini
|
||||
ipcMain.handle(IpcChannel.Gemini_UploadFile, GeminiService.uploadFile)
|
||||
ipcMain.handle(IpcChannel.Gemini_Base64File, GeminiService.base64File)
|
||||
ipcMain.handle(IpcChannel.Gemini_RetrieveFile, GeminiService.retrieveFile)
|
||||
ipcMain.handle(IpcChannel.Gemini_ListFiles, GeminiService.listFiles)
|
||||
ipcMain.handle(IpcChannel.Gemini_DeleteFile, GeminiService.deleteFile)
|
||||
|
||||
// mini window
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
|
||||
@@ -379,4 +343,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// store sync
|
||||
storeSyncService.registerIpcHandler()
|
||||
|
||||
// selection assistant
|
||||
SelectionService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
@@ -94,15 +95,22 @@ export default class AppUpdater {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
}
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { update: updateLocale } = locale.translation
|
||||
|
||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
||||
if (detail === '') {
|
||||
detail = updateLocale.noReleaseNotes
|
||||
}
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: '安装更新',
|
||||
title: updateLocale.title,
|
||||
icon,
|
||||
message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
|
||||
detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
|
||||
buttons: ['稍后安装', '立即安装'],
|
||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
||||
detail,
|
||||
buttons: [updateLocale.later, updateLocale.install],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
@@ -118,7 +126,7 @@ export default class AppUpdater {
|
||||
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return '暂无更新说明'
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
|
||||
@@ -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'
|
||||
@@ -340,12 +340,8 @@ class BackupManager {
|
||||
|
||||
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
|
||||
try {
|
||||
const client = createClient(config.webdavHost, {
|
||||
username: config.webdavUser,
|
||||
password: config.webdavPass
|
||||
})
|
||||
|
||||
const response = await client.getDirectoryContents(config.webdavPath)
|
||||
const client = new WebDav(config)
|
||||
const response = await client.getDirectoryContents()
|
||||
const files = Array.isArray(response) ? response : response.data
|
||||
|
||||
return files
|
||||
|
||||
@@ -5,7 +5,7 @@ import Store from 'electron-store'
|
||||
|
||||
import { locales } from '../utils/locales'
|
||||
|
||||
enum ConfigKeys {
|
||||
export enum ConfigKeys {
|
||||
Language = 'language',
|
||||
Theme = 'theme',
|
||||
LaunchToTray = 'launchToTray',
|
||||
@@ -16,7 +16,13 @@ enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
EnableDataCollection = 'enableDataCollection'
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
|
||||
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
|
||||
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -32,12 +38,12 @@ export class ConfigManager {
|
||||
return this.get(ConfigKeys.Language, locale) as LanguageVarious
|
||||
}
|
||||
|
||||
setLanguage(theme: LanguageVarious) {
|
||||
this.set(ConfigKeys.Language, theme)
|
||||
setLanguage(lang: LanguageVarious) {
|
||||
this.setAndNotify(ConfigKeys.Language, lang)
|
||||
}
|
||||
|
||||
getTheme(): ThemeMode {
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.auto)
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.system)
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
@@ -57,8 +63,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setTray(value: boolean) {
|
||||
this.set(ConfigKeys.Tray, value)
|
||||
this.notifySubscribers(ConfigKeys.Tray, value)
|
||||
this.setAndNotify(ConfigKeys.Tray, value)
|
||||
}
|
||||
|
||||
getTrayOnClose(): boolean {
|
||||
@@ -74,8 +79,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setZoomFactor(factor: number) {
|
||||
this.set(ConfigKeys.ZoomFactor, factor)
|
||||
this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
|
||||
this.setAndNotify(ConfigKeys.ZoomFactor, factor)
|
||||
}
|
||||
|
||||
subscribe<T>(key: string, callback: (newValue: T) => void) {
|
||||
@@ -107,11 +111,10 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setShortcuts(shortcuts: Shortcut[]) {
|
||||
this.set(
|
||||
this.setAndNotify(
|
||||
ConfigKeys.Shortcuts,
|
||||
shortcuts.filter((shortcut) => shortcut.system)
|
||||
)
|
||||
this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
|
||||
}
|
||||
|
||||
getClickTrayToShowQuickAssistant(): boolean {
|
||||
@@ -127,7 +130,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setEnableQuickAssistant(value: boolean) {
|
||||
this.set(ConfigKeys.EnableQuickAssistant, value)
|
||||
this.setAndNotify(ConfigKeys.EnableQuickAssistant, value)
|
||||
}
|
||||
|
||||
getAutoUpdate(): boolean {
|
||||
@@ -146,8 +149,64 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.EnableDataCollection, value)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
// Selection Assistant: is enabled the selection assistant
|
||||
getSelectionAssistantEnabled(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, true)
|
||||
}
|
||||
|
||||
setSelectionAssistantEnabled(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: trigger mode (selected, ctrlkey)
|
||||
getSelectionAssistantTriggerMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantTriggerMode, 'selected')
|
||||
}
|
||||
|
||||
setSelectionAssistantTriggerMode(value: string) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: if action window position follow toolbar
|
||||
getSelectionAssistantFollowToolbar(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantFollowToolbar, true)
|
||||
}
|
||||
|
||||
setSelectionAssistantFollowToolbar(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantRemeberWinSize(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantRemeberWinSize, false)
|
||||
}
|
||||
|
||||
setSelectionAssistantRemeberWinSize(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantFilterMode, 'default')
|
||||
}
|
||||
|
||||
setSelectionAssistantFilterMode(value: string) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterList(): string[] {
|
||||
return this.get<string[]>(ConfigKeys.SelectionAssistantFilterList, [])
|
||||
}
|
||||
|
||||
setSelectionAssistantFilterList(value: string[]) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value)
|
||||
}
|
||||
|
||||
setAndNotify(key: string, value: unknown) {
|
||||
this.set(key, value, true)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown, isNotify: boolean = false) {
|
||||
this.store.set(key, value)
|
||||
isNotify && this.notifySubscribers(key, value)
|
||||
}
|
||||
|
||||
get<T>(key: string, defaultValue?: T) {
|
||||
|
||||
@@ -268,6 +268,51 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
|
||||
try {
|
||||
if (!base64Data) {
|
||||
throw new Error('Base64 data is required')
|
||||
}
|
||||
|
||||
// 移除 base64 头部信息(如果存在)
|
||||
const base64String = base64Data.replace(/^data:.*;base64,/, '')
|
||||
const buffer = Buffer.from(base64String, 'base64')
|
||||
const uuid = uuidv4()
|
||||
const ext = '.png'
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
logger.info('[FileStorage] Saving base64 image:', {
|
||||
storageDir: this.storageDir,
|
||||
destPath,
|
||||
bufferSize: buffer.length
|
||||
})
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
id: uuid,
|
||||
origin_name: uuid + ext,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: new Date().toISOString(),
|
||||
size: buffer.length,
|
||||
ext: ext.slice(1),
|
||||
type: getFileType(ext),
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Failed to save base64 image:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
const buffer = await fs.promises.readFile(filePath)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { File, FileState, GoogleGenAI, Pager } from '@google/genai'
|
||||
import { FileType } from '@types'
|
||||
import fs from 'fs'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
|
||||
export class GeminiService {
|
||||
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
|
||||
private static readonly CACHE_DURATION = 3000
|
||||
|
||||
static async uploadFile(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
file: FileType,
|
||||
{ apiKey, baseURL }: { apiKey: string; baseURL: string }
|
||||
): Promise<File> {
|
||||
const sdk = new GoogleGenAI({
|
||||
vertexai: false,
|
||||
apiKey,
|
||||
httpOptions: {
|
||||
baseUrl: baseURL
|
||||
}
|
||||
})
|
||||
|
||||
return await sdk.files.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
displayName: file.origin_name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async base64File(_: Electron.IpcMainInvokeEvent, file: FileType) {
|
||||
return {
|
||||
data: Buffer.from(fs.readFileSync(file.path)).toString('base64'),
|
||||
mimeType: 'application/pdf'
|
||||
}
|
||||
}
|
||||
|
||||
static async retrieveFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File | undefined> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
|
||||
if (cachedResponse) {
|
||||
return GeminiService.processResponse(cachedResponse, file)
|
||||
}
|
||||
|
||||
const response = await sdk.files.list()
|
||||
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, response, GeminiService.CACHE_DURATION)
|
||||
|
||||
return GeminiService.processResponse(response, file)
|
||||
}
|
||||
|
||||
private static async processResponse(response: Pager<File>, file: FileType) {
|
||||
for await (const f of response) {
|
||||
if (f.state === FileState.ACTIVE) {
|
||||
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string): Promise<File[]> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const files: File[] = []
|
||||
for await (const f of await sdk.files.list()) {
|
||||
files.push(f)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
static async deleteFile(_: Electron.IpcMainInvokeEvent, fileId: string, apiKey: string) {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
await sdk.files.delete({ name: fileId })
|
||||
}
|
||||
}
|
||||
1234
src/main/services/SelectionService.ts
Normal file
1234
src/main/services/SelectionService.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
src/main/services/ThemeService.ts
Normal file
48
src/main/services/ThemeService.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ThemeService {
|
||||
private theme: ThemeMode = ThemeMode.system
|
||||
constructor() {
|
||||
this.theme = configManager.getTheme()
|
||||
|
||||
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
|
||||
nativeTheme.themeSource = this.theme
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
configManager.setTheme(ThemeMode.system)
|
||||
nativeTheme.themeSource = ThemeMode.system
|
||||
}
|
||||
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
|
||||
}
|
||||
|
||||
themeUpdatadHandler() {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
|
||||
try {
|
||||
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
} catch (error) {
|
||||
// don't throw error if setTitleBarOverlay failed
|
||||
// Because it may be called with some windows have some title bar
|
||||
}
|
||||
}
|
||||
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
|
||||
})
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
if (theme === this.theme) {
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
nativeTheme.themeSource = theme
|
||||
configManager.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const themeService = new ThemeService()
|
||||
@@ -5,16 +5,17 @@ import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray }
|
||||
import icon from '../../../build/tray_icon.png?asset'
|
||||
import iconDark from '../../../build/tray_icon_dark.png?asset'
|
||||
import iconLight from '../../../build/tray_icon_light.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
private static instance: TrayService
|
||||
private tray: Tray | null = null
|
||||
private contextMenu: Menu | null = null
|
||||
|
||||
constructor() {
|
||||
this.watchConfigChanges()
|
||||
this.updateTray()
|
||||
this.watchTrayChanges()
|
||||
TrayService.instance = this
|
||||
}
|
||||
|
||||
@@ -43,6 +44,30 @@ export class TrayService {
|
||||
|
||||
this.tray = tray
|
||||
|
||||
this.updateContextMenu()
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(this.contextMenu)
|
||||
}
|
||||
|
||||
this.tray.setToolTip('Cherry Studio')
|
||||
|
||||
this.tray.on('right-click', () => {
|
||||
if (this.contextMenu) {
|
||||
this.tray?.popUpContextMenu(this.contextMenu)
|
||||
}
|
||||
})
|
||||
|
||||
this.tray.on('click', () => {
|
||||
if (configManager.getEnableQuickAssistant() && configManager.getClickTrayToShowQuickAssistant()) {
|
||||
windowService.showMiniWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private updateContextMenu() {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale } = locale.translation
|
||||
|
||||
@@ -64,25 +89,7 @@ export class TrayService {
|
||||
}
|
||||
].filter(Boolean) as MenuItemConstructorOptions[]
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate(template)
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(contextMenu)
|
||||
}
|
||||
|
||||
this.tray.setToolTip('Cherry Studio')
|
||||
|
||||
this.tray.on('right-click', () => {
|
||||
this.tray?.popUpContextMenu(contextMenu)
|
||||
})
|
||||
|
||||
this.tray.on('click', () => {
|
||||
if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
|
||||
windowService.showMiniWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
this.contextMenu = Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
private updateTray() {
|
||||
@@ -94,13 +101,6 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
public restartTray() {
|
||||
if (configManager.getTray()) {
|
||||
this.destroyTray()
|
||||
this.createTray()
|
||||
}
|
||||
}
|
||||
|
||||
private destroyTray() {
|
||||
if (this.tray) {
|
||||
this.tray.destroy()
|
||||
@@ -108,8 +108,16 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
private watchTrayChanges() {
|
||||
configManager.subscribe<boolean>('tray', () => this.updateTray())
|
||||
private watchConfigChanges() {
|
||||
configManager.subscribe(ConfigKeys.Tray, () => this.updateTray())
|
||||
|
||||
configManager.subscribe(ConfigKeys.Language, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
}
|
||||
|
||||
private quit() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import Stream from 'stream'
|
||||
import https from 'https'
|
||||
import {
|
||||
BufferLike,
|
||||
createClient,
|
||||
@@ -20,7 +21,8 @@ export default class WebDav {
|
||||
username: params.webdavUser,
|
||||
password: params.webdavPass,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity
|
||||
maxContentLength: Infinity,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false })
|
||||
})
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
@@ -74,6 +76,19 @@ export default class WebDav {
|
||||
}
|
||||
}
|
||||
|
||||
public getDirectoryContents = async () => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.instance.getDirectoryContents(this.webdavPath)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error getting directory contents on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public checkConnection = async () => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
|
||||
@@ -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,
|
||||
|
||||
71
src/main/utils/__tests__/aes.test.ts
Normal file
71
src/main/utils/__tests__/aes.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { decrypt, encrypt } from '../aes'
|
||||
|
||||
const key = '12345678901234567890123456789012' // 32字节
|
||||
const iv = '1234567890abcdef1234567890abcdef' // 32字节hex,实际应16字节hex
|
||||
|
||||
function getIv16() {
|
||||
// 取前16字节作为 hex
|
||||
return iv.slice(0, 32)
|
||||
}
|
||||
|
||||
describe('aes utils', () => {
|
||||
it('should encrypt and decrypt normal string', () => {
|
||||
const text = 'hello world'
|
||||
const { iv: outIv, encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(typeof encryptedData).toBe('string')
|
||||
expect(outIv).toBe(getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should support unicode and special chars', () => {
|
||||
const text = '你好,世界!🌟🚀'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const text = ''
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should encrypt and decrypt long string', () => {
|
||||
const text = 'a'.repeat(100_000)
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should throw error for wrong key', () => {
|
||||
const text = 'test'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for wrong iv', () => {
|
||||
const text = 'test'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid key/iv length', () => {
|
||||
expect(() => encrypt('test', 'shortkey', getIv16())).toThrow()
|
||||
expect(() => encrypt('test', key, 'shortiv')).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid encrypted data', () => {
|
||||
expect(() => decrypt('nothexdata', getIv16(), key)).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for non-string input', () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
expect(() => encrypt(null, key, getIv16())).toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
expect(() => decrypt(null, getIv16(), key)).toThrow()
|
||||
})
|
||||
})
|
||||
243
src/main/utils/__tests__/file.test.ts
Normal file
243
src/main/utils/__tests__/file.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileTypes } from '@types'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
vi.mock('node:os')
|
||||
vi.mock('node:path')
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'mock-uuid'
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((key) => {
|
||||
if (key === 'temp') return '/mock/temp'
|
||||
if (key === 'userData') return '/mock/userData'
|
||||
return '/mock/unknown'
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
describe('file', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock path.extname
|
||||
vi.mocked(path.extname).mockImplementation((file) => {
|
||||
const parts = file.split('.')
|
||||
return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''
|
||||
})
|
||||
|
||||
// Mock path.basename
|
||||
vi.mocked(path.basename).mockImplementation((file) => {
|
||||
const parts = file.split('/')
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
|
||||
// Mock path.join
|
||||
vi.mocked(path.join).mockImplementation((...args) => args.join('/'))
|
||||
|
||||
// Mock os.homedir
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('getFileType', () => {
|
||||
it('should return IMAGE for image extensions', () => {
|
||||
expect(getFileType('.jpg')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.png')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.gif')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.webp')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.bmp')).toBe(FileTypes.IMAGE)
|
||||
})
|
||||
|
||||
it('should return VIDEO for video extensions', () => {
|
||||
expect(getFileType('.mp4')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.avi')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.mov')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.mkv')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.flv')).toBe(FileTypes.VIDEO)
|
||||
})
|
||||
|
||||
it('should return AUDIO for audio extensions', () => {
|
||||
expect(getFileType('.mp3')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.wav')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.ogg')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.flac')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.aac')).toBe(FileTypes.AUDIO)
|
||||
})
|
||||
|
||||
it('should return TEXT for text extensions', () => {
|
||||
expect(getFileType('.txt')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.md')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.html')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.json')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.js')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.ts')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.css')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.java')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.py')).toBe(FileTypes.TEXT)
|
||||
})
|
||||
|
||||
it('should return DOCUMENT for document extensions', () => {
|
||||
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should return OTHER for unknown extensions', () => {
|
||||
expect(getFileType('.unknown')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('...')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.123')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
|
||||
it('should handle case-insensitive extensions', () => {
|
||||
expect(getFileType('.JPG')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.HtMl')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should handle extensions without leading dot', () => {
|
||||
expect(getFileType('jpg')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('pdf')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('mp3')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
|
||||
it('should handle extreme cases', () => {
|
||||
expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.文件')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.файл')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllFiles', () => {
|
||||
it('should return all valid files recursively', () => {
|
||||
// Mock file system
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => {
|
||||
if (dirPath === '/test') {
|
||||
return ['file1.txt', 'file2.pdf', 'subdir']
|
||||
} else if (dirPath === '/test/subdir') {
|
||||
return ['file3.md', 'file4.docx']
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
vi.mocked(fs.statSync).mockImplementation((filePath) => {
|
||||
const isDir = String(filePath).endsWith('subdir')
|
||||
return {
|
||||
isDirectory: () => isDir,
|
||||
size: 1024
|
||||
} as fs.Stats
|
||||
})
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].id).toBe('mock-uuid')
|
||||
expect(result[0].name).toBe('file1.txt')
|
||||
expect(result[0].type).toBe(FileTypes.TEXT)
|
||||
expect(result[1].name).toBe('file2.pdf')
|
||||
expect(result[1].type).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should skip hidden files', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt'])
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => false,
|
||||
size: 1024
|
||||
} as fs.Stats)
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('visible.txt')
|
||||
})
|
||||
|
||||
it('should skip unsupported file types', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf'])
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => false,
|
||||
size: 1024
|
||||
} as fs.Stats)
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
// Should only include document.pdf as the others are excluded types
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('document.pdf')
|
||||
expect(result[0].type).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should return empty array for empty directory', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue([])
|
||||
|
||||
const result = getAllFiles('/empty')
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle file system errors', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
|
||||
throw new Error('Directory not found')
|
||||
})
|
||||
|
||||
// Since the function doesn't have error handling, we expect it to propagate
|
||||
expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTempDir', () => {
|
||||
it('should return correct temp directory path', () => {
|
||||
const tempDir = getTempDir()
|
||||
expect(tempDir).toBe('/mock/temp/CherryStudio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilesDir', () => {
|
||||
it('should return correct files directory path', () => {
|
||||
const filesDir = getFilesDir()
|
||||
expect(filesDir).toBe('/mock/userData/Data/Files')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfigDir', () => {
|
||||
it('should return correct config directory path', () => {
|
||||
const configDir = getConfigDir()
|
||||
expect(configDir).toBe('/mock/home/.cherrystudio/config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAppConfigDir', () => {
|
||||
it('should return correct app config directory path', () => {
|
||||
const appConfigDir = getAppConfigDir('test-app')
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app')
|
||||
})
|
||||
|
||||
it('should handle empty app name', () => {
|
||||
const appConfigDir = getAppConfigDir('')
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
|
||||
})
|
||||
})
|
||||
})
|
||||
61
src/main/utils/__tests__/zip.test.ts
Normal file
61
src/main/utils/__tests__/zip.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compress, decompress } from '../zip'
|
||||
|
||||
const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] })
|
||||
|
||||
// 辅助函数:生成大字符串
|
||||
function makeLargeString(size: number) {
|
||||
return 'a'.repeat(size)
|
||||
}
|
||||
|
||||
describe('zip', () => {
|
||||
describe('compress & decompress', () => {
|
||||
it('should compress and decompress a normal JSON string', async () => {
|
||||
const compressed = await compress(jsonStr)
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe(jsonStr)
|
||||
})
|
||||
|
||||
it('should handle empty string', async () => {
|
||||
const compressed = await compress('')
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe('')
|
||||
})
|
||||
|
||||
it('should handle large string', async () => {
|
||||
const largeStr = makeLargeString(100_000)
|
||||
const compressed = await compress(largeStr)
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
expect(compressed.length).toBeLessThan(largeStr.length)
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe(largeStr)
|
||||
})
|
||||
|
||||
it('should throw error when decompressing invalid buffer', async () => {
|
||||
const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8')
|
||||
await expect(decompress(invalidBuffer)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error when compress input is not string', async () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(null)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(undefined)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(123)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error when decompress input is not buffer', async () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress(null)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress(undefined)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress('string')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip)
|
||||
|
||||
/**
|
||||
* 压缩字符串
|
||||
* @param {string} str 要压缩的 JSON 字符串
|
||||
* @returns {Promise<Buffer>} 压缩后的 Buffer
|
||||
* @param str
|
||||
*/
|
||||
export async function compress(str) {
|
||||
export async function compress(str: string): Promise<Buffer> {
|
||||
try {
|
||||
const buffer = Buffer.from(str, 'utf-8')
|
||||
return await gzipPromise(buffer)
|
||||
@@ -27,7 +27,7 @@ export async function compress(str) {
|
||||
* @param {Buffer} compressedBuffer - 压缩的 Buffer
|
||||
* @returns {Promise<string>} 解压缩后的 JSON 字符串
|
||||
*/
|
||||
export async function decompress(compressedBuffer) {
|
||||
export async function decompress(compressedBuffer: Buffer): Promise<string> {
|
||||
try {
|
||||
const buffer = await gunzipPromise(compressedBuffer)
|
||||
return buffer.toString('utf-8')
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
|
||||
@@ -18,8 +20,7 @@ const api = {
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
|
||||
setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
@@ -74,7 +75,9 @@ const api = {
|
||||
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||
download: (url: string, isUseContentType?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
@@ -124,7 +127,8 @@ const api = {
|
||||
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
|
||||
},
|
||||
miniWindow: {
|
||||
@@ -204,6 +208,24 @@ const api = {
|
||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||
unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
|
||||
onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action)
|
||||
},
|
||||
selection: {
|
||||
hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide),
|
||||
writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text),
|
||||
determineToolbarSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height),
|
||||
setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled),
|
||||
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
|
||||
setFollowToolbar: (isFollowToolbar: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
|
||||
setRemeberWinSize: (isRemeberWinSize: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
|
||||
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
|
||||
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('electron-log/renderer', () => {
|
||||
return {
|
||||
default: {
|
||||
info: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
debug: console.debug,
|
||||
verbose: console.log,
|
||||
silly: console.log,
|
||||
log: console.log,
|
||||
transports: {
|
||||
console: {
|
||||
level: 'info'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
on: vi.fn(), // Mocking ipcRenderer.on
|
||||
send: vi.fn() // Mocking ipcRenderer.send
|
||||
}
|
||||
},
|
||||
api: {
|
||||
file: {
|
||||
read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
|
||||
writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
|
||||
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
|
||||
// You can add other axios methods like put, delete etc. as needed
|
||||
}
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
...global.window, // Copy other global properties
|
||||
addEventListener: vi.fn(), // Mock addEventListener
|
||||
removeEventListener: vi.fn() // You can also mock removeEventListener if needed
|
||||
})
|
||||
41
src/renderer/selectionAction.html
Normal file
41
src/renderer/selectionAction.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Assistant</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
43
src/renderer/selectionToolbar.html
Normal file
43
src/renderer/selectionToolbar.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -206,8 +206,14 @@
|
||||
|
||||
.ant-collapse {
|
||||
border: 1px solid var(--color-border);
|
||||
.ant-color-picker & {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', serif, Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
@@ -147,11 +147,16 @@ ul {
|
||||
background-color: var(--color-white-soft);
|
||||
}
|
||||
}
|
||||
.group-grid-container.horizontal,
|
||||
.group-grid-container.grid {
|
||||
.message-content-container-assistant {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.group-message-wrapper {
|
||||
background-color: var(--color-background);
|
||||
.message-content-container {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-background-mute);
|
||||
}
|
||||
}
|
||||
.group-menu-bar {
|
||||
@@ -170,6 +175,7 @@ span.highlight {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
span.highlight.selected {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
@@ -299,15 +299,21 @@ emoji-picker {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
border-radius: 5px;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
font-family: var(--code-font-family);
|
||||
padding: 1px;
|
||||
border-radius: 5px;
|
||||
|
||||
.cm-gutters {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
:root {
|
||||
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(255, 255, 255, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(0, 0, 0, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 全局初始化滚动条样式 */
|
||||
|
||||
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@use './font.scss';
|
||||
|
||||
html {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
|
||||
--color-selection-toolbar-hover-bg: #222222;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-error: #f44336;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
|
||||
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
@@ -231,7 +231,6 @@ const ContentContainer = styled.div<{
|
||||
$wrap: boolean
|
||||
$fadeIn: boolean
|
||||
}>`
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border: 0.5px solid transparent;
|
||||
@@ -239,12 +238,11 @@ const ContentContainer = styled.div<{
|
||||
margin-top: 0;
|
||||
|
||||
.shiki {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
padding: 1em;
|
||||
|
||||
code {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.line {
|
||||
display: block;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex } from 'antd'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@@ -10,12 +12,16 @@ interface Props {
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const { mermaid, isLoading, error: mermaidError } = useMermaid()
|
||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isRendering, setIsRendering] = useState(false)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
|
||||
@@ -32,55 +38,69 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
const render = useCallback(async () => {
|
||||
try {
|
||||
if (!children) return
|
||||
// 实际的渲染函数
|
||||
const renderMermaid = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content || !mermaidRef.current) return
|
||||
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(children)
|
||||
try {
|
||||
setIsRendering(true)
|
||||
|
||||
if (!mermaidRef.current) return
|
||||
const { svg } = await mermaid.render(diagramId, children, mermaidRef.current)
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(content)
|
||||
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
const { svg } = await mermaid.render(diagramId, content, mermaidRef.current)
|
||||
|
||||
// 没有语法错误时清除错误记录和定时器
|
||||
setError(null)
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
// 延迟显示错误
|
||||
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = setTimeout(() => {
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message)
|
||||
}, 500)
|
||||
}
|
||||
}, [children, diagramId, mermaid])
|
||||
|
||||
// 渲染Mermaid图表
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
|
||||
startTransition(render)
|
||||
|
||||
// 清理定时器
|
||||
return () => {
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
} finally {
|
||||
setIsRendering(false)
|
||||
}
|
||||
},
|
||||
[diagramId, mermaid]
|
||||
)
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderMermaid(content))
|
||||
}, 300),
|
||||
[renderMermaid]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (isLoadingMermaid) return
|
||||
|
||||
if (children) {
|
||||
setIsRendering(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsRendering(false)
|
||||
}
|
||||
}, [isLoading, render])
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, isLoadingMermaid, debouncedRender])
|
||||
|
||||
const isLoading = isLoadingMermaid || isRendering
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = svgContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' })
|
||||
|
||||
// 添加基础样式
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host {
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
|
||||
// 清空并重新添加内容
|
||||
shadowRoot.innerHTML = ''
|
||||
shadowRoot.appendChild(style)
|
||||
|
||||
const svgContainer = document.createElement('div')
|
||||
svgContainer.innerHTML = children
|
||||
shadowRoot.appendChild(svgContainer)
|
||||
}, [children])
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
||||
imgSelector: '.svg-preview svg',
|
||||
imgSelector: 'svg',
|
||||
prefix: 'svg-image'
|
||||
})
|
||||
|
||||
@@ -23,18 +58,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
)
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
}
|
||||
|
||||
const SvgPreviewContainer = styled.div`
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
`
|
||||
|
||||
export default memo(SvgPreview)
|
||||
|
||||
@@ -249,8 +249,8 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
}
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
/* FIXME: 在 bubble style 中撑开一些宽度*/
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
@@ -285,13 +285,10 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
|
||||
const SplitViewWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -224,11 +224,10 @@ const CodeEditor = ({
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
border: '0.5px solid transparent',
|
||||
borderRadius: '5px',
|
||||
marginTop: 0,
|
||||
...style
|
||||
marginTop: 0
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
||||
copy: {
|
||||
id: 'copy',
|
||||
type: 'core',
|
||||
order: 10
|
||||
order: 11
|
||||
},
|
||||
download: {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 11
|
||||
order: 10
|
||||
},
|
||||
edit: {
|
||||
id: 'edit',
|
||||
|
||||
@@ -32,6 +32,14 @@ export const usePreviewToolHandlers = (
|
||||
// 创建选择器函数
|
||||
const getImgElement = useCallback(() => {
|
||||
if (!containerRef.current) return null
|
||||
|
||||
// 优先尝试从 Shadow DOM 中查找
|
||||
const shadowRoot = containerRef.current.shadowRoot
|
||||
if (shadowRoot) {
|
||||
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
||||
}
|
||||
|
||||
// 降级到常规 DOM 查找
|
||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||
}, [containerRef, imgSelector])
|
||||
|
||||
|
||||
83
src/renderer/src/components/CopyButton.tsx
Normal file
83
src/renderer/src/components/CopyButton.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Tooltip } from 'antd'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CopyButtonProps {
|
||||
tooltip?: string
|
||||
textToCopy: string
|
||||
label?: string
|
||||
color?: string
|
||||
hoverColor?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ButtonContainerProps {
|
||||
$color: string
|
||||
$hoverColor: string
|
||||
}
|
||||
|
||||
const CopyButton: FC<CopyButtonProps> = ({
|
||||
tooltip,
|
||||
textToCopy,
|
||||
label,
|
||||
color = 'var(--color-text-2)',
|
||||
hoverColor = 'var(--color-primary)',
|
||||
size = 14
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard
|
||||
.writeText(textToCopy)
|
||||
.then(() => {
|
||||
window.message?.success(t('message.copy.success'))
|
||||
})
|
||||
.catch(() => {
|
||||
window.message?.error(t('message.copy.failed'))
|
||||
})
|
||||
}
|
||||
|
||||
const button = (
|
||||
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
|
||||
<Copy size={size} className="copy-icon" />
|
||||
{label && <RightText size={size}>{label}</RightText>}
|
||||
</ButtonContainer>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return <Tooltip title={tooltip}>{button}</Tooltip>
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
const ButtonContainer = styled.div<ButtonContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RightText = styled.span<{ size: number }>`
|
||||
font-size: ${(props) => props.size}px;
|
||||
`
|
||||
|
||||
export default CopyButton
|
||||
@@ -66,6 +66,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
const collapseStyle = merge({}, defaultCollapseStyle, style)
|
||||
const collapseItemStyles = useMemo(() => {
|
||||
return merge({}, defaultCollapseItemStyles, styles)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeKeys])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getLeadingEmoji } from '@renderer/utils'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -8,12 +7,10 @@ interface EmojiIconProps {
|
||||
}
|
||||
|
||||
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className }) => {
|
||||
const _emoji = getLeadingEmoji(emoji || '⭐️') || '⭐️'
|
||||
|
||||
return (
|
||||
<Container className={className}>
|
||||
<EmojiBackground>{_emoji}</EmojiBackground>
|
||||
{_emoji}
|
||||
<EmojiBackground>{emoji || '⭐️'}</EmojiBackground>
|
||||
{emoji}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
|
||||
if (ref.current) {
|
||||
ref.current.addEventListener('emoji-click', (event: any) => {
|
||||
event.stopPropagation()
|
||||
onEmojiClick(event.detail.emoji.unicode)
|
||||
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
|
||||
})
|
||||
}
|
||||
}, [onEmojiClick])
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
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
|
||||
}
|
||||
|
||||
interface PopupContainerProps {
|
||||
title: string
|
||||
obsidianTags: string | null
|
||||
processingMethod: string | '3'
|
||||
open: boolean
|
||||
resolve: (success: boolean) => void
|
||||
message?: Message
|
||||
messages?: Message[]
|
||||
topic?: Topic
|
||||
}
|
||||
|
||||
// 转换文件信息数组为树形结构
|
||||
const convertToTreeData = (files: FileInfo[]) => {
|
||||
const treeData: any[] = [
|
||||
@@ -113,13 +123,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 +142,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 +149,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 +167,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 +192,9 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchVaults()
|
||||
}, [defaultObsidianVault])
|
||||
|
||||
// 当选择的vault变化时,获取其文件和文件夹
|
||||
useEffect(() => {
|
||||
if (selectedVault) {
|
||||
const fetchFiles = async () => {
|
||||
@@ -209,7 +210,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchFiles()
|
||||
}
|
||||
}, [selectedVault])
|
||||
@@ -219,82 +219,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') {
|
||||
content = `\n---\n${markdown}`
|
||||
} else {
|
||||
content = `---
|
||||
\ntitle: ${state.title}
|
||||
\ncreated: ${state.createdAt}
|
||||
\nsource: ${state.source}
|
||||
\ntags: ${state.tags}
|
||||
\n---\n${markdown}`
|
||||
content = `---\n\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')
|
||||
} else {
|
||||
// 如果是文件夹,自动设置标题为话题名并设置处理方式为3(新建)
|
||||
handleChange('processingMethod', '3')
|
||||
// 仅当用户未手动编辑过 title 时,才将其重置为 props.title
|
||||
if (!hasTitleBeenManuallyEdited) {
|
||||
// title 是 props.title
|
||||
handleChange('title', title)
|
||||
}
|
||||
}
|
||||
@@ -305,7 +294,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 +306,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 +317,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 +342,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
|
||||
<Spin spinning={loading}>
|
||||
{selectedVault ? (
|
||||
@@ -376,7 +363,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
)}
|
||||
</Spin>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
|
||||
<Input
|
||||
value={state.tags}
|
||||
@@ -398,7 +384,6 @@ 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}
|
||||
@@ -410,9 +395,12 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
<Option value="3">{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 { PopupContainer }
|
||||
|
||||
@@ -1,44 +1,38 @@
|
||||
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { 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: string | '3'
|
||||
topic?: Topic
|
||||
message?: Message
|
||||
messages?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置Obsidian 笔记属性弹窗
|
||||
* @param options.title 标题
|
||||
* @param options.markdown markdown内容
|
||||
* @param options.processingMethod 处理方式
|
||||
* @returns
|
||||
*/
|
||||
const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const div = document.createElement('div')
|
||||
document.body.appendChild(div)
|
||||
const root = createRoot(div)
|
||||
|
||||
const handleClose = (success: boolean) => {
|
||||
root.unmount()
|
||||
document.body.removeChild(div)
|
||||
resolve(success)
|
||||
}
|
||||
// 不再从store中获取tag配置
|
||||
root.render(
|
||||
<ObsidianExportDialog
|
||||
title={options.title}
|
||||
markdown={options.markdown}
|
||||
obsidianTags=""
|
||||
processingMethod={options.processingMethod}
|
||||
open={true}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
show: showObsidianExportDialog
|
||||
export default class ObsidianExportPopup {
|
||||
static hide() {
|
||||
TopView.hide('ObsidianExportPopup')
|
||||
}
|
||||
static show(options: ObsidianExportOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
title={options.title}
|
||||
processingMethod={options.processingMethod}
|
||||
topic={options.topic}
|
||||
message={options.message}
|
||||
messages={options.messages}
|
||||
obsidianTags={''}
|
||||
open={true}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
ObsidianExportPopup.hide()
|
||||
}}
|
||||
/>,
|
||||
'ObsidianExportPopup'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,9 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
const onAfterClose = () => {
|
||||
resolve(null)
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
@@ -61,7 +62,7 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
afterClose={onAfterClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
@@ -95,16 +96,7 @@ export default class PromptPopup {
|
||||
}
|
||||
static show(props: PromptPopupShowParams) {
|
||||
return new Promise<string>((resolve) => {
|
||||
TopView.show(
|
||||
<PromptPopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
'PromptPopup'
|
||||
)
|
||||
TopView.show(<PromptPopupContainer {...props} resolve={resolve} />, 'PromptPopup')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ShowParams {
|
||||
text: string
|
||||
textareaProps?: TextAreaProps
|
||||
modalProps?: ModalProps
|
||||
showTranslate?: boolean
|
||||
children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode
|
||||
}
|
||||
|
||||
@@ -25,7 +26,14 @@ interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, resolve, children }) => {
|
||||
const PopupContainer: React.FC<Props> = ({
|
||||
text,
|
||||
textareaProps,
|
||||
modalProps,
|
||||
resolve,
|
||||
children,
|
||||
showTranslate = true
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [textValue, setTextValue] = useState(text)
|
||||
@@ -148,12 +156,14 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
onInput={resizeTextArea}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
/>
|
||||
<TranslateButton
|
||||
onClick={handleTranslate}
|
||||
aria-label="Translate text"
|
||||
disabled={isTranslating || !textValue.trim()}>
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</TranslateButton>
|
||||
{showTranslate && (
|
||||
<TranslateButton
|
||||
onClick={handleTranslate}
|
||||
aria-label="Translate text"
|
||||
disabled={isTranslating || !textValue.trim()}>
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</TranslateButton>
|
||||
)}
|
||||
</TextAreaContainer>
|
||||
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
|
||||
</Modal>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { theme } from 'antd'
|
||||
import Color from 'color'
|
||||
import { t } from 'i18next'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -40,8 +39,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
throw new Error('QuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
|
||||
const { token } = theme.useToken()
|
||||
const colorPrimary = Color(token.colorPrimary || '#008000')
|
||||
const { colorPrimary } = useUserTheme()
|
||||
const selectedColor = colorPrimary.alpha(0.15).toString()
|
||||
const selectedColorHover = colorPrimary.alpha(0.2).toString()
|
||||
|
||||
@@ -434,7 +432,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
$pageSize={ctx.pageSize}
|
||||
$selectedColor={selectedColor}
|
||||
$selectedColorHover={selectedColorHover}
|
||||
className={ctx.isVisible ? 'visible' : ''}>
|
||||
className={ctx.isVisible ? 'visible' : ''}
|
||||
data-testid="quick-panel">
|
||||
<QuickPanelBody
|
||||
ref={bodyRef}
|
||||
onMouseMove={() =>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
right?: boolean
|
||||
ref?: React.RefObject<HTMLDivElement | null>
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
@@ -12,38 +11,46 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsScrolling(true)
|
||||
|
||||
const clearScrollingTimeout = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
|
||||
}, [])
|
||||
|
||||
const throttledInternalScrollHandler = throttle(handleScroll, 200)
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsScrolling(true)
|
||||
clearScrollingTimeout()
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsScrolling(false)
|
||||
timeoutRef.current = null
|
||||
}, 1500)
|
||||
}, [clearScrollingTimeout])
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledInternalScrollHandler = useCallback(throttle(handleScroll, 100, { leading: true, trailing: true }), [
|
||||
handleScroll
|
||||
])
|
||||
|
||||
// Combined scroll handler
|
||||
const combinedOnScroll = useCallback(() => {
|
||||
// Event is available if needed by internal handler
|
||||
throttledInternalScrollHandler() // Call internal logic
|
||||
throttledInternalScrollHandler()
|
||||
if (externalOnScroll) {
|
||||
externalOnScroll() // Call external logic (from useScrollPosition)
|
||||
externalOnScroll()
|
||||
}
|
||||
}, [throttledInternalScrollHandler, externalOnScroll])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current)
|
||||
clearScrollingTimeout()
|
||||
throttledInternalScrollHandler.cancel()
|
||||
}
|
||||
}, [throttledInternalScrollHandler])
|
||||
}, [throttledInternalScrollHandler, clearScrollingTimeout])
|
||||
|
||||
return (
|
||||
<Container
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
isScrolling={isScrolling}
|
||||
$isScrolling={isScrolling}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
@@ -51,15 +58,13 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
|
||||
const Container = styled.div<{ $isScrolling: boolean }>`
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: ${(props) =>
|
||||
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''})` : 'transparent'};
|
||||
background: ${(props) => (props.$isScrolling ? 'var(--color-scrollbar-thumb)' : 'transparent')};
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''}-hover)` : 'transparent'};
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
44
src/renderer/src/components/__tests__/CustomTag.test.tsx
Normal file
44
src/renderer/src/components/__tests__/CustomTag.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import CustomTag from '../CustomTag'
|
||||
|
||||
const COLOR = '#ff0000'
|
||||
|
||||
describe('CustomTag', () => {
|
||||
it('should render children text', () => {
|
||||
render(<CustomTag color={COLOR}>content</CustomTag>)
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon if provided', () => {
|
||||
render(
|
||||
<CustomTag color={COLOR} icon={<span data-testid="icon">cherry</span>}>
|
||||
content
|
||||
</CustomTag>
|
||||
)
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tooltip if tooltip prop is set', async () => {
|
||||
render(
|
||||
<CustomTag color={COLOR} tooltip="reasoning model">
|
||||
reasoning
|
||||
</CustomTag>
|
||||
)
|
||||
// 鼠标悬停触发 Tooltip
|
||||
await userEvent.hover(screen.getByText('reasoning'))
|
||||
expect(await screen.findByText('reasoning model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render Tooltip when tooltip is not set', () => {
|
||||
render(<CustomTag color="#ff0000">no tooltip</CustomTag>)
|
||||
|
||||
expect(screen.getByText('no tooltip')).toBeInTheDocument()
|
||||
// 不应有 tooltip 相关内容
|
||||
expect(document.querySelector('.ant-tooltip')).toBeNull()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
282
src/renderer/src/components/__tests__/DragableList.test.tsx
Normal file
282
src/renderer/src/components/__tests__/DragableList.test.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/// <reference types="@vitest/browser/context" />
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DragableList from '../DragableList'
|
||||
|
||||
// mock @hello-pangea/dnd 组件
|
||||
vi.mock('@hello-pangea/dnd', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
DragDropContext: ({ children, onDragEnd }: any) => {
|
||||
// 挂载到 window 以便测试用例直接调用
|
||||
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
|
||||
onDragEnd && onDragEnd(result, provided)
|
||||
}
|
||||
return <div data-testid="drag-drop-context">{children}</div>
|
||||
},
|
||||
Droppable: ({ children }: any) => (
|
||||
<div data-testid="droppable">
|
||||
{children({ droppableProps: {}, innerRef: () => {}, placeholder: <div data-testid="placeholder" /> })}
|
||||
</div>
|
||||
),
|
||||
Draggable: ({ children, draggableId, index }: any) => (
|
||||
<div data-testid={`draggable-${draggableId}-${index}`}>
|
||||
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: () => {} })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// mock VirtualList 只做简单渲染
|
||||
vi.mock('rc-virtual-list', () => ({
|
||||
__esModule: true,
|
||||
default: ({ data, itemKey, children }: any) => (
|
||||
<div data-testid="virtual-list">
|
||||
{data.map((item: any, idx: number) => (
|
||||
<div key={item[itemKey] || item} data-testid="virtual-list-item">
|
||||
{children(item, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
triggerOnDragEnd: (result?: any, provided?: any) => void
|
||||
}
|
||||
}
|
||||
|
||||
describe('DragableList', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render all list items', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
const items = screen.getAllByTestId('item')
|
||||
expect(items.length).toBe(3)
|
||||
expect(items[0].textContent).toBe('A')
|
||||
expect(items[1].textContent).toBe('B')
|
||||
expect(items[2].textContent).toBe('C')
|
||||
})
|
||||
|
||||
it('should render with custom style and listStyle', () => {
|
||||
const list = [{ id: 'a', name: 'A' }]
|
||||
const style = { background: 'red' }
|
||||
const listStyle = { color: 'blue' }
|
||||
render(
|
||||
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
// 检查 style 是否传递到外层容器
|
||||
const virtualList = screen.getByTestId('virtual-list')
|
||||
expect(virtualList.parentElement).toHaveStyle({ background: 'red' })
|
||||
})
|
||||
|
||||
it('should render nothing when list is empty', () => {
|
||||
render(
|
||||
<DragableList list={[]} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
// 虚拟列表存在但无内容
|
||||
const items = screen.queryAllByTestId('item')
|
||||
expect(items.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop', () => {
|
||||
it('should call onUpdate with new order after drag end', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const newOrder = [list[1], list[2], list[0]]
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(newOrder)
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onDragStart and onDragEnd', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const onDragStart = vi.fn()
|
||||
const onDragEnd = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 先手动调用 onDragStart
|
||||
onDragStart()
|
||||
// 再模拟拖拽结束
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
|
||||
expect(onDragStart).toHaveBeenCalledTimes(1)
|
||||
expect(onDragEnd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onUpdate if dropped at same position', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 模拟拖拽到自身
|
||||
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(list)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should work with single item', () => {
|
||||
const list = [{ id: 'a', name: 'A' }]
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 拖拽自身
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(list)
|
||||
})
|
||||
|
||||
it('should not crash if callbacks are undefined', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' }
|
||||
]
|
||||
|
||||
// 不传 onDragStart/onDragEnd
|
||||
expect(() => {
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle items without id', () => {
|
||||
const list = ['A', 'B', 'C']
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 拖拽第0项到第2项
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate.mock.calls[0][0]).toEqual(['B', 'C', 'A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction', () => {
|
||||
it('should show placeholder during drag', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// placeholder 应该在初始渲染时就存在
|
||||
const placeholder = screen.getByTestId('placeholder')
|
||||
expect(placeholder).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reorder correctly when dragged to first/last', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const onUpdate = vi.fn()
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
|
||||
// 拖拽第2项到第0项
|
||||
window.triggerOnDragEnd({ source: { index: 2 }, destination: { index: 0 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: 'c', name: 'C' },
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' }
|
||||
])
|
||||
|
||||
// 拖拽第0项到第2项
|
||||
onUpdate.mockClear()
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' },
|
||||
{ id: 'a', name: 'A' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot', () => {
|
||||
it('should match snapshot', () => {
|
||||
const list = [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const { container } = render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ExpandableText from '../ExpandableText'
|
||||
|
||||
// mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k })
|
||||
}))
|
||||
|
||||
describe('ExpandableText', () => {
|
||||
const TEXT = 'This is a long text for testing.'
|
||||
|
||||
it('should render text and expand button', () => {
|
||||
render(<ExpandableText text={TEXT} />)
|
||||
expect(screen.getByText(TEXT)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toHaveTextContent('common.expand')
|
||||
})
|
||||
|
||||
it('should toggle expand/collapse when button is clicked', async () => {
|
||||
render(<ExpandableText text={TEXT} />)
|
||||
const button = screen.getByRole('button')
|
||||
// 初始为收起状态
|
||||
expect(button).toHaveTextContent('common.expand')
|
||||
// 点击展开
|
||||
await userEvent.click(button)
|
||||
expect(button).toHaveTextContent('common.collapse')
|
||||
// 再次点击收起
|
||||
await userEvent.click(button)
|
||||
expect(button).toHaveTextContent('common.expand')
|
||||
})
|
||||
})
|
||||
211
src/renderer/src/components/__tests__/QuickPanelView.test.tsx
Normal file
211
src/renderer/src/components/__tests__/QuickPanelView.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel'
|
||||
|
||||
// Mock Redux store
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
settings: (state = { userTheme: { colorPrimary: '#1677ff' } }) => state
|
||||
}
|
||||
})
|
||||
|
||||
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
|
||||
return Array.from({ length }, (_, i) => ({
|
||||
label: `${prefix} ${i + 1}`,
|
||||
description: `${prefix} Description ${i + 1}`,
|
||||
icon: `${prefix} Icon ${i + 1}`,
|
||||
action: () => {},
|
||||
...extra
|
||||
}))
|
||||
}
|
||||
|
||||
type KeyStep = {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
expected: string | ((text: string) => boolean)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 7
|
||||
|
||||
// 用于测试 open 行为的组件
|
||||
function OpenPanelOnMount({ list }: { list: QuickPanelListItem[] }) {
|
||||
const quickPanel = useQuickPanel()
|
||||
useEffect(() => {
|
||||
quickPanel.open({
|
||||
title: 'Test Panel',
|
||||
list,
|
||||
symbol: 'test',
|
||||
pageSize: PAGE_SIZE
|
||||
})
|
||||
}, [list, quickPanel])
|
||||
return null
|
||||
}
|
||||
|
||||
function wrapWithProviders(children: React.ReactNode) {
|
||||
return (
|
||||
<Provider store={mockStore}>
|
||||
<QuickPanelProvider>{children}</QuickPanelProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('QuickPanelView', () => {
|
||||
beforeEach(() => {
|
||||
// 添加一个假的 .inputbar textarea 到 document.body
|
||||
const inputbar = document.createElement('div')
|
||||
inputbar.className = 'inputbar'
|
||||
const textarea = document.createElement('textarea')
|
||||
inputbar.appendChild(textarea)
|
||||
document.body.appendChild(inputbar)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const inputbar = document.querySelector('.inputbar')
|
||||
if (inputbar) inputbar.remove()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render without crashing when wrapped in QuickPanelProvider', () => {
|
||||
render(wrapWithProviders(<QuickPanelView setInputText={vi.fn()} />))
|
||||
|
||||
// 检查面板容器是否存在且初始不可见
|
||||
const panel = screen.getByTestId('quick-panel')
|
||||
expect(panel.classList.contains('visible')).toBe(false)
|
||||
})
|
||||
|
||||
it('should render list after open', async () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
// 检查面板可见
|
||||
const panel = screen.getByTestId('quick-panel')
|
||||
expect(panel.classList.contains('visible')).toBe(true)
|
||||
// 检查第一个 item 是否渲染
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('focusing', () => {
|
||||
// 执行一系列按键,检查 focused item 是否正确
|
||||
async function runKeySequenceAndCheck(panel: HTMLElement, sequence: KeyStep[]) {
|
||||
const user = userEvent.setup()
|
||||
for (const { key, ctrlKey, expected } of sequence) {
|
||||
let keyString = ''
|
||||
if (ctrlKey) keyString += '{Control>}'
|
||||
keyString += key.length === 1 ? key : `{${key}}`
|
||||
if (ctrlKey) keyString += '{/Control}'
|
||||
await user.keyboard(keyString)
|
||||
|
||||
// 检查是否只有一个 focused item
|
||||
const focused = panel.querySelectorAll('.focused')
|
||||
expect(focused.length).toBe(1)
|
||||
// 检查 focused item 是否包含预期文本
|
||||
const text = focused[0].textContent || ''
|
||||
if (typeof expected === 'string') {
|
||||
expect(text).toContain(expected)
|
||||
} else {
|
||||
expect(expected(text)).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should focus on the first item after panel open', () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
// 检查第一个 item 是否有 focused
|
||||
const item1 = screen.getByText('Item 1')
|
||||
const focused = item1.closest('.focused')
|
||||
expect(focused).not.toBeNull()
|
||||
expect(item1).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'ArrowUp', expected: 'Item 99' },
|
||||
{ key: 'ArrowDown', expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', expected: 'Item 1' }
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
})
|
||||
|
||||
it('should focus on the right item using PageUp, PageDown', async () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
|
||||
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
|
||||
{ key: 'PageDown', expected: 'Item 100' }
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
})
|
||||
|
||||
it('should focus on the right item using Ctrl+ArrowUp, Ctrl+ArrowDown', async () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
})
|
||||
})
|
||||
})
|
||||
176
src/renderer/src/components/__tests__/Scrollbar.test.tsx
Normal file
176
src/renderer/src/components/__tests__/Scrollbar.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
// Mock lodash throttle
|
||||
vi.mock('lodash', async () => {
|
||||
const actual = await import('lodash')
|
||||
return {
|
||||
...actual,
|
||||
throttle: vi.fn((fn) => {
|
||||
// 简单地直接返回函数,不实际执行节流
|
||||
const throttled = (...args: any[]) => fn(...args)
|
||||
throttled.cancel = vi.fn()
|
||||
return throttled
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('Scrollbar', () => {
|
||||
beforeEach(() => {
|
||||
// 使用 fake timers
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// 恢复真实的 timers
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render children correctly', () => {
|
||||
render(
|
||||
<Scrollbar data-testid="scrollbar">
|
||||
<div data-testid="child">测试内容</div>
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
const child = screen.getByTestId('child')
|
||||
expect(child).toBeDefined()
|
||||
expect(child.textContent).toBe('测试内容')
|
||||
})
|
||||
|
||||
it('should pass custom props to container', () => {
|
||||
render(
|
||||
<Scrollbar data-testid="scrollbar" className="custom-class">
|
||||
内容
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
expect(scrollbar.className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('should match default styled snapshot', () => {
|
||||
const { container } = render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrolling behavior', () => {
|
||||
it('should update isScrolling state when scrolled', () => {
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 初始状态下应该不是滚动状态
|
||||
expect(scrollbar.getAttribute('isScrolling')).toBeFalsy()
|
||||
|
||||
// 触发滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 由于 isScrolling 是组件内部状态,不直接反映在 DOM 属性上
|
||||
// 但可以检查模拟的事件处理是否被调用
|
||||
expect(scrollbar).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset isScrolling after timeout', () => {
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 触发滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 前进时间但不超过timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
// 前进超过timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
|
||||
// 不测试样式,这里只检查组件是否存在
|
||||
expect(scrollbar).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset timeout on continuous scrolling', () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
|
||||
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 第一次滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 前进一部分时间
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800)
|
||||
})
|
||||
|
||||
// 再次滚动
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// clearTimeout 应该被调用,因为在第二次滚动时会清除之前的定时器
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('throttling', () => {
|
||||
it('should use throttled scroll handler', async () => {
|
||||
const { throttle } = await import('lodash')
|
||||
|
||||
render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
// 验证 throttle 被调用
|
||||
expect(throttle).toHaveBeenCalled()
|
||||
// 验证 throttle 调用时使用了 100ms 延迟和正确的选项
|
||||
expect(throttle).toHaveBeenCalledWith(expect.any(Function), 100, { leading: true, trailing: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clear timeout and cancel throttle on unmount', async () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
|
||||
|
||||
const { unmount } = render(<Scrollbar data-testid="scrollbar">内容</Scrollbar>)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 触发滚动设置定时器
|
||||
fireEvent.scroll(scrollbar)
|
||||
|
||||
// 卸载组件
|
||||
unmount()
|
||||
|
||||
// 验证 clearTimeout 被调用
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
|
||||
// 验证 throttle.cancel 被调用
|
||||
const { throttle } = await import('lodash')
|
||||
const throttledFunction = (throttle as unknown as Mock).mock.results[0].value
|
||||
expect(throttledFunction.cancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('props handling', () => {
|
||||
it('should handle ref forwarding', () => {
|
||||
const ref = { current: null }
|
||||
|
||||
render(
|
||||
<Scrollbar data-testid="scrollbar" ref={ref}>
|
||||
内容
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
// 验证 ref 被正确设置
|
||||
expect(ref.current).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DragableList > snapshot > should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
data-testid="drag-drop-context"
|
||||
>
|
||||
<div
|
||||
data-testid="droppable"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
data-testid="virtual-list"
|
||||
>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-a-0"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-b-1"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="virtual-list-item"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-c-2"
|
||||
>
|
||||
<div
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<div
|
||||
data-testid="item"
|
||||
>
|
||||
C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="scrollbar"
|
||||
>
|
||||
内容
|
||||
</div>
|
||||
`;
|
||||
@@ -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" />
|
||||
@@ -137,7 +140,7 @@ const MainMenus: FC = () => {
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const { pathname } = useLocation()
|
||||
const { sidebarIcons } = useSettings()
|
||||
const { sidebarIcons, defaultPaintingProvider } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
const { theme } = useTheme()
|
||||
@@ -146,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" />,
|
||||
@@ -158,7 +161,7 @@ const MainMenus: FC = () => {
|
||||
const pathMap = {
|
||||
assistants: '/',
|
||||
agents: '/agents',
|
||||
paintings: '/paintings',
|
||||
paintings: `/paintings/${defaultPaintingProvider}`,
|
||||
translate: '/translate',
|
||||
minapp: '/apps',
|
||||
knowledge: '/knowledge',
|
||||
|
||||
@@ -15,3 +15,18 @@ export const TOKENFLUX_HOST = 'https://tokenflux.ai'
|
||||
// Messages loading configuration
|
||||
export const INITIAL_MESSAGES_COUNT = 20
|
||||
export const LOAD_MORE_COUNT = 20
|
||||
|
||||
export const DEFAULT_COLOR_PRIMARY = '#00b96b'
|
||||
export const THEME_COLOR_PRESETS = [
|
||||
DEFAULT_COLOR_PRIMARY,
|
||||
'#FF5470', // Coral Pink
|
||||
'#14B8A6', // Teal
|
||||
'#6366F1', // Indigo
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#3B82F6', // Blue
|
||||
'#F59E0B', // Amber
|
||||
'#6D28D9', // Violet
|
||||
'#0EA5E9', // Sky Blue
|
||||
'#0284C7' // Light Blue
|
||||
]
|
||||
|
||||
@@ -1348,15 +1348,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'
|
||||
}
|
||||
],
|
||||
@@ -2315,7 +2339,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',
|
||||
@@ -2617,7 +2642,7 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
|
||||
// Claude models
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 64000 }
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
|
||||
}
|
||||
|
||||
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
|
||||
|
||||
@@ -124,8 +124,8 @@ export const PROVIDER_CONFIG = {
|
||||
websites: {
|
||||
official: 'https://o3.fan',
|
||||
apiKey: 'https://o3.fan/token',
|
||||
docs: 'https://docs.o3.fan',
|
||||
models: 'https://docs.o3.fan/models'
|
||||
docs: '',
|
||||
models: 'https://o3.fan/info/models/'
|
||||
}
|
||||
},
|
||||
burncloud: {
|
||||
@@ -144,11 +144,10 @@ export const PROVIDER_CONFIG = {
|
||||
url: 'https://api.ppinfra.com/v3/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
official: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
models:
|
||||
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link'
|
||||
models: 'https://ppio.cn/model-api/product/llm-api?invited_by=JYT9GD&utm_source=github_cherry-studio'
|
||||
}
|
||||
},
|
||||
gemini: {
|
||||
@@ -395,7 +394,7 @@ export const PROVIDER_CONFIG = {
|
||||
official: 'https://openrouter.ai/',
|
||||
apiKey: 'https://openrouter.ai/settings/keys',
|
||||
docs: 'https://openrouter.ai/docs/quick-start',
|
||||
models: 'https://openrouter.ai/docs/models'
|
||||
models: 'https://openrouter.ai/models'
|
||||
}
|
||||
},
|
||||
groq: {
|
||||
@@ -447,7 +446,7 @@ export const PROVIDER_CONFIG = {
|
||||
websites: {
|
||||
official: 'https://x.ai/',
|
||||
docs: 'https://docs.x.ai/',
|
||||
models: 'https://docs.x.ai/docs#getting-started'
|
||||
models: 'https://docs.x.ai/docs/models'
|
||||
}
|
||||
},
|
||||
hyperbolic: {
|
||||
|
||||
@@ -1,65 +1,127 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
export const TranslateLanguageOptions = [
|
||||
export interface TranslateLanguageOption {
|
||||
value: string
|
||||
langCode?: string
|
||||
label: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export const TranslateLanguageOptions: TranslateLanguageOption[] = [
|
||||
{
|
||||
value: 'english',
|
||||
langCode: 'en-us',
|
||||
label: i18n.t('languages.english'),
|
||||
emoji: '🇬🇧'
|
||||
},
|
||||
{
|
||||
value: 'chinese',
|
||||
langCode: 'zh-cn',
|
||||
label: i18n.t('languages.chinese'),
|
||||
emoji: '🇨🇳'
|
||||
},
|
||||
{
|
||||
value: 'chinese-traditional',
|
||||
langCode: 'zh-tw',
|
||||
label: i18n.t('languages.chinese-traditional'),
|
||||
emoji: '🇭🇰'
|
||||
},
|
||||
{
|
||||
value: 'japanese',
|
||||
langCode: 'ja-jp',
|
||||
label: i18n.t('languages.japanese'),
|
||||
emoji: '🇯🇵'
|
||||
},
|
||||
{
|
||||
value: 'korean',
|
||||
langCode: 'ko-kr',
|
||||
label: i18n.t('languages.korean'),
|
||||
emoji: '🇰🇷'
|
||||
},
|
||||
{
|
||||
value: 'russian',
|
||||
label: i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
},
|
||||
{
|
||||
value: 'spanish',
|
||||
label: i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
|
||||
{
|
||||
value: 'french',
|
||||
langCode: 'fr-fr',
|
||||
label: i18n.t('languages.french'),
|
||||
emoji: '🇫🇷'
|
||||
},
|
||||
{
|
||||
value: 'german',
|
||||
langCode: 'de-de',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
},
|
||||
{
|
||||
value: 'italian',
|
||||
langCode: 'it-it',
|
||||
label: i18n.t('languages.italian'),
|
||||
emoji: '🇮🇹'
|
||||
},
|
||||
{
|
||||
value: 'spanish',
|
||||
langCode: 'es-es',
|
||||
label: i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
{
|
||||
value: 'portuguese',
|
||||
langCode: 'pt-pt',
|
||||
label: i18n.t('languages.portuguese'),
|
||||
emoji: '🇵🇹'
|
||||
},
|
||||
{
|
||||
value: 'russian',
|
||||
langCode: 'ru-ru',
|
||||
label: i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
},
|
||||
{
|
||||
value: 'polish',
|
||||
langCode: 'pl-pl',
|
||||
label: i18n.t('languages.polish'),
|
||||
emoji: '🇵🇱'
|
||||
},
|
||||
{
|
||||
value: 'arabic',
|
||||
langCode: 'ar-ar',
|
||||
label: i18n.t('languages.arabic'),
|
||||
emoji: '🇸🇦'
|
||||
},
|
||||
{
|
||||
value: 'german',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
value: 'turkish',
|
||||
langCode: 'tr-tr',
|
||||
label: i18n.t('languages.turkish'),
|
||||
emoji: '🇹🇷'
|
||||
},
|
||||
{
|
||||
value: 'thai',
|
||||
langCode: 'th-th',
|
||||
label: i18n.t('languages.thai'),
|
||||
emoji: '🇹🇭'
|
||||
},
|
||||
{
|
||||
value: 'vietnamese',
|
||||
langCode: 'vi-vn',
|
||||
label: i18n.t('languages.vietnamese'),
|
||||
emoji: '🇻🇳'
|
||||
},
|
||||
{
|
||||
value: 'indonesian',
|
||||
langCode: 'id-id',
|
||||
label: i18n.t('languages.indonesian'),
|
||||
emoji: '🇮🇩'
|
||||
},
|
||||
{
|
||||
value: 'urdu',
|
||||
langCode: 'ur-pk',
|
||||
label: i18n.t('languages.urdu'),
|
||||
emoji: '🇵🇰'
|
||||
},
|
||||
{
|
||||
value: 'malay',
|
||||
langCode: 'ms-my',
|
||||
label: i18n.t('languages.malay'),
|
||||
emoji: '🇲🇾'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -15,13 +15,18 @@ import { FC, PropsWithChildren } from 'react'
|
||||
import { useTheme } from './ThemeProvider'
|
||||
|
||||
const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { language } = useSettings()
|
||||
const {
|
||||
language,
|
||||
userTheme: { colorPrimary }
|
||||
} = useSettings()
|
||||
const { theme: _theme } = useTheme()
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={getAntdLocale(language)}
|
||||
theme={{
|
||||
cssVar: true,
|
||||
hashed: false,
|
||||
algorithm: [_theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm],
|
||||
components: {
|
||||
Menu: {
|
||||
@@ -40,10 +45,17 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
},
|
||||
Tooltip: {
|
||||
fontSize: 13
|
||||
},
|
||||
ColorPicker: {
|
||||
fontFamily: 'var(--code-font-family)'
|
||||
},
|
||||
Segmented: {
|
||||
itemActiveBg: 'var(--color-background-mute)',
|
||||
itemHoverBg: 'var(--color-background-mute)'
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: '#00b96b',
|
||||
colorPrimary: colorPrimary,
|
||||
fontFamily: 'var(--font-family)'
|
||||
}
|
||||
}}>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeMode
|
||||
settingTheme: ThemeMode
|
||||
settedTheme: ThemeMode
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: ThemeMode.auto,
|
||||
settingTheme: ThemeMode.auto,
|
||||
theme: ThemeMode.system,
|
||||
settedTheme: ThemeMode.dark,
|
||||
toggleTheme: () => {}
|
||||
})
|
||||
|
||||
@@ -20,47 +21,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)
|
||||
|
||||
return <ThemeContext value={{ theme: effectiveTheme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext>
|
||||
// if theme is old auto, then set theme to system
|
||||
// we can delete this after next big release
|
||||
if (settedTheme !== ThemeMode.dark && settedTheme !== ThemeMode.light && settedTheme !== ThemeMode.system) {
|
||||
setSettedTheme(ThemeMode.system)
|
||||
}
|
||||
|
||||
initUserTheme()
|
||||
|
||||
// listen for theme updates from main process
|
||||
return window.electron.ipcRenderer.on(IpcChannel.ThemeUpdated, (_, actualTheme: ThemeMode) => {
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
setActualTheme(actualTheme)
|
||||
})
|
||||
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.setTheme(settedTheme)
|
||||
}, [settedTheme])
|
||||
|
||||
return <ThemeContext value={{ theme: actualTheme, settedTheme, toggleTheme }}>{children}</ThemeContext>
|
||||
}
|
||||
|
||||
export const useTheme = () => use(ThemeContext)
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
63
src/renderer/src/hooks/useSelectionAssistant.ts
Normal file
63
src/renderer/src/hooks/useSelectionAssistant.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
setActionItems,
|
||||
setActionWindowOpacity,
|
||||
setFilterList,
|
||||
setFilterMode,
|
||||
setIsAutoClose,
|
||||
setIsAutoPin,
|
||||
setIsCompact,
|
||||
setIsFollowToolbar,
|
||||
setIsRemeberWinSize,
|
||||
setSelectionEnabled,
|
||||
setTriggerMode
|
||||
} from '@renderer/store/selectionStore'
|
||||
import { ActionItem, FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
|
||||
|
||||
export function useSelectionAssistant() {
|
||||
const dispatch = useAppDispatch()
|
||||
const selectionStore = useAppSelector((state) => state.selectionStore)
|
||||
|
||||
return {
|
||||
...selectionStore,
|
||||
setSelectionEnabled: (enabled: boolean) => {
|
||||
dispatch(setSelectionEnabled(enabled))
|
||||
window.api.selection.setEnabled(enabled)
|
||||
},
|
||||
setTriggerMode: (mode: TriggerMode) => {
|
||||
dispatch(setTriggerMode(mode))
|
||||
window.api.selection.setTriggerMode(mode)
|
||||
},
|
||||
setIsCompact: (isCompact: boolean) => {
|
||||
dispatch(setIsCompact(isCompact))
|
||||
},
|
||||
setIsAutoClose: (isAutoClose: boolean) => {
|
||||
dispatch(setIsAutoClose(isAutoClose))
|
||||
},
|
||||
setIsAutoPin: (isAutoPin: boolean) => {
|
||||
dispatch(setIsAutoPin(isAutoPin))
|
||||
},
|
||||
setIsFollowToolbar: (isFollowToolbar: boolean) => {
|
||||
dispatch(setIsFollowToolbar(isFollowToolbar))
|
||||
window.api.selection.setFollowToolbar(isFollowToolbar)
|
||||
},
|
||||
setIsRemeberWinSize: (isRemeberWinSize: boolean) => {
|
||||
dispatch(setIsRemeberWinSize(isRemeberWinSize))
|
||||
window.api.selection.setRemeberWinSize(isRemeberWinSize)
|
||||
},
|
||||
setFilterMode: (mode: FilterMode) => {
|
||||
dispatch(setFilterMode(mode))
|
||||
window.api.selection.setFilterMode(mode)
|
||||
},
|
||||
setFilterList: (list: string[]) => {
|
||||
dispatch(setFilterList(list))
|
||||
window.api.selection.setFilterList(list)
|
||||
},
|
||||
setActionWindowOpacity: (opacity: number) => {
|
||||
dispatch(setActionWindowOpacity(opacity))
|
||||
},
|
||||
setActionItems: (items: ActionItem[]) => {
|
||||
dispatch(setActionItems(items))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
setAutoCheckUpdate as _setAutoCheckUpdate,
|
||||
setLaunchOnBoot,
|
||||
setLaunchToTray,
|
||||
setPinTopicsToTop,
|
||||
setSendMessageShortcut as _setSendMessageShortcut,
|
||||
setShowTokens,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTheme,
|
||||
SettingsState,
|
||||
setTopicPosition,
|
||||
setPinTopicsToTop,
|
||||
setTray as _setTray,
|
||||
setTrayOnClose,
|
||||
setWindowStyle
|
||||
@@ -83,6 +84,9 @@ export function useSettings() {
|
||||
},
|
||||
setAssistantIconType(assistantIconType: AssistantIconType) {
|
||||
dispatch(setAssistantIconType(assistantIconType))
|
||||
},
|
||||
setShowTokens(showTokens: boolean) {
|
||||
dispatch(setShowTokens(showTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setShowAssistants, setShowTopics, toggleShowAssistants, toggleShowTopics } from '@renderer/store/settings'
|
||||
import {
|
||||
setAssistantsTabSortType,
|
||||
setShowAssistants,
|
||||
setShowTopics,
|
||||
toggleShowAssistants,
|
||||
toggleShowTopics
|
||||
} from '@renderer/store/settings'
|
||||
import { AssistantsSortType } from '@renderer/types'
|
||||
|
||||
export function useShowAssistants() {
|
||||
const showAssistants = useAppSelector((state) => state.settings.showAssistants)
|
||||
@@ -22,3 +29,13 @@ export function useShowTopics() {
|
||||
toggleShowTopics: () => dispatch(toggleShowTopics())
|
||||
}
|
||||
}
|
||||
|
||||
export function useAssistantsTabSortType() {
|
||||
const assistantsTabSortType = useAppSelector((state) => state.settings.assistantsTabSortType)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
assistantsTabSortType,
|
||||
setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType))
|
||||
}
|
||||
}
|
||||
|
||||
53
src/renderer/src/hooks/useTags.ts
Normal file
53
src/renderer/src/hooks/useTags.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { flatMap, groupBy, uniq } from 'lodash'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useAssistants } from './useAssistant'
|
||||
|
||||
// 定义useTags的返回类型,包含所有标签和获取特定标签的助手函数
|
||||
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
|
||||
// 但是为了方便管理,增加了一个获取特定标签的助手函数
|
||||
|
||||
export const useTags = () => {
|
||||
const { assistants } = useAssistants()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 计算所有标签
|
||||
const allTags = useMemo(() => {
|
||||
return uniq(flatMap(assistants, (assistant) => assistant.tags || []))
|
||||
}, [assistants])
|
||||
|
||||
const getAssistantsByTag = useCallback(
|
||||
(tag: string) => assistants.filter((assistant) => assistant.tags?.includes(tag)),
|
||||
[assistants]
|
||||
)
|
||||
|
||||
const getGroupedAssistants = useMemo(() => {
|
||||
// 按标签分组,处理多标签的情况
|
||||
const assistantsByTags = flatMap(assistants, (assistant) => {
|
||||
const tags = assistant.tags?.length ? assistant.tags : [t('assistants.tags.untagged')]
|
||||
return tags.map((tag) => ({ tag, assistant }))
|
||||
})
|
||||
|
||||
// 按标签分组并构建结果
|
||||
const grouped = Object.entries(groupBy(assistantsByTags, 'tag')).map(([tag, group]) => ({
|
||||
tag,
|
||||
assistants: group.map((g) => g.assistant)
|
||||
}))
|
||||
|
||||
// 将未标记的组移到最前面
|
||||
const untaggedIndex = grouped.findIndex((g) => g.tag === t('assistants.tags.untagged'))
|
||||
if (untaggedIndex > -1) {
|
||||
const [untagged] = grouped.splice(untaggedIndex, 1)
|
||||
grouped.unshift(untagged)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}, [assistants, t])
|
||||
|
||||
return {
|
||||
allTags,
|
||||
getAssistantsByTag,
|
||||
getGroupedAssistants
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
|
||||
const data = { ...topic, name: summaryText }
|
||||
_setActiveTopic(data)
|
||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
} else {
|
||||
window.message?.error(i18n.t('message.error.fetchTopicName'))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
29
src/renderer/src/hooks/useUserTheme.ts
Normal file
29
src/renderer/src/hooks/useUserTheme.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setUserTheme, UserTheme } from '@renderer/store/settings'
|
||||
import Color from 'color'
|
||||
|
||||
export default function useUserTheme() {
|
||||
const userTheme = useAppSelector((state) => state.settings.userTheme)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const initUserTheme = (theme: UserTheme = userTheme) => {
|
||||
const colorPrimary = Color(theme.colorPrimary)
|
||||
|
||||
document.body.style.setProperty('--color-primary', colorPrimary.toString())
|
||||
document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString())
|
||||
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())
|
||||
}
|
||||
|
||||
return {
|
||||
colorPrimary: Color(userTheme.colorPrimary),
|
||||
|
||||
initUserTheme,
|
||||
|
||||
setUserTheme(userTheme: UserTheme) {
|
||||
dispatch(setUserTheme(userTheme))
|
||||
|
||||
initUserTheme(userTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"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.title": "Create Agent",
|
||||
"import": {
|
||||
"title": "Import from External",
|
||||
@@ -101,6 +102,22 @@
|
||||
"titlePlaceholder": "Enter title",
|
||||
"contentLabel": "Content",
|
||||
"contentPlaceholder": "Please enter phrase content, support using variables, and press Tab to quickly locate the variable to modify. For example: \nHelp me plan a route from ${from} to ${to}, and send it to ${email}."
|
||||
},
|
||||
"list": {
|
||||
"showByList": "List View",
|
||||
"showByTags": "Tag View"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "Untagged",
|
||||
"none": "No tags",
|
||||
"manage": "Tag Management",
|
||||
"modify": "Modify Tag",
|
||||
"add": "Add Tag",
|
||||
"delete": "Delete Tag",
|
||||
"deleteConfirm": "Are you sure to delete this tag?",
|
||||
"settings": {
|
||||
"title": "Tag Settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -241,7 +258,7 @@
|
||||
"settings.show_line_numbers": "Show line numbers in code",
|
||||
"settings.temperature": "Temperature",
|
||||
"settings.temperature.tip": "Higher values make the model more creative and unpredictable, while lower values make it more deterministic and precise.",
|
||||
"settings.thought_auto_collapse": "Automatically Collapse Thought Content",
|
||||
"settings.thought_auto_collapse": "Collapse Thought Content",
|
||||
"settings.thought_auto_collapse.tip": "Automatically collapse thought content after thinking ends",
|
||||
"settings.top_p": "Top-P",
|
||||
"settings.top_p.tip": "Default value is 1, the smaller the value, the less variety in the answers, the easier to understand, the larger the value, the larger the range of the AI's vocabulary, the more diverse",
|
||||
@@ -305,6 +322,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...",
|
||||
@@ -569,7 +587,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.",
|
||||
@@ -610,6 +635,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",
|
||||
@@ -636,7 +662,7 @@
|
||||
"message.code_style": "Code style",
|
||||
"message.delete.content": "Are you sure you want to delete this message?",
|
||||
"message.delete.title": "Delete Message",
|
||||
"message.multi_model_style": "Multi-model response style",
|
||||
"message.multi_model_style": "Group style",
|
||||
"message.multi_model_style.fold": "Fold view",
|
||||
"message.multi_model_style.fold.compress": "Switch to compact layout",
|
||||
"message.multi_model_style.fold.expand": "Switch to expanded layout",
|
||||
@@ -820,14 +846,15 @@
|
||||
"seed_desc_tip": "The same seed and prompt can generate similar images, setting -1 will generate different results each time",
|
||||
"title": "Images",
|
||||
"magic_prompt_option": "Magic Prompt",
|
||||
"model": "Model Version",
|
||||
"model": "Model",
|
||||
"aspect_ratio": "Aspect Ratio",
|
||||
"style_type": "Style",
|
||||
"rendering_speed": "Rendering Speed",
|
||||
"learn_more": "Learn More",
|
||||
"paint_course":"tutorial",
|
||||
"paint_course": "tutorial",
|
||||
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
|
||||
"proxy_required": "Currently, you need to open a proxy to view the generated images, it will be supported in the future",
|
||||
"prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts",
|
||||
"proxy_required": "Open the proxy and enable “TUN mode” to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
|
||||
"image_file_required": "Please upload an image first",
|
||||
"image_file_retry": "Please re-upload an image first",
|
||||
"image_placeholder": "No image available",
|
||||
@@ -846,6 +873,34 @@
|
||||
"turbo": "Turbo",
|
||||
"quality": "Quality"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "Auto",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "Auto",
|
||||
"low": "Low"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "Auto",
|
||||
"transparent": "Transparent",
|
||||
"opaque": "Opaque"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "Square",
|
||||
"portrait": "Portrait",
|
||||
"landscape": "Landscape"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "Allow all",
|
||||
"allow_adult": "Allow adult",
|
||||
"allow_none": "Not allowed"
|
||||
},
|
||||
"quality": "Quality",
|
||||
"moderation": "Moderation",
|
||||
"background": "Background",
|
||||
"mode": {
|
||||
"generate": "Draw",
|
||||
"edit": "Edit",
|
||||
@@ -859,7 +914,9 @@
|
||||
"negative_prompt_tip": "Describe unwanted elements, only for V_1, V_1_TURBO, V_2, and V_2_TURBO",
|
||||
"magic_prompt_option_tip": "Intelligently enhances prompts for better results",
|
||||
"style_type_tip": "Image generation style for V_2 and above",
|
||||
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3"
|
||||
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3",
|
||||
"person_generation": "Generate person",
|
||||
"person_generation_tip": "Allow model to generate person images"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "Edited Image",
|
||||
@@ -892,7 +949,20 @@
|
||||
"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",
|
||||
"req_error_text": "Operation failed. Please try again. Avoid using 'copyrighted' or 'sensitive' words in your prompt.",
|
||||
"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",
|
||||
@@ -946,7 +1016,7 @@
|
||||
"zhinao": "360AI",
|
||||
"zhipu": "ZHIPU AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu",
|
||||
"qiniu": "Qiniu AI",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1045,7 +1115,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",
|
||||
@@ -1054,12 +1126,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",
|
||||
@@ -1073,10 +1147,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",
|
||||
@@ -1213,6 +1286,7 @@
|
||||
"display.sidebar.translate.icon": "Show Translate icon",
|
||||
"display.sidebar.visible": "Show icons",
|
||||
"display.title": "Display Settings",
|
||||
"display.zoom.title": "Zoom Settings",
|
||||
"display.topic.title": "Topic Settings",
|
||||
"miniapps": {
|
||||
"title": "Mini Apps Settings",
|
||||
@@ -1438,6 +1512,7 @@
|
||||
"advancedSettings": "Advanced Settings"
|
||||
},
|
||||
"messages.prompt": "Show prompt",
|
||||
"messages.tokens": "Show token usage",
|
||||
"messages.divider": "Show divider between messages",
|
||||
"messages.grid_columns": "Message grid display columns",
|
||||
"messages.grid_popover_trigger": "Grid detail trigger",
|
||||
@@ -1448,14 +1523,14 @@
|
||||
"messages.input.send_shortcuts": "Send shortcuts",
|
||||
"messages.input.show_estimated_tokens": "Show estimated tokens",
|
||||
"messages.input.title": "Input Settings",
|
||||
"messages.input.enable_quick_triggers": "Enable '/' and '@' triggers",
|
||||
"messages.input.enable_quick_triggers": "Enable / and @ triggers",
|
||||
"messages.input.enable_delete_model": "Enable the backspace key to delete models/attachments.",
|
||||
"messages.markdown_rendering_input_message": "Markdown render input message",
|
||||
"messages.math_engine": "Math engine",
|
||||
"messages.math_engine.none": "None",
|
||||
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
|
||||
"messages.model.title": "Model Settings",
|
||||
"messages.navigation": "Message Navigation",
|
||||
"messages.navigation": "Navigation bar",
|
||||
"messages.navigation.anchor": "Message Anchor",
|
||||
"messages.navigation.buttons": "Navigation Buttons",
|
||||
"messages.navigation.none": "None",
|
||||
@@ -1491,6 +1566,7 @@
|
||||
"models.check.start": "Start",
|
||||
"models.check.title": "Model health check",
|
||||
"models.check.use_all_keys": "Key(s)",
|
||||
"models.check.disclaimer": "Health check requires sending requests, please use it with caution. Models that charge per request may incur additional costs, please bear the responsibility.",
|
||||
"models.default_assistant_model": "Default Assistant Model",
|
||||
"models.default_assistant_model_description": "Model used when creating a new assistant, if the assistant is not set, this model will be used",
|
||||
"models.empty": "No models found",
|
||||
@@ -1638,10 +1714,11 @@
|
||||
"zoom_out": "Zoom Out",
|
||||
"zoom_reset": "Reset Zoom"
|
||||
},
|
||||
"theme.auto": "Auto",
|
||||
"theme.system": "System",
|
||||
"theme.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
"theme.title": "Theme",
|
||||
"theme.color_primary": "Primary Color",
|
||||
"theme.window.style.opaque": "Opaque Window",
|
||||
"theme.window.style.title": "Window Style",
|
||||
"theme.window.style.transparent": "Transparent Window",
|
||||
@@ -1739,10 +1816,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": "Detected Language",
|
||||
"empty": "Translation content is empty",
|
||||
"not.found": "Translation content not found",
|
||||
"confirm": {
|
||||
@@ -1761,8 +1841,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": {
|
||||
@@ -1779,6 +1867,174 @@
|
||||
"quit": "Quit",
|
||||
"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": {
|
||||
"builtin": {
|
||||
"translate": "Translate",
|
||||
"explain": "Explain",
|
||||
"summary": "Summarize",
|
||||
"search": "Search",
|
||||
"refine": "Refine",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Pin",
|
||||
"pinned": "Pinned",
|
||||
"opacity": "Window Opacity",
|
||||
"original_show": "Show Original",
|
||||
"original_hide": "Hide Original",
|
||||
"original_copy": "Copy Original",
|
||||
"esc_close": "Esc: Close",
|
||||
"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": {
|
||||
"experimental": "Experimental Features",
|
||||
"enable": {
|
||||
"title": "Enable",
|
||||
"description": "Currently only supported on Windows systems"
|
||||
},
|
||||
"toolbar": {
|
||||
"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.",
|
||||
"selected": "Selection",
|
||||
"ctrlkey": "Ctrl Key"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "Compact Mode",
|
||||
"description": "In compact mode, only icons are displayed without text"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "Action Window",
|
||||
"follow_toolbar": {
|
||||
"title": "Follow Toolbar",
|
||||
"description": "Window position will follow the toolbar. When disabled, it will always be centered."
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "Remember Size",
|
||||
"description": "Window will display at the last adjusted size during the application running"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "Auto Close",
|
||||
"description": "Automatically close the window when it's not pinned and loses focus"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "Auto Pin",
|
||||
"description": "Pin the window by default"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "Opacity",
|
||||
"description": "Set the default opacity of the window, 100% is fully opaque"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "Actions",
|
||||
"reset": {
|
||||
"button": "Reset",
|
||||
"tooltip": "Reset to default actions. Custom actions will not be deleted.",
|
||||
"confirm": "Are you sure you want to reset to default actions? Custom actions will not be deleted."
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "Add Custom Action",
|
||||
"disabled": "Maximum number of custom actions reached ({{max}})"
|
||||
},
|
||||
"delete_confirm": "Are you sure you want to delete this custom action?",
|
||||
"drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"filter_mode": {
|
||||
"title": "Application Filter",
|
||||
"description": "Can limit the selection assistant to only work in specific applications (whitelist) or not work (blacklist)",
|
||||
"default": "Off",
|
||||
"whitelist": "Whitelist",
|
||||
"blacklist": "Blacklist"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "Filter List",
|
||||
"description": "Advanced feature, recommended for users with experience"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "Add Custom Action",
|
||||
"edit": "Edit Custom Action"
|
||||
},
|
||||
"name": {
|
||||
"label": "Name",
|
||||
"hint": "Please enter action name"
|
||||
},
|
||||
"icon": {
|
||||
"label": "Icon",
|
||||
"placeholder": "Enter Lucide icon name",
|
||||
"error": "Invalid icon name, please check your input",
|
||||
"tooltip": "Lucide icon names are lowercase, e.g. arrow-right",
|
||||
"view_all": "View All Icons",
|
||||
"random": "Random Icon"
|
||||
},
|
||||
"model": {
|
||||
"label": "Model",
|
||||
"tooltip": "Using Assistant: Will use both the assistant's system prompt and model parameters",
|
||||
"default": "Default Model",
|
||||
"assistant": "Use Assistant"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "Select Assistant",
|
||||
"default": "Default"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "User Prompt",
|
||||
"tooltip": "User prompt serves as a supplement to user input and won't override the assistant's system prompt",
|
||||
"placeholder": "Use placeholder {{text}} to represent selected text. When empty, selected text will be appended to this prompt",
|
||||
"placeholder_text": "Placeholder",
|
||||
"copy_placeholder": "Copy Placeholder"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "Set Search Engine",
|
||||
"engine": {
|
||||
"label": "Search Engine",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "Custom Name",
|
||||
"hint": "Please enter search engine name",
|
||||
"max_length": "Name cannot exceed 16 characters"
|
||||
},
|
||||
"url": {
|
||||
"label": "Custom Search URL",
|
||||
"hint": "Use {{queryString}} to represent the search term",
|
||||
"required": "Please enter search URL",
|
||||
"invalid_format": "Please enter a valid URL starting with http:// or https://",
|
||||
"missing_placeholder": "URL must contain {{queryString}} placeholder"
|
||||
},
|
||||
"test": "Test"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "Application Filter List",
|
||||
"user_tips": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "名前を入力",
|
||||
"add.prompt": "プロンプト",
|
||||
"add.prompt.placeholder": "プロンプトを入力",
|
||||
"add.prompt.variables.tip": "利用可能な変数:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "エージェントを作成",
|
||||
"import": {
|
||||
"title": "外部からインポート",
|
||||
@@ -99,6 +100,22 @@
|
||||
"settings.knowledge_base.recognition": "ナレッジベースの呼び出し",
|
||||
"settings.knowledge_base.recognition.off": "強制検索",
|
||||
"settings.knowledge_base.recognition.on": "意図認識",
|
||||
"list": {
|
||||
"showByList": "リスト表示",
|
||||
"showByTags": "タグ表示"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "未分類",
|
||||
"none": "タグなし",
|
||||
"manage": "タグ管理",
|
||||
"add": "タグ追加",
|
||||
"modify": "タグ修正",
|
||||
"delete": "タグ削除",
|
||||
"deleteConfirm": "このタグを削除してもよろしいですか?",
|
||||
"settings": {
|
||||
"title": "タグ設定"
|
||||
}
|
||||
},
|
||||
"settings.tool_use_mode": "工具調用方式",
|
||||
"settings.tool_use_mode.function": "関数",
|
||||
"settings.tool_use_mode.prompt": "提示詞"
|
||||
@@ -305,6 +322,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": "翻訳中...",
|
||||
@@ -569,7 +587,14 @@
|
||||
"korean": "韓国語",
|
||||
"portuguese": "ポルトガル語",
|
||||
"russian": "ロシア語",
|
||||
"spanish": "スペイン語"
|
||||
"spanish": "スペイン語",
|
||||
"polish": "ポーランド語",
|
||||
"turkish": "トルコ語",
|
||||
"thai": "タイ語",
|
||||
"vietnamese": "ベトナム語",
|
||||
"indonesian": "インドネシア語",
|
||||
"urdu": "ウルドゥー語",
|
||||
"malay": "マレー語"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
@@ -610,6 +635,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キーです",
|
||||
@@ -820,13 +846,14 @@
|
||||
"seed_desc_tip": "同じシードとプロンプトで類似した画像を生成できますが、-1 に設定すると毎回異なる結果が生成されます",
|
||||
"title": "画像",
|
||||
"magic_prompt_option": "プロンプト強化",
|
||||
"model": "モデルバージョン",
|
||||
"model": "モデル",
|
||||
"aspect_ratio": "画幅比例",
|
||||
"style_type": "スタイル",
|
||||
"learn_more": "詳しくはこちら",
|
||||
"prompt_placeholder_edit": "画像の説明を入力します。テキスト描画には '二重引用符' を使用します",
|
||||
"paint_course":"チュートリアル",
|
||||
"proxy_required": "現在、プロキシを開く必要があります。これは、将来サポートされる予定です",
|
||||
"prompt_placeholder_en": "「英語」の説明を入力します。Imagenは現在、英語のプロンプト語のみをサポートしています",
|
||||
"paint_course": "チュートリアル",
|
||||
"proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連",
|
||||
"image_file_required": "画像を先にアップロードしてください",
|
||||
"image_file_retry": "画像を先にアップロードしてください",
|
||||
"image_placeholder": "画像がありません",
|
||||
@@ -844,6 +871,34 @@
|
||||
"turbo": "高速",
|
||||
"quality": "高品質"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "自動",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "自動",
|
||||
"low": "低"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "自動",
|
||||
"transparent": "透明",
|
||||
"opaque": "不透明"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "正方形",
|
||||
"portrait": "縦図",
|
||||
"landscape": "横図"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "許可する",
|
||||
"allow_adult": "許可する",
|
||||
"allow_none": "許可しない"
|
||||
},
|
||||
"quality": "品質",
|
||||
"moderation": "敏感度",
|
||||
"background": "背景",
|
||||
"mode": {
|
||||
"generate": "画像生成",
|
||||
"edit": "部分編集",
|
||||
@@ -857,7 +912,9 @@
|
||||
"negative_prompt_tip": "画像に含めたくない内容を説明します",
|
||||
"magic_prompt_option_tip": "生成効果を向上させるための提示詞を最適化します",
|
||||
"style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用",
|
||||
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です"
|
||||
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です",
|
||||
"person_generation": "人物生成",
|
||||
"person_generation_tip": "人物画像を生成する"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "編集画像",
|
||||
@@ -892,7 +949,20 @@
|
||||
},
|
||||
"rendering_speed": "レンダリング速度",
|
||||
"translating": "翻訳中...",
|
||||
"text_desc_required": "画像の説明を先に入力してください"
|
||||
"text_desc_required": "画像の説明を先に入力してください",
|
||||
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
|
||||
"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": "この概念を説明してください",
|
||||
@@ -946,7 +1016,7 @@
|
||||
"zhinao": "360智脳",
|
||||
"zhipu": "智譜AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "七牛云",
|
||||
"qiniu": "七牛云 AI 推理",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1043,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数式に$$を強制使用",
|
||||
@@ -1052,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": "自動バックアップ",
|
||||
@@ -1194,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",
|
||||
@@ -1211,6 +1284,7 @@
|
||||
"display.sidebar.translate.icon": "翻訳のアイコンを表示",
|
||||
"display.sidebar.visible": "アイコンを表示",
|
||||
"display.title": "表示設定",
|
||||
"display.zoom.title": "ズーム設定",
|
||||
"display.topic.title": "トピック設定",
|
||||
"miniapps": {
|
||||
"title": "ミニアプリ設定",
|
||||
@@ -1434,6 +1508,7 @@
|
||||
"advancedSettings": "詳細設定"
|
||||
},
|
||||
"messages.prompt": "プロンプト表示",
|
||||
"messages.tokens": "トークン使用量を表示",
|
||||
"messages.divider": "メッセージ間に区切り線を表示",
|
||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||
"messages.grid_popover_trigger": "グリッド詳細トリガー",
|
||||
@@ -1444,7 +1519,7 @@
|
||||
"messages.input.send_shortcuts": "送信ショートカット",
|
||||
"messages.input.show_estimated_tokens": "推定トークン数を表示",
|
||||
"messages.input.title": "入力設定",
|
||||
"messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。",
|
||||
"messages.input.enable_quick_triggers": "/ と @ を有効にしてクイックメニューを表示します。",
|
||||
"messages.input.enable_delete_model": "バックスペースキーでモデル/添付ファイルを削除します。",
|
||||
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
|
||||
"messages.math_engine": "数式エンジン",
|
||||
@@ -1487,6 +1562,7 @@
|
||||
"models.check.start": "開始",
|
||||
"models.check.title": "モデル健康チェック",
|
||||
"models.check.use_all_keys": "キー",
|
||||
"models.check.disclaimer": "健康チェックはリクエストを送信するため、費用が発生する可能性があります。慎重に使用してください。",
|
||||
"models.default_assistant_model": "デフォルトアシスタントモデル",
|
||||
"models.default_assistant_model_description": "新しいアシスタントを作成する際に使用されるモデル。アシスタントがモデルを設定していない場合、このモデルが使用されます",
|
||||
"models.empty": "モデルが見つかりません",
|
||||
@@ -1628,10 +1704,11 @@
|
||||
"zoom_out": "ズームアウト",
|
||||
"zoom_reset": "ズームをリセット"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.system": "システム",
|
||||
"theme.dark": "ダーク",
|
||||
"theme.light": "ライト",
|
||||
"theme.title": "テーマ",
|
||||
"theme.color_primary": "テーマ色",
|
||||
"theme.window.style.opaque": "不透明ウィンドウ",
|
||||
"theme.window.style.title": "ウィンドウスタイル",
|
||||
"theme.window.style.transparent": "透明ウィンドウ",
|
||||
@@ -1739,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意の言語",
|
||||
"target_language": "目標言語",
|
||||
"alter_language": "備用言語",
|
||||
"button.translate": "翻訳",
|
||||
"close": "閉じる",
|
||||
"closed": "翻訳は閉じられました",
|
||||
@@ -1761,13 +1840,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": "終了",
|
||||
@@ -1779,6 +1867,174 @@
|
||||
"quit": "終了",
|
||||
"show_window": "ウィンドウを表示",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新",
|
||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||
"later": "後で",
|
||||
"install": "今すぐインストール",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "テキスト選択ツール",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "翻訳",
|
||||
"explain": "解説",
|
||||
"summary": "要約",
|
||||
"search": "検索",
|
||||
"refine": "最適化",
|
||||
"copy": "コピー"
|
||||
},
|
||||
"window": {
|
||||
"pin": "最前面に固定",
|
||||
"pinned": "固定中",
|
||||
"opacity": "ウィンドウの透過度",
|
||||
"original_show": "原文を表示",
|
||||
"original_hide": "原文を非表示",
|
||||
"original_copy": "原文をコピー",
|
||||
"esc_close": "Escで閉じる",
|
||||
"esc_stop": "Escで停止",
|
||||
"c_copy": "Cでコピー",
|
||||
"r_regenerate": "Rで再生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "実験的機能",
|
||||
"enable": {
|
||||
"title": "有効化",
|
||||
"description": "現在Windowsのみ対応"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "ツールバー",
|
||||
"trigger_mode": {
|
||||
"title": "表示方法",
|
||||
"description": "テキスト選択時に即時表示、またはCtrlキー押下時のみ表示",
|
||||
"description_note": "一部のアプリはCtrlキーでのテキスト選択に対応していません。AHKなどでCtrlキーをリマップすると、選択できなくなる場合があります。",
|
||||
"selected": "選択時",
|
||||
"ctrlkey": "Ctrlキー"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "コンパクトモード",
|
||||
"description": "アイコンのみ表示(テキスト非表示)"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "機能ウィンドウ",
|
||||
"follow_toolbar": {
|
||||
"title": "ツールバーに追従",
|
||||
"description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)"
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "サイズを記憶",
|
||||
"description": "アプリケーション実行中、ウィンドウは最後に調整されたサイズで表示されます"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自動閉じる",
|
||||
"description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "自動で最前面に固定",
|
||||
"description": "デフォルトで最前面表示"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "透明度",
|
||||
"description": "デフォルトの透明度を設定(100%は完全不透明)"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "機能設定",
|
||||
"reset": {
|
||||
"button": "リセット",
|
||||
"tooltip": "デフォルト機能にリセット(カスタム機能は保持)",
|
||||
"confirm": "デフォルト機能にリセットしますか?\nカスタム機能は削除されません"
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "カスタム機能を追加",
|
||||
"disabled": "カスタム機能の上限に達しました (最大{{max}}個)"
|
||||
},
|
||||
"delete_confirm": "このカスタム機能を削除しますか?",
|
||||
"drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "進階",
|
||||
"filter_mode": {
|
||||
"title": "アプリケーションフィルター",
|
||||
"description": "特定のアプリケーションでのみ選択ツールを有効にするか、無効にするかを選択できます。",
|
||||
"default": "オフ",
|
||||
"whitelist": "ホワイトリスト",
|
||||
"blacklist": "ブラックリスト"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "フィルターリスト",
|
||||
"description": "進階機能です。経験豊富なユーザー向けです。"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "カスタム機能追加",
|
||||
"edit": "カスタム機能編集"
|
||||
},
|
||||
"name": {
|
||||
"label": "機能名",
|
||||
"hint": "機能名を入力"
|
||||
},
|
||||
"icon": {
|
||||
"label": "アイコン",
|
||||
"placeholder": "Lucideアイコン名を入力",
|
||||
"error": "無効なアイコン名です",
|
||||
"tooltip": "例: arrow-right(小文字で入力)",
|
||||
"view_all": "全アイコンを表示",
|
||||
"random": "ランダム選択"
|
||||
},
|
||||
"model": {
|
||||
"label": "モデル",
|
||||
"tooltip": "アシスタント使用時はシステムプロンプトとモデルパラメータも適用",
|
||||
"default": "デフォルトモデル",
|
||||
"assistant": "アシスタントを使用"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "アシスタント選択",
|
||||
"default": "デフォルト"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "ユーザープロンプト",
|
||||
"tooltip": "アシスタントのシステムプロンプトを上書きせず、入力補助として機能",
|
||||
"placeholder": "{{text}}で選択テキストを参照(未入力時は末尾に追加)",
|
||||
"placeholder_text": "プレースホルダー",
|
||||
"copy_placeholder": "プレースホルダーをコピー"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "検索エンジン設定",
|
||||
"engine": {
|
||||
"label": "検索エンジン",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "表示名",
|
||||
"hint": "検索エンジン名(16文字以内)",
|
||||
"max_length": "16文字以内で入力"
|
||||
},
|
||||
"url": {
|
||||
"label": "検索URL",
|
||||
"hint": "{{queryString}}で検索語を表す",
|
||||
"required": "URLを入力してください",
|
||||
"invalid_format": "http:// または https:// で始まるURLを入力",
|
||||
"missing_placeholder": "{{queryString}}を含めてください"
|
||||
},
|
||||
"test": "テスト"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "アプリケーションフィルターリスト",
|
||||
"user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "Введите имя",
|
||||
"add.prompt": "Промпт",
|
||||
"add.prompt.placeholder": "Введите промпт",
|
||||
"add.prompt.variables.tip": "Доступные переменные: {{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "Создать агента",
|
||||
"delete.popup.content": "Вы уверены, что хотите удалить этого агента?",
|
||||
"edit.message.add.title": "Добавить",
|
||||
@@ -101,6 +102,22 @@
|
||||
"titlePlaceholder": "Введите заголовок",
|
||||
"contentLabel": "Содержание",
|
||||
"contentPlaceholder": "Введите содержание фразы, поддерживает использование переменных, и нажмите Tab для быстрого перехода к переменной для изменения. Например: \nПомоги мне спланировать маршрут от ${from} до ${to} и отправить его на ${email}."
|
||||
},
|
||||
"list": {
|
||||
"showByList": "Список",
|
||||
"showByTags": "По тегам"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "Несгруппированные метки",
|
||||
"none": "Нет тегов",
|
||||
"manage": "Управление тегами",
|
||||
"add": "Добавить тег",
|
||||
"modify": "Изменить тег",
|
||||
"delete": "Удалить тег",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить этот тег?",
|
||||
"settings": {
|
||||
"title": "Настройки тегов"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -305,6 +322,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": "Перевод...",
|
||||
@@ -569,7 +587,14 @@
|
||||
"korean": "Корейский",
|
||||
"portuguese": "Португальский",
|
||||
"russian": "Русский",
|
||||
"spanish": "Испанский"
|
||||
"spanish": "Испанский",
|
||||
"polish": "Польский",
|
||||
"turkish": "Туркменский",
|
||||
"thai": "Тайский",
|
||||
"vietnamese": "Вьетнамский",
|
||||
"indonesian": "Индонезийский",
|
||||
"urdu": "Урду",
|
||||
"malay": "Малайзийский"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
@@ -610,6 +635,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 ключ",
|
||||
@@ -820,14 +846,15 @@
|
||||
"seed_desc_tip": "Одинаковые сиды и промпты могут генерировать похожие изображения, установка -1 будет создавать разные результаты каждый раз",
|
||||
"title": "Изображения",
|
||||
"magic_prompt_option": "Улучшение промпта",
|
||||
"model": "Версия",
|
||||
"model": "Модель",
|
||||
"aspect_ratio": "Пропорции изображения",
|
||||
"style_type": "Стиль",
|
||||
"rendering_speed": "Скорость рендеринга",
|
||||
"learn_more": "Узнать больше",
|
||||
"prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
|
||||
"paint_course":"Руководство / Учебник",
|
||||
"proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",
|
||||
"prompt_placeholder_en": "Введите” английский “описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
|
||||
"paint_course": "Руководство / Учебник",
|
||||
"proxy_required": "Открыть прокси и включить “TUN режим” для просмотра сгенерированных изображений или скопировать их в браузер для открытия. В будущем будет поддерживаться прямое соединение",
|
||||
"image_file_required": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_file_retry": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_placeholder": "Изображение недоступно",
|
||||
@@ -841,11 +868,39 @@
|
||||
"3d": "3D",
|
||||
"anime": "Аниме"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "Авто",
|
||||
"low": "Низкое",
|
||||
"medium": "Среднее",
|
||||
"high": "Высокое"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "Авто",
|
||||
"low": "Низкое"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "Авто",
|
||||
"transparent": "Прозрачный",
|
||||
"opaque": "Непрозрачный"
|
||||
},
|
||||
"rendering_speeds": {
|
||||
"default": "По умолчанию",
|
||||
"turbo": "Быстро",
|
||||
"quality": "Качественно"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "Квадрат",
|
||||
"portrait": "Портрет",
|
||||
"landscape": "Пейзаж"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "Разрешено все",
|
||||
"allow_adult": "Разрешено взрослые",
|
||||
"allow_none": "Не разрешено"
|
||||
},
|
||||
"quality": "Качество",
|
||||
"moderation": "Сенсорность",
|
||||
"background": "Фон",
|
||||
"mode": {
|
||||
"generate": "Рисование",
|
||||
"edit": "Редактирование",
|
||||
@@ -859,7 +914,9 @@
|
||||
"negative_prompt_tip": "Описывает, что вы не хотите видеть в изображении",
|
||||
"magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта генерации",
|
||||
"style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше",
|
||||
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3"
|
||||
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3",
|
||||
"person_generation": "Генерация персонажа",
|
||||
"person_generation_tip": "Разрешить модель генерировать изображения людей"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "Изображение для редактирования",
|
||||
@@ -892,8 +949,20 @@
|
||||
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов",
|
||||
"magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов"
|
||||
},
|
||||
"rendering_speed": "Скорость рендеринга",
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения"
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения",
|
||||
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
|
||||
"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": "Объясните мне этот концепт",
|
||||
@@ -947,7 +1016,7 @@
|
||||
"zhinao": "360AI",
|
||||
"zhipu": "ZHIPU AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu",
|
||||
"qiniu": "Qiniu AI",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1044,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",
|
||||
@@ -1053,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",
|
||||
@@ -1072,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": "Автоматическое резервное копирование",
|
||||
@@ -1212,6 +1284,7 @@
|
||||
"display.sidebar.translate.icon": "Показывать иконку перевода",
|
||||
"display.sidebar.visible": "Показывать иконки",
|
||||
"display.title": "Настройки отображения",
|
||||
"display.zoom.title": "Настройки масштаба",
|
||||
"display.topic.title": "Настройки топиков",
|
||||
"miniapps": {
|
||||
"title": "Настройки мини-приложений",
|
||||
@@ -1435,6 +1508,7 @@
|
||||
"advancedSettings": "Расширенные настройки"
|
||||
},
|
||||
"messages.prompt": "Показывать подсказки",
|
||||
"messages.tokens": "Показать использование токенов",
|
||||
"messages.divider": "Показывать разделитель между сообщениями",
|
||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
|
||||
@@ -1445,7 +1519,7 @@
|
||||
"messages.input.send_shortcuts": "Горячие клавиши для отправки",
|
||||
"messages.input.show_estimated_tokens": "Показывать затраты токенов",
|
||||
"messages.input.title": "Настройки ввода",
|
||||
"messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.",
|
||||
"messages.input.enable_quick_triggers": "Включите / и @, чтобы вызвать быстрое меню.",
|
||||
"messages.input.enable_delete_model": "Включите удаление модели/вложения с помощью клавиши Backspace",
|
||||
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
|
||||
"messages.math_engine": "Математический движок",
|
||||
@@ -1488,6 +1562,7 @@
|
||||
"models.check.start": "Начать",
|
||||
"models.check.title": "Проверка состояния моделей",
|
||||
"models.check.use_all_keys": "Использовать все ключи",
|
||||
"models.check.disclaimer": "Проверка состояния моделей требует отправки запросов, пожалуйста, используйте эту функцию с осторожностью. Модели, которые взимают плату за запросы, могут привести к дополнительным расходам, пожалуйста, самостоятельно несем ответственность за них.",
|
||||
"models.default_assistant_model": "Модель ассистента по умолчанию",
|
||||
"models.default_assistant_model_description": "Модель, используемая при создании нового ассистента, если ассистент не имеет настроенной модели, будет использоваться эта модель",
|
||||
"models.empty": "Модели не найдены",
|
||||
@@ -1629,10 +1704,11 @@
|
||||
"zoom_out": "Уменьшить",
|
||||
"zoom_reset": "Сбросить масштаб"
|
||||
},
|
||||
"theme.auto": "Автоматически",
|
||||
"theme.system": "Системная",
|
||||
"theme.dark": "Темная",
|
||||
"theme.light": "Светлая",
|
||||
"theme.title": "Тема",
|
||||
"theme.color_primary": "Цвет темы",
|
||||
"theme.window.style.opaque": "Непрозрачное окно",
|
||||
"theme.window.style.title": "Стиль окна",
|
||||
"theme.window.style.transparent": "Прозрачное окно",
|
||||
@@ -1740,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "Любой язык",
|
||||
"target_language": "Целевой язык",
|
||||
"alter_language": "Альтернативный язык",
|
||||
"button.translate": "Перевести",
|
||||
"close": "Закрыть",
|
||||
"closed": "Перевод закрыт",
|
||||
@@ -1762,13 +1840,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": "Выйти",
|
||||
@@ -1780,6 +1867,174 @@
|
||||
"quit": "Выйти",
|
||||
"show_window": "Показать окно",
|
||||
"visualization": "Визуализация"
|
||||
},
|
||||
"update": {
|
||||
"title": "Обновление",
|
||||
"message": "Новая версия {{version}} готова, установить сейчас?",
|
||||
"later": "Позже",
|
||||
"install": "Установить",
|
||||
"noReleaseNotes": "Нет заметок об обновлении"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Помощник выбора",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "Перевести",
|
||||
"explain": "Объяснить",
|
||||
"summary": "Суммаризировать",
|
||||
"search": "Поиск",
|
||||
"refine": "Уточнить",
|
||||
"copy": "Копировать"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Закрепить",
|
||||
"pinned": "Закреплено",
|
||||
"opacity": "Прозрачность окна",
|
||||
"original_show": "Показать оригинал",
|
||||
"original_hide": "Скрыть оригинал",
|
||||
"original_copy": "Копировать оригинал",
|
||||
"esc_close": "Esc - закрыть",
|
||||
"esc_stop": "Esc - остановить",
|
||||
"c_copy": "C - копировать",
|
||||
"r_regenerate": "R - перегенерировать"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "Экспериментальные функции",
|
||||
"enable": {
|
||||
"title": "Включить",
|
||||
"description": "Поддерживается только в Windows"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "Панель инструментов",
|
||||
"trigger_mode": {
|
||||
"title": "Режим активации",
|
||||
"description": "Показывать панель сразу при выделении или только при удержании Ctrl.",
|
||||
"description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
|
||||
"selected": "При выделении",
|
||||
"ctrlkey": "По Ctrl"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "Компактный режим",
|
||||
"description": "Отображать только иконки без текста"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "Окно действий",
|
||||
"follow_toolbar": {
|
||||
"title": "Следовать за панелью",
|
||||
"description": "Окно будет следовать за панелью. Иначе - по центру."
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "Запомнить размер",
|
||||
"description": "При отключенном режиме, окно будет восстанавливаться до последнего размера при запуске приложения"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "Автозакрытие",
|
||||
"description": "Закрывать окно при потере фокуса (если не закреплено)"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "Автозакрепление",
|
||||
"description": "Закреплять окно по умолчанию"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "Прозрачность",
|
||||
"description": "Установить прозрачность окна по умолчанию"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "Действия",
|
||||
"reset": {
|
||||
"button": "Сбросить",
|
||||
"tooltip": "Сбросить стандартные действия. Пользовательские останутся.",
|
||||
"confirm": "Сбросить стандартные действия? Пользовательские останутся."
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "Добавить действие",
|
||||
"disabled": "Достигнут лимит ({{max}})"
|
||||
},
|
||||
"delete_confirm": "Удалить это действие?",
|
||||
"drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Расширенные",
|
||||
"filter_mode": {
|
||||
"title": "Режим фильтрации",
|
||||
"description": "Можно ограничить выборку по определенным приложениям (белый список) или исключить их (черный список)",
|
||||
"default": "Выключено",
|
||||
"whitelist": "Белый список",
|
||||
"blacklist": "Черный список"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "Список фильтрации",
|
||||
"description": "Расширенная функция, рекомендуется для пользователей с опытом"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "Добавить действие",
|
||||
"edit": "Редактировать действие"
|
||||
},
|
||||
"name": {
|
||||
"label": "Название",
|
||||
"hint": "Введите название"
|
||||
},
|
||||
"icon": {
|
||||
"label": "Иконка",
|
||||
"placeholder": "Название иконки Lucide",
|
||||
"error": "Некорректное название",
|
||||
"tooltip": "Названия в lowercase, например arrow-right",
|
||||
"view_all": "Все иконки",
|
||||
"random": "Случайная"
|
||||
},
|
||||
"model": {
|
||||
"label": "Модель",
|
||||
"tooltip": "Использовать ассистента: будут применены его системные настройки",
|
||||
"default": "По умолчанию",
|
||||
"assistant": "Ассистент"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "Ассистент",
|
||||
"default": "По умолчанию"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "Промпт",
|
||||
"tooltip": "Дополняет ввод пользователя, не заменяя системный промпт ассистента",
|
||||
"placeholder": "Используйте {{text}} для выделенного текста. Если пусто - текст будет добавлен",
|
||||
"placeholder_text": "Плейсхолдер",
|
||||
"copy_placeholder": "Копировать плейсхолдер"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "Поисковая система",
|
||||
"engine": {
|
||||
"label": "Поисковик",
|
||||
"custom": "Свой"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "Название",
|
||||
"hint": "Название поисковика",
|
||||
"max_length": "Не более 16 символов"
|
||||
},
|
||||
"url": {
|
||||
"label": "URL поиска",
|
||||
"hint": "Используйте {{queryString}} для представления поискового запроса",
|
||||
"required": "Введите URL",
|
||||
"invalid_format": "URL должен начинаться с http:// или https://",
|
||||
"missing_placeholder": "Должен содержать {{queryString}}"
|
||||
},
|
||||
"test": "Тест"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "Список фильтрации",
|
||||
"user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "输入名称",
|
||||
"add.prompt": "提示词",
|
||||
"add.prompt.placeholder": "输入提示词",
|
||||
"add.prompt.variables.tip": "可用的变量:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "创建智能体",
|
||||
"import": {
|
||||
"title": "从外部导入",
|
||||
@@ -101,6 +102,22 @@
|
||||
"titlePlaceholder": "输入标题",
|
||||
"contentLabel": "内容",
|
||||
"contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}"
|
||||
},
|
||||
"list": {
|
||||
"showByList": "列表展示",
|
||||
"showByTags": "标签展示"
|
||||
},
|
||||
"tags": {
|
||||
"none": "暂无标签",
|
||||
"manage": "标签管理",
|
||||
"add": "添加标签",
|
||||
"untagged": "未分组",
|
||||
"modify": "修改标签",
|
||||
"delete": "删除标签",
|
||||
"deleteConfirm": "确定要删除这个标签吗?",
|
||||
"settings": {
|
||||
"title": "标签设置"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -308,6 +325,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": "导出到语雀",
|
||||
@@ -569,7 +587,14 @@
|
||||
"korean": "韩文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"polish": "波兰文",
|
||||
"turkish": "土耳其文",
|
||||
"thai": "泰文",
|
||||
"vietnamese": "越南文",
|
||||
"indonesian": "印尼文",
|
||||
"urdu": "乌尔都文",
|
||||
"malay": "马来文"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
||||
@@ -610,6 +635,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 密钥",
|
||||
@@ -629,7 +655,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": "切换模型回答",
|
||||
@@ -820,14 +846,15 @@
|
||||
"seed_desc_tip": "相同的种子和提示词可以生成相似的图片,设置 -1 每次生成都不一样",
|
||||
"title": "图片",
|
||||
"magic_prompt_option": "提示词增强",
|
||||
"model": "版本",
|
||||
"model": "模型",
|
||||
"aspect_ratio": "画幅比例",
|
||||
"style_type": "风格",
|
||||
"rendering_speed": "渲染速度",
|
||||
"learn_more": "了解更多",
|
||||
"paint_course":"教程",
|
||||
"paint_course": "教程",
|
||||
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
|
||||
"proxy_required": "目前需要打开代理才能查看生成图片,后续会支持国内直连",
|
||||
"prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词",
|
||||
"proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开,后续会支持国内直连",
|
||||
"image_file_required": "请先上传图片",
|
||||
"image_file_retry": "请重新上传图片",
|
||||
"image_placeholder": "暂无图片",
|
||||
@@ -846,11 +873,39 @@
|
||||
"turbo": "快速",
|
||||
"quality": "高质量"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "自动",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "自动",
|
||||
"low": "低"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "自动",
|
||||
"transparent": "透明",
|
||||
"opaque": "不透明"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "允许所有",
|
||||
"allow_adult": "允许成人",
|
||||
"allow_none": "不允许"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "方形",
|
||||
"portrait": "竖图",
|
||||
"landscape": "横图"
|
||||
},
|
||||
"quality": "质量",
|
||||
"moderation": "敏感度",
|
||||
"background": "背景",
|
||||
"mode": {
|
||||
"generate": "绘图",
|
||||
"edit": "编辑",
|
||||
"remix": "混合",
|
||||
"upscale": "放大"
|
||||
"upscale": "高清增强"
|
||||
},
|
||||
"generate": {
|
||||
"model_tip": "模型版本:V3 为最新版本,V2 为之前版本,V2A 为快速模型、V_1 为初代模型,_TURBO 为加速版本",
|
||||
@@ -859,7 +914,9 @@
|
||||
"negative_prompt_tip": "描述不想在图像中出现的元素,仅支持 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本",
|
||||
"magic_prompt_option_tip": "智能优化提示词以提升生成效果",
|
||||
"style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本",
|
||||
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本"
|
||||
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本",
|
||||
"person_generation": "生成人物",
|
||||
"person_generation_tip": "允许模型生成人物图像"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "编辑的图像",
|
||||
@@ -892,7 +949,20 @@
|
||||
"seed_tip": "控制放大结果的随机性",
|
||||
"magic_prompt_option_tip": "智能优化放大提示词"
|
||||
},
|
||||
"text_desc_required": "请先输入图片描述"
|
||||
"text_desc_required": "请先输入图片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"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": "帮我解释一下这个概念",
|
||||
@@ -946,7 +1016,7 @@
|
||||
"zhinao": "360智脑",
|
||||
"zhipu": "智谱AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "七牛云",
|
||||
"qiniu": "七牛云 AI 推理",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1045,7 +1115,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公式",
|
||||
@@ -1054,14 +1126,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",
|
||||
@@ -1075,10 +1149,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": "自动备份",
|
||||
@@ -1213,6 +1286,7 @@
|
||||
"display.sidebar.translate.icon": "显示翻译图标",
|
||||
"display.sidebar.visible": "显示的图标",
|
||||
"display.title": "显示设置",
|
||||
"display.zoom.title": "缩放设置",
|
||||
"display.topic.title": "话题设置",
|
||||
"miniapps": {
|
||||
"title": "小程序设置",
|
||||
@@ -1438,6 +1512,7 @@
|
||||
"advancedSettings": "高级设置"
|
||||
},
|
||||
"messages.prompt": "显示提示词",
|
||||
"messages.tokens": "显示Token用量",
|
||||
"messages.divider": "消息分割线",
|
||||
"messages.grid_columns": "消息网格展示列数",
|
||||
"messages.grid_popover_trigger": "网格详情触发",
|
||||
@@ -1448,7 +1523,7 @@
|
||||
"messages.input.send_shortcuts": "发送快捷键",
|
||||
"messages.input.show_estimated_tokens": "显示预估 Token 数",
|
||||
"messages.input.title": "输入设置",
|
||||
"messages.input.enable_quick_triggers": "启用 '/' 和 '@' 触发快捷菜单",
|
||||
"messages.input.enable_quick_triggers": "启用 / 和 @ 触发快捷菜单",
|
||||
"messages.input.enable_delete_model": "启用删除键删除输入的模型/附件",
|
||||
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
|
||||
"messages.math_engine": "数学公式引擎",
|
||||
@@ -1491,6 +1566,7 @@
|
||||
"models.check.start": "开始",
|
||||
"models.check.title": "模型健康检测",
|
||||
"models.check.use_all_keys": "使用密钥",
|
||||
"models.check.disclaimer": "健康检查需要发送请求,请谨慎使用。按次收费的模型可能产生更多费用,请自行承担。",
|
||||
"models.default_assistant_model": "默认助手模型",
|
||||
"models.default_assistant_model_description": "创建新助手时使用的模型,如果助手未设置模型,则使用此模型",
|
||||
"models.empty": "没有模型",
|
||||
@@ -1638,10 +1714,11 @@
|
||||
"zoom_out": "缩小界面",
|
||||
"zoom_reset": "重置缩放"
|
||||
},
|
||||
"theme.auto": "自动",
|
||||
"theme.system": "系统",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "浅色",
|
||||
"theme.title": "主题",
|
||||
"theme.color_primary": "主题颜色",
|
||||
"theme.window.style.opaque": "不透明窗口",
|
||||
"theme.window.style.title": "窗口样式",
|
||||
"theme.window.style.transparent": "透明窗口",
|
||||
@@ -1739,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意语言",
|
||||
"target_language": "目标语言",
|
||||
"alter_language": "备用语言",
|
||||
"button.translate": "翻译",
|
||||
"close": "关闭",
|
||||
"closed": "翻译已关闭",
|
||||
@@ -1764,10 +1843,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": "退出",
|
||||
@@ -1779,6 +1867,174 @@
|
||||
"quit": "退出",
|
||||
"show_window": "显示窗口",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "发现新版本 {{version}},是否立即安装?",
|
||||
"later": "稍后",
|
||||
"install": "立即安装",
|
||||
"noReleaseNotes": "暂无更新日志"
|
||||
},
|
||||
"selection": {
|
||||
"name": "划词助手",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "翻译",
|
||||
"explain": "解释",
|
||||
"summary": "总结",
|
||||
"search": "搜索",
|
||||
"refine": "优化",
|
||||
"copy": "复制"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置顶",
|
||||
"pinned": "已置顶",
|
||||
"opacity": "窗口透明度",
|
||||
"original_show": "显示原文",
|
||||
"original_hide": "隐藏原文",
|
||||
"original_copy": "复制原文",
|
||||
"esc_close": "Esc 关闭",
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 复制",
|
||||
"r_regenerate": "R 重新生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "实验性功能",
|
||||
"enable": {
|
||||
"title": "启用",
|
||||
"description": "当前仅支持 Windows 系统"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "工具栏",
|
||||
"trigger_mode": {
|
||||
"title": "触发方式",
|
||||
"description": "划词立即显示工具栏,或者划词后按住 Ctrl 键才显示工具栏。",
|
||||
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
|
||||
"selected": "划词",
|
||||
"ctrlkey": "Ctrl 键"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "紧凑模式",
|
||||
"description": "紧凑模式下,只显示图标,不显示文字"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "功能窗口",
|
||||
"follow_toolbar": {
|
||||
"title": "跟随工具栏",
|
||||
"description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示"
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "记住大小",
|
||||
"description": "应用运行期间,窗口会按上次调整的大小显示"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自动关闭",
|
||||
"description": "当窗口未置顶且失去焦点时,将自动关闭该窗口"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "自动置顶",
|
||||
"description": "默认将窗口置于顶部"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "透明度",
|
||||
"description": "设置窗口的默认透明度,100%为完全不透明"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "功能",
|
||||
"reset": {
|
||||
"button": "重置",
|
||||
"tooltip": "重置为默认功能,自定义功能不会被删除",
|
||||
"confirm": "确定要重置为默认功能吗?自定义功能不会被删除。"
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "添加自定义功能",
|
||||
"disabled": "自定义功能已达上限 ({{max}}个)"
|
||||
},
|
||||
"delete_confirm": "确定要删除这个自定义功能吗?",
|
||||
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级",
|
||||
"filter_mode": {
|
||||
"title": "应用筛选",
|
||||
"description": "可以限制划词助手只在特定应用中生效(白名单)或不生效(黑名单)",
|
||||
"default": "关闭",
|
||||
"whitelist": "白名单",
|
||||
"blacklist": "黑名单"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "筛选名单",
|
||||
"description": "高级功能,建议有经验的用户在了解的情况下再进行设置"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "添加自定义功能",
|
||||
"edit": "编辑自定义功能"
|
||||
},
|
||||
"name": {
|
||||
"label": "名称",
|
||||
"hint": "请输入功能名称"
|
||||
},
|
||||
"icon": {
|
||||
"label": "图标",
|
||||
"placeholder": "输入 Lucide 图标名称",
|
||||
"error": "无效的图标名称,请检查输入",
|
||||
"tooltip": "Lucide图标名称为小写,如 arrow-right",
|
||||
"view_all": "查看所有图标",
|
||||
"random": "随机图标"
|
||||
},
|
||||
"model": {
|
||||
"label": "模型",
|
||||
"tooltip": "使用助手:会同时使用助手的系统提示词和模型参数",
|
||||
"default": "默认模型",
|
||||
"assistant": "使用助手"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "选择助手",
|
||||
"default": "默认"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "用户提示词(Prompt)",
|
||||
"tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词",
|
||||
"placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾",
|
||||
"placeholder_text": "占位符",
|
||||
"copy_placeholder": "复制占位符"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "设置搜索引擎",
|
||||
"engine": {
|
||||
"label": "搜索引擎",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "自定义名称",
|
||||
"hint": "请输入搜索引擎名称",
|
||||
"max_length": "名称不能超过16个字符"
|
||||
},
|
||||
"url": {
|
||||
"label": "自定义搜索 URL",
|
||||
"hint": "用 {{queryString}} 代表搜索词",
|
||||
"required": "请输入搜索 URL",
|
||||
"invalid_format": "请输入以 http:// 或 https:// 开头的有效 URL",
|
||||
"missing_placeholder": "URL 必须包含 {{queryString}} 占位符"
|
||||
},
|
||||
"test": "测试"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "应用筛选名单",
|
||||
"user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "輸入名稱",
|
||||
"add.prompt": "提示詞",
|
||||
"add.prompt.placeholder": "輸入提示詞",
|
||||
"add.prompt.variables.tip": "可用的變數:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "建立智慧代理人",
|
||||
"import": {
|
||||
"title": "從外部導入",
|
||||
@@ -99,6 +100,22 @@
|
||||
"settings.knowledge_base.recognition": "調用知識庫",
|
||||
"settings.knowledge_base.recognition.off": "強制檢索",
|
||||
"settings.knowledge_base.recognition.on": "意圖識別",
|
||||
"list": {
|
||||
"showByList": "列表展示",
|
||||
"showByTags": "標籤展示"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "未分組",
|
||||
"none": "暫無標籤",
|
||||
"manage": "標籤管理",
|
||||
"add": "添加標籤",
|
||||
"modify": "修改標籤",
|
||||
"delete": "刪除標籤",
|
||||
"deleteConfirm": "確定要刪除這個標籤嗎?",
|
||||
"settings": {
|
||||
"title": "標籤設定"
|
||||
}
|
||||
},
|
||||
"settings.tool_use_mode": "工具調用方式",
|
||||
"settings.tool_use_mode.function": "函數",
|
||||
"settings.tool_use_mode.prompt": "提示詞"
|
||||
@@ -305,6 +322,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": "翻譯中...",
|
||||
@@ -569,7 +587,14 @@
|
||||
"korean": "韓文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"polish": "波蘭文",
|
||||
"turkish": "土耳其文",
|
||||
"thai": "泰文",
|
||||
"vietnamese": "越南文",
|
||||
"indonesian": "印尼文",
|
||||
"urdu": "烏爾都文",
|
||||
"malay": "馬來文"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||
@@ -598,12 +623,11 @@
|
||||
"citations": "引用內容",
|
||||
"copied": "已複製!",
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.success": "已複製!",
|
||||
"copy.success": "複製成功",
|
||||
"delete.confirm.title": "刪除確認",
|
||||
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
||||
"delete.failed": "刪除失敗",
|
||||
"delete.success": "刪除成功",
|
||||
"copy.success": "複製成功",
|
||||
"empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙",
|
||||
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
|
||||
"error.dimension_too_large": "內容尺寸過大",
|
||||
@@ -611,6 +635,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 金鑰",
|
||||
@@ -821,13 +846,14 @@
|
||||
"seed_desc_tip": "相同的種子和提示詞可以生成相似的圖片,設置 -1 每次生成都不一樣",
|
||||
"title": "繪圖",
|
||||
"magic_prompt_option": "提示詞增強",
|
||||
"model": "版本",
|
||||
"model": "模型",
|
||||
"aspect_ratio": "畫幅比例",
|
||||
"style_type": "風格",
|
||||
"learn_more": "了解更多",
|
||||
"prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹",
|
||||
"paint_course":"教程",
|
||||
"proxy_required": "目前需要打開代理才能查看生成圖片,後續會支持國內直連",
|
||||
"prompt_placeholder_en": "輸入”英文“圖片描述,目前 Imagen 僅支持英文提示詞",
|
||||
"paint_course": "教程",
|
||||
"proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連",
|
||||
"image_file_required": "請先上傳圖片",
|
||||
"image_file_retry": "請重新上傳圖片",
|
||||
"image_placeholder": "無圖片",
|
||||
@@ -846,6 +872,34 @@
|
||||
"turbo": "快速",
|
||||
"quality": "高品質"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "自動",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "自動",
|
||||
"low": "低"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "自動",
|
||||
"transparent": "透明",
|
||||
"opaque": "不透明"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "方形",
|
||||
"portrait": "豎圖",
|
||||
"landscape": "橫圖"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "允許所有",
|
||||
"allow_adult": "允許成人",
|
||||
"allow_none": "不允許"
|
||||
},
|
||||
"quality": "品質",
|
||||
"moderation": "敏感度",
|
||||
"background": "背景",
|
||||
"mode": {
|
||||
"generate": "繪圖",
|
||||
"edit": "編輯",
|
||||
@@ -859,7 +913,9 @@
|
||||
"negative_prompt_tip": "描述不想在圖像中出現的內容",
|
||||
"magic_prompt_option_tip": "智能優化生成效果的提示詞",
|
||||
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本",
|
||||
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本"
|
||||
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本",
|
||||
"person_generation": "人物生成",
|
||||
"person_generation_tip": "允許模型生成人物圖像"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "編輯圖像",
|
||||
@@ -893,7 +949,20 @@
|
||||
"magic_prompt_option_tip": "智能優化放大提示詞"
|
||||
},
|
||||
"rendering_speed": "渲染速度",
|
||||
"text_desc_required": "請先輸入圖片描述"
|
||||
"text_desc_required": "請先輸入圖片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"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": "幫我解釋一下這個概念",
|
||||
@@ -947,7 +1016,7 @@
|
||||
"zhinao": "360 智腦",
|
||||
"zhipu": "智譜 AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "七牛雲",
|
||||
"qiniu": "七牛雲 AI 推理",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1046,7 +1115,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公式強制使用$$",
|
||||
@@ -1055,12 +1126,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",
|
||||
@@ -1074,10 +1147,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": "自動備份",
|
||||
@@ -1214,6 +1286,7 @@
|
||||
"display.sidebar.translate.icon": "顯示翻譯圖示",
|
||||
"display.sidebar.visible": "顯示的圖示",
|
||||
"display.title": "顯示設定",
|
||||
"display.zoom.title": "縮放設定",
|
||||
"display.topic.title": "話題設定",
|
||||
"miniapps": {
|
||||
"title": "小程式設置",
|
||||
@@ -1438,6 +1511,7 @@
|
||||
"advancedSettings": "高級設定"
|
||||
},
|
||||
"messages.prompt": "提示詞顯示",
|
||||
"messages.tokens": "Token用量顯示",
|
||||
"messages.divider": "訊息間顯示分隔線",
|
||||
"messages.grid_columns": "訊息網格展示列數",
|
||||
"messages.grid_popover_trigger": "網格詳細資訊觸發",
|
||||
@@ -1448,7 +1522,7 @@
|
||||
"messages.input.send_shortcuts": "傳送快捷鍵",
|
||||
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
|
||||
"messages.input.title": "輸入設定",
|
||||
"messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單",
|
||||
"messages.input.enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單",
|
||||
"messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件",
|
||||
"messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息",
|
||||
"messages.math_engine": "數學公式引擎",
|
||||
@@ -1491,6 +1565,7 @@
|
||||
"models.check.start": "開始",
|
||||
"models.check.title": "模型健康檢查",
|
||||
"models.check.use_all_keys": "使用密鑰",
|
||||
"models.check.disclaimer": "健康檢查需要發送請求,請謹慎使用。按次收費的模型可能產生更多費用,請自行承擔。",
|
||||
"models.default_assistant_model": "預設助手模型",
|
||||
"models.default_assistant_model_description": "建立新助手時使用的模型,如果助手未設定模型,則使用此模型",
|
||||
"models.empty": "找不到模型",
|
||||
@@ -1632,10 +1707,11 @@
|
||||
"zoom_reset": "重設縮放",
|
||||
"exit_fullscreen": "退出螢幕"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.system": "系統",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "淺色",
|
||||
"theme.title": "主題",
|
||||
"theme.color_primary": "主題顏色",
|
||||
"theme.window.style.opaque": "不透明視窗",
|
||||
"theme.window.style.title": "視窗樣式",
|
||||
"theme.window.style.transparent": "透明視窗",
|
||||
@@ -1740,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意語言",
|
||||
"target_language": "目標語言",
|
||||
"alter_language": "備用語言",
|
||||
"button.translate": "翻譯",
|
||||
"close": "關閉",
|
||||
"closed": "翻譯已關閉",
|
||||
@@ -1762,13 +1840,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": "結束",
|
||||
@@ -1780,6 +1867,174 @@
|
||||
"quit": "結束",
|
||||
"show_window": "顯示視窗",
|
||||
"visualization": "視覺化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||
"later": "稍後",
|
||||
"install": "立即安裝",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "劃詞助手",
|
||||
"action": {
|
||||
"builtin": {
|
||||
"translate": "翻譯",
|
||||
"explain": "解釋",
|
||||
"summary": "總結",
|
||||
"search": "搜尋",
|
||||
"refine": "優化",
|
||||
"copy": "複製"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置頂",
|
||||
"pinned": "已置頂",
|
||||
"opacity": "視窗透明度",
|
||||
"original_show": "顯示原文",
|
||||
"original_hide": "隱藏原文",
|
||||
"original_copy": "複製原文",
|
||||
"esc_close": "Esc 關閉",
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 複製",
|
||||
"r_regenerate": "R 重新生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"experimental": "實驗性功能",
|
||||
"enable": {
|
||||
"title": "啟用",
|
||||
"description": "目前僅支援 Windows 系統"
|
||||
},
|
||||
"toolbar": {
|
||||
"title": "工具列",
|
||||
"trigger_mode": {
|
||||
"title": "觸發方式",
|
||||
"description": "劃詞立即顯示工具列,或者劃詞後按住 Ctrl 鍵才顯示工具列。",
|
||||
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。",
|
||||
"selected": "劃詞",
|
||||
"ctrlkey": "Ctrl 鍵"
|
||||
},
|
||||
"compact_mode": {
|
||||
"title": "緊湊模式",
|
||||
"description": "緊湊模式下,只顯示圖示,不顯示文字"
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"title": "功能視窗",
|
||||
"follow_toolbar": {
|
||||
"title": "跟隨工具列",
|
||||
"description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示"
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "記住大小",
|
||||
"description": "應用運行期間,視窗會按上次調整的大小顯示"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自動關閉",
|
||||
"description": "當視窗未置頂且失去焦點時,將自動關閉該視窗"
|
||||
},
|
||||
"auto_pin": {
|
||||
"title": "自動置頂",
|
||||
"description": "預設將視窗置於頂部"
|
||||
},
|
||||
"opacity": {
|
||||
"title": "透明度",
|
||||
"description": "設置視窗的默認透明度,100%為完全不透明"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "功能",
|
||||
"reset": {
|
||||
"button": "重設",
|
||||
"tooltip": "重設為預設功能,自訂功能不會被刪除",
|
||||
"confirm": "確定要重設為預設功能嗎?自訂功能不會被刪除。"
|
||||
},
|
||||
"add_tooltip": {
|
||||
"enabled": "新增自訂功能",
|
||||
"disabled": "自訂功能已達上限 ({{max}}個)"
|
||||
},
|
||||
"delete_confirm": "確定要刪除這個自訂功能嗎?",
|
||||
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "進階",
|
||||
"filter_mode": {
|
||||
"title": "應用篩選",
|
||||
"description": "可以限制劃詞助手只在特定應用中生效(白名單)或不生效(黑名單)",
|
||||
"default": "關閉",
|
||||
"whitelist": "白名單",
|
||||
"blacklist": "黑名單"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "篩選名單",
|
||||
"description": "進階功能,建議有經驗的用戶在了解情況下再進行設置"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "新增自訂功能",
|
||||
"edit": "編輯自訂功能"
|
||||
},
|
||||
"name": {
|
||||
"label": "名稱",
|
||||
"hint": "請輸入功能名稱"
|
||||
},
|
||||
"icon": {
|
||||
"label": "圖示",
|
||||
"placeholder": "輸入 Lucide 圖示名稱",
|
||||
"error": "無效的圖示名稱,請檢查輸入",
|
||||
"tooltip": "Lucide圖示名稱為小寫,如 arrow-right",
|
||||
"view_all": "檢視所有圖示",
|
||||
"random": "隨機圖示"
|
||||
},
|
||||
"model": {
|
||||
"label": "模型",
|
||||
"tooltip": "使用助手:會同時使用助手的系統提示詞和模型參數",
|
||||
"default": "預設模型",
|
||||
"assistant": "使用助手"
|
||||
},
|
||||
"assistant": {
|
||||
"label": "選擇助手",
|
||||
"default": "預設"
|
||||
},
|
||||
"prompt": {
|
||||
"label": "使用者提示詞(Prompt)",
|
||||
"tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞",
|
||||
"placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾",
|
||||
"placeholder_text": "佔位符",
|
||||
"copy_placeholder": "複製佔位符"
|
||||
}
|
||||
},
|
||||
"search_modal": {
|
||||
"title": "設定搜尋引擎",
|
||||
"engine": {
|
||||
"label": "搜尋引擎",
|
||||
"custom": "自訂"
|
||||
},
|
||||
"custom": {
|
||||
"name": {
|
||||
"label": "自訂名稱",
|
||||
"hint": "請輸入搜尋引擎名稱",
|
||||
"max_length": "名稱不能超過16個字元"
|
||||
},
|
||||
"url": {
|
||||
"label": "自訂搜尋 URL",
|
||||
"hint": "使用 {{queryString}} 代表搜尋詞",
|
||||
"required": "請輸入搜尋 URL",
|
||||
"invalid_format": "請輸入以 http:// 或 https:// 開頭的有效 URL",
|
||||
"missing_placeholder": "URL 必須包含 {{queryString}} 佔位符"
|
||||
},
|
||||
"test": "測試"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "應用篩選名單",
|
||||
"user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +557,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",
|
||||
@@ -880,7 +881,7 @@
|
||||
"zhinao": "360 Intelligent Brain",
|
||||
"zhipu": "Zhipu AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Cloud"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Είστε σίγουροι ότι θέλετε να επαναφέρετε τα δεδομένα;",
|
||||
@@ -1140,6 +1141,7 @@
|
||||
"display.sidebar.translate.icon": "Εμφάνιση εικονιδίου μετάφρασης",
|
||||
"display.sidebar.visible": "Εμφανιζόμενα εικονίδια",
|
||||
"display.title": "Ρυθμίσεις εμφάνισης",
|
||||
"display.zoom.title": "Ρυθμίσεις κλίμακας",
|
||||
"display.topic.title": "Ρυθμίσεις Θεμάτων",
|
||||
"font_size.title": "Μέγεθος γραμμάτων των μηνυμάτων",
|
||||
"general": "Γενικές ρυθμίσεις",
|
||||
@@ -1477,7 +1479,7 @@
|
||||
"zoom_out": "Σμικρύνση εμφάνισης",
|
||||
"zoom_reset": "Επαναφορά εμφάνισης"
|
||||
},
|
||||
"theme.auto": "Αυτόματο",
|
||||
"theme.system": "Σύστημα",
|
||||
"theme.dark": "Σκοτεινό",
|
||||
"theme.light": "Φωτεινό",
|
||||
"theme.title": "Θέμα",
|
||||
@@ -1656,6 +1658,13 @@
|
||||
"quit": "Έξοδος",
|
||||
"show_window": "Εμφάνιση Παραθύρου",
|
||||
"visualization": "προβολή"
|
||||
},
|
||||
"update": {
|
||||
"title": "Ενημέρωση",
|
||||
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
||||
"later": "Μετά",
|
||||
"install": "Εγκατάσταση",
|
||||
"noReleaseNotes": "Χωρίς σημειώσεις"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
"error.enter.api.key": "Ingrese su clave API",
|
||||
"error.enter.model": "Seleccione un modelo",
|
||||
"error.enter.name": "Ingrese el nombre de la base de conocimiento",
|
||||
"error.fetchTopicName": "Error al nombrar el tema",
|
||||
"error.get_embedding_dimensions": "Fallo al obtener las dimensiones de incrustación",
|
||||
"error.invalid.api.host": "Dirección API inválida",
|
||||
"error.invalid.api.key": "Clave API inválida",
|
||||
@@ -849,7 +850,7 @@
|
||||
"doubao": "Volcán Motor",
|
||||
"fireworks": "Fuegos Artificiales",
|
||||
"gemini": "Géminis",
|
||||
"gitee-ai": "Gitee IA",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"github": "GitHub Modelos",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Grok",
|
||||
@@ -881,7 +882,7 @@
|
||||
"zhinao": "360 Inteligente",
|
||||
"zhipu": "ZhiPu IA",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Yun"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "¿Está seguro de que desea restaurar los datos?",
|
||||
@@ -1139,6 +1140,7 @@
|
||||
"display.sidebar.translate.icon": "Mostrar icono de traducción",
|
||||
"display.sidebar.visible": "Iconos visibles",
|
||||
"display.title": "Configuración de visualización",
|
||||
"display.zoom.title": "Configuración de zoom",
|
||||
"display.topic.title": "Configuración de tema",
|
||||
"font_size.title": "Tamaño de fuente de mensajes",
|
||||
"general": "Configuración general",
|
||||
@@ -1476,7 +1478,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",
|
||||
@@ -1655,6 +1657,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
"error.enter.api.key": "Veuillez entrer votre clé API",
|
||||
"error.enter.model": "Veuillez sélectionner un modèle",
|
||||
"error.enter.name": "Veuillez entrer le nom de la base de connaissances",
|
||||
"error.fetchTopicName": "Échec de la dénomination du sujet",
|
||||
"error.get_embedding_dimensions": "Impossible d'obtenir les dimensions d'encodage",
|
||||
"error.invalid.api.host": "Adresse API invalide",
|
||||
"error.invalid.api.key": "Clé API invalide",
|
||||
@@ -848,7 +849,7 @@
|
||||
"doubao": "Huoshan Engine",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee IA",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"github": "GitHub Modèles",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Grok",
|
||||
@@ -880,7 +881,7 @@
|
||||
"zhinao": "360 ZhiNao",
|
||||
"zhipu": "ZhiPu IA",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Cloud"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Êtes-vous sûr de vouloir restaurer les données ?",
|
||||
@@ -1140,6 +1141,7 @@
|
||||
"display.sidebar.translate.icon": "Afficher l'icône de traduction",
|
||||
"display.sidebar.visible": "Icônes affichées",
|
||||
"display.title": "Paramètres d'affichage",
|
||||
"display.zoom.title": "Paramètres de zoom",
|
||||
"display.topic.title": "Paramètres de sujet",
|
||||
"font_size.title": "Taille de police des messages",
|
||||
"general": "Paramètres généraux",
|
||||
@@ -1477,7 +1479,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",
|
||||
@@ -1656,6 +1658,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"error.enter.api.key": "Insira sua chave API",
|
||||
"error.enter.model": "Selecione um modelo",
|
||||
"error.enter.name": "Insira o nome da base de conhecimento",
|
||||
"error.fetchTopicName": "Falha ao nomear o tópico",
|
||||
"error.get_embedding_dimensions": "Falha ao obter dimensões de incorporação",
|
||||
"error.invalid.api.host": "Endereço API inválido",
|
||||
"error.invalid.api.key": "Chave API inválida",
|
||||
@@ -850,7 +851,7 @@
|
||||
"doubao": "Volcano Engine",
|
||||
"fireworks": "Fogos de Artifício",
|
||||
"gemini": "Gêmeos",
|
||||
"gitee-ai": "Gitee IA",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"github": "GitHub Models",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Compreender",
|
||||
@@ -882,7 +883,7 @@
|
||||
"zhinao": "360 Inteligência Artificial",
|
||||
"zhipu": "ZhiPu IA",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Cloud"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Tem certeza de que deseja restaurar os dados?",
|
||||
@@ -1142,6 +1143,7 @@
|
||||
"display.sidebar.translate.icon": "Mostrar ícone de tradução",
|
||||
"display.sidebar.visible": "Ícones visíveis",
|
||||
"display.title": "Configurações de exibição",
|
||||
"display.zoom.title": "Configurações de zoom",
|
||||
"display.topic.title": "Configurações de tópico",
|
||||
"font_size.title": "Tamanho da fonte da mensagem",
|
||||
"general": "Configurações gerais",
|
||||
@@ -1479,7 +1481,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",
|
||||
@@ -1658,6 +1660,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Col, Image, Row, Spin, Table } from 'antd'
|
||||
import React, { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import GeminiFiles from './GeminiFiles'
|
||||
|
||||
interface ContentViewProps {
|
||||
id: FileTypes | 'all' | string
|
||||
files?: FileType[]
|
||||
@@ -45,10 +43,6 @@ const ContentView: React.FC<ContentViewProps> = ({ id, files, dataSource, column
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith('gemini_')) {
|
||||
return <GeminiFiles id={id.replace('gemini_', '') as string} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import FileItem from './FileItem'
|
||||
import GeminiFiles from './GeminiFiles'
|
||||
|
||||
interface FileItemProps {
|
||||
id: FileTypes | 'all' | string
|
||||
@@ -58,10 +57,6 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith('gemini_')) {
|
||||
return <GeminiFiles id={id.replace('gemini_', '') as string} />
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualList
|
||||
data={list}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import type { File } from '@google/genai'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { Spin } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import FileItem from './FileItem'
|
||||
|
||||
interface GeminiFilesProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
||||
const { provider } = useProvider(id)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
const files = await window.api.gemini.listFiles(provider.apiKey)
|
||||
files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
|
||||
}, [provider])
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await fetchFiles()
|
||||
setLoading(false)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch files:', error)
|
||||
window.message.error(error.message)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}, [fetchFiles])
|
||||
|
||||
useEffect(() => {
|
||||
setFiles([])
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container>
|
||||
<LoadingWrapper>
|
||||
<Spin />
|
||||
</LoadingWrapper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<FileListContainer>
|
||||
{files.map((file) => (
|
||||
<FileItem
|
||||
key={file.name}
|
||||
fileInfo={{
|
||||
name: file.displayName,
|
||||
ext: `.${file.name?.split('.').pop()}`,
|
||||
extra: `${dayjs(file.createTime).format('MM-DD HH:mm')} · ${(parseInt(file.sizeBytes || '0') / MB).toFixed(2)} MB`,
|
||||
actions: (
|
||||
<DeleteOutlined
|
||||
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
|
||||
onClick={() => {
|
||||
setFiles(files.filter((f) => f.name !== file.name))
|
||||
window.api.gemini.deleteFile(file.name!, provider.apiKey).catch((error) => {
|
||||
console.error('Failed to delete file:', error)
|
||||
setFiles((prev) => [...prev, file])
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FileListContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const FileListContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
`
|
||||
|
||||
export default GeminiFiles
|
||||
@@ -36,8 +36,8 @@ const Chat: FC<Props> = (props) => {
|
||||
|
||||
const maxWidth = useMemo(() => {
|
||||
const showRightTopics = showTopics && topicPosition === 'right'
|
||||
const minusAssistantsWidth = showAssistants ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
|
||||
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
|
||||
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})`
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
@@ -115,16 +115,14 @@ const Chat: FC<Props> = (props) => {
|
||||
includeUser={filterIncludeUser}
|
||||
onIncludeUserChange={userOutlinedItemClickHandler}
|
||||
/>
|
||||
<MessagesContainer>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
</MessagesContainer>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||
@@ -143,13 +141,6 @@ const Chat: FC<Props> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const MessagesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -450,10 +450,6 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
openResourcesList
|
||||
}))
|
||||
|
||||
if (activedMcpServers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user