Compare commits
2 Commits
v1.4.0
...
build/wind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d74a2330e | ||
|
|
8fd57d0271 |
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
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ stats.html
|
||||
local
|
||||
.aider*
|
||||
.cursorrules
|
||||
.cursor/rules
|
||||
.cursor/*
|
||||
|
||||
# vitest
|
||||
coverage
|
||||
|
||||
85
.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch
vendored
85
.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch
vendored
@@ -1,85 +0,0 @@
|
||||
diff --git a/core.js b/core.js
|
||||
index 862d66101f441fb4f47dfc8cff5e2d39e1f5a11e..6464bebbf696c39d35f0368f061ea4236225c162 100644
|
||||
--- a/core.js
|
||||
+++ b/core.js
|
||||
@@ -159,7 +159,7 @@ class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
...this.authHeaders(opts),
|
||||
};
|
||||
}
|
||||
diff --git a/core.mjs b/core.mjs
|
||||
index 05dbc6cfde51589a2b100d4e4b5b3c1a33b32b89..789fbb4985eb952a0349b779fa83b1a068af6e7e 100644
|
||||
--- a/core.mjs
|
||||
+++ b/core.mjs
|
||||
@@ -152,7 +152,7 @@ export class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
...this.authHeaders(opts),
|
||||
};
|
||||
}
|
||||
diff --git a/error.mjs b/error.mjs
|
||||
index 7d19f5578040afa004bc887aab1725e8703d2bac..59ec725b6142299a62798ac4bdedb63ba7d9932c 100644
|
||||
--- a/error.mjs
|
||||
+++ b/error.mjs
|
||||
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: castToError(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/resources/embeddings.js b/resources/embeddings.js
|
||||
index aae578404cb2d09a39ac33fc416f1c215c45eecd..25c54b05bdae64d5c3b36fbb30dc7c8221b14034 100644
|
||||
--- a/resources/embeddings.js
|
||||
+++ b/resources/embeddings.js
|
||||
@@ -36,6 +36,9 @@ class Embeddings extends resource_1.APIResource {
|
||||
// No encoding_format specified, defaulting to base64 for performance reasons
|
||||
// See https://github.com/openai/openai-node/pull/1312
|
||||
let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
+ if (body.model.includes('jina')) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
if (hasUserProvidedEncodingFormat) {
|
||||
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
|
||||
}
|
||||
@@ -47,7 +50,7 @@ class Embeddings extends resource_1.APIResource {
|
||||
...options,
|
||||
});
|
||||
// if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
|
||||
return response;
|
||||
}
|
||||
// in this stage, we are sure the user did not specify an encoding_format
|
||||
diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
|
||||
index 0df3c6cc79a520e54acb4c2b5f77c43b774035ff..aa488b8a11b2c413c0a663d9a6059d286d7b5faf 100644
|
||||
--- a/resources/embeddings.mjs
|
||||
+++ b/resources/embeddings.mjs
|
||||
@@ -10,6 +10,9 @@ export class Embeddings extends APIResource {
|
||||
// No encoding_format specified, defaulting to base64 for performance reasons
|
||||
// See https://github.com/openai/openai-node/pull/1312
|
||||
let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
+ if (body.model.includes('jina')) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
if (hasUserProvidedEncodingFormat) {
|
||||
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
|
||||
}
|
||||
@@ -21,7 +24,7 @@ export class Embeddings extends APIResource {
|
||||
...options,
|
||||
});
|
||||
// if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
|
||||
return response;
|
||||
}
|
||||
// in this stage, we are sure the user did not specify an encoding_format
|
||||
279
.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
vendored
Normal file
279
.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
diff --git a/client.js b/client.js
|
||||
index 33b4ff6309d5f29187dab4e285d07dac20340bab..8f568637ee9e4677585931fb0284c8165a933f69 100644
|
||||
--- a/client.js
|
||||
+++ b/client.js
|
||||
@@ -433,7 +433,7 @@ class OpenAI {
|
||||
'User-Agent': this.getUserAgent(),
|
||||
'X-Stainless-Retry-Count': String(retryCount),
|
||||
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
|
||||
- ...(0, detect_platform_1.getPlatformHeaders)(),
|
||||
+ // ...(0, detect_platform_1.getPlatformHeaders)(),
|
||||
'OpenAI-Organization': this.organization,
|
||||
'OpenAI-Project': this.project,
|
||||
},
|
||||
diff --git a/client.mjs b/client.mjs
|
||||
index c34c18213073540ebb296ea540b1d1ad39527906..1ce1a98256d7e90e26ca963582f235b23e996e73 100644
|
||||
--- a/client.mjs
|
||||
+++ b/client.mjs
|
||||
@@ -430,7 +430,7 @@ export class OpenAI {
|
||||
'User-Agent': this.getUserAgent(),
|
||||
'X-Stainless-Retry-Count': String(retryCount),
|
||||
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
'OpenAI-Organization': this.organization,
|
||||
'OpenAI-Project': this.project,
|
||||
},
|
||||
diff --git a/core/error.js b/core/error.js
|
||||
index a12d9d9ccd242050161adeb0f82e1b98d9e78e20..fe3a5462480558bc426deea147f864f12b36f9bd 100644
|
||||
--- a/core/error.js
|
||||
+++ b/core/error.js
|
||||
@@ -40,7 +40,7 @@ class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: (0, errors_1.castToError)(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/core/error.mjs b/core/error.mjs
|
||||
index 83cefbaffeb8c657536347322d8de9516af479a2..63334b7972ec04882aa4a0800c1ead5982345045 100644
|
||||
--- a/core/error.mjs
|
||||
+++ b/core/error.mjs
|
||||
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: castToError(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/resources/embeddings.js b/resources/embeddings.js
|
||||
index 2404264d4ba0204322548945ebb7eab3bea82173..8f1bc45cc45e0797d50989d96b51147b90ae6790 100644
|
||||
--- a/resources/embeddings.js
|
||||
+++ b/resources/embeddings.js
|
||||
@@ -5,52 +5,64 @@ exports.Embeddings = void 0;
|
||||
const resource_1 = require("../core/resource.js");
|
||||
const utils_1 = require("../internal/utils.js");
|
||||
class Embeddings extends resource_1.APIResource {
|
||||
- /**
|
||||
- * Creates an embedding vector representing the input text.
|
||||
- *
|
||||
- * @example
|
||||
- * ```ts
|
||||
- * const createEmbeddingResponse =
|
||||
- * await client.embeddings.create({
|
||||
- * input: 'The quick brown fox jumped over the lazy dog',
|
||||
- * model: 'text-embedding-3-small',
|
||||
- * });
|
||||
- * ```
|
||||
- */
|
||||
- create(body, options) {
|
||||
- const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
- // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
- // See https://github.com/openai/openai-node/pull/1312
|
||||
- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- (0, utils_1.loggerFor)(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
|
||||
- }
|
||||
- const response = this._client.post('/embeddings', {
|
||||
- body: {
|
||||
- ...body,
|
||||
- encoding_format: encoding_format,
|
||||
- },
|
||||
- ...options,
|
||||
- });
|
||||
- // if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- return response;
|
||||
- }
|
||||
- // in this stage, we are sure the user did not specify an encoding_format
|
||||
- // and we defaulted to base64 for performance reasons
|
||||
- // we are sure then that the response is base64 encoded, let's decode it
|
||||
- // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
- (0, utils_1.loggerFor)(this._client).debug('embeddings/decoding base64 embeddings from base64');
|
||||
- return response._thenUnwrap((response) => {
|
||||
- if (response && response.data) {
|
||||
- response.data.forEach((embeddingBase64Obj) => {
|
||||
- const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
- embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(embeddingBase64Str);
|
||||
- });
|
||||
- }
|
||||
- return response;
|
||||
- });
|
||||
- }
|
||||
+ /**
|
||||
+ * Creates an embedding vector representing the input text.
|
||||
+ *
|
||||
+ * @example
|
||||
+ * ```ts
|
||||
+ * const createEmbeddingResponse =
|
||||
+ * await client.embeddings.create({
|
||||
+ * input: 'The quick brown fox jumped over the lazy dog',
|
||||
+ * model: 'text-embedding-3-small',
|
||||
+ * });
|
||||
+ * ```
|
||||
+ */
|
||||
+ create(body, options) {
|
||||
+ const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
+ // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
+ // See https://github.com/openai/openai-node/pull/1312
|
||||
+ let encoding_format = hasUserProvidedEncodingFormat
|
||||
+ ? body.encoding_format
|
||||
+ : "base64";
|
||||
+ if (body.model.includes("jina")) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
+ if (hasUserProvidedEncodingFormat) {
|
||||
+ (0, utils_1.loggerFor)(this._client).debug(
|
||||
+ "embeddings/user defined encoding_format:",
|
||||
+ body.encoding_format
|
||||
+ );
|
||||
+ }
|
||||
+ const response = this._client.post("/embeddings", {
|
||||
+ body: {
|
||||
+ ...body,
|
||||
+ encoding_format: encoding_format,
|
||||
+ },
|
||||
+ ...options,
|
||||
+ });
|
||||
+ // if the user specified an encoding_format, return the response as-is
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
|
||||
+ return response;
|
||||
+ }
|
||||
+ // in this stage, we are sure the user did not specify an encoding_format
|
||||
+ // and we defaulted to base64 for performance reasons
|
||||
+ // we are sure then that the response is base64 encoded, let's decode it
|
||||
+ // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
+ (0, utils_1.loggerFor)(this._client).debug(
|
||||
+ "embeddings/decoding base64 embeddings from base64"
|
||||
+ );
|
||||
+ return response._thenUnwrap((response) => {
|
||||
+ if (response && response.data) {
|
||||
+ 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,72 +12,51 @@ electronLanguages:
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '**/*'
|
||||
- '!**/{.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}'
|
||||
- '!{.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}'
|
||||
- '!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__,powered-test,coverage}/**'
|
||||
- '!**/{example,examples}/**'
|
||||
- '!**/{test,tests,__tests__,coverage}/**'
|
||||
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||
- '!**/*.min.*.map'
|
||||
- '!**/*.d.ts'
|
||||
- '!**/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}'
|
||||
- '!**/{.DS_Store,Thumbs.db}'
|
||||
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,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}'
|
||||
- '**/*.{node,dll,metal,exp,lib}'
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
artifactName: ${productName}-${version}-portable.${ext}
|
||||
target:
|
||||
- target: nsis
|
||||
- target: portable
|
||||
signtoolOptions:
|
||||
sign: scripts/win-sign.js
|
||||
verifyUpdateCodeSignature: false
|
||||
nsis:
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
artifactName: ${productName}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
include: build/nsis-installer.nsh
|
||||
buildUniversalInstaller: false
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-${arch}-portable.${ext}
|
||||
buildUniversalInstaller: false
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
@@ -85,11 +64,20 @@ mac:
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
linux:
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: AppImage
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
desktop:
|
||||
@@ -98,13 +86,15 @@ linux:
|
||||
mimeTypes:
|
||||
- x-scheme-handler/cherrystudio
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://releases.cherry-ai.com
|
||||
# provider: generic
|
||||
# url: https://cherrystudio.ocool.online
|
||||
provider: github
|
||||
repo: cherry-studio
|
||||
owner: CherryHQ
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
新增划词助手
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import fs from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
@@ -9,7 +10,22 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('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')],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
@@ -19,12 +35,28 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate']
|
||||
},
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
},
|
||||
optimizeDeps: {
|
||||
noDiscovery: process.env.NODE_ENV === 'development'
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
|
||||
plugins: [
|
||||
{
|
||||
name: 'inject-windows7-polyfill',
|
||||
generateBundle(_, bundle) {
|
||||
// 遍历所有生成的文件
|
||||
for (const fileName in bundle) {
|
||||
const chunk = bundle[fileName]
|
||||
if (
|
||||
chunk.type === 'chunk' &&
|
||||
chunk.isEntry &&
|
||||
chunk.fileName.includes('index.js') // 匹配主进程入口文件
|
||||
) {
|
||||
const code = fs.readFileSync('src/main/polyfill/windows7-patch.js', 'utf-8')
|
||||
// 在文件末尾插入自定义代码
|
||||
chunk.code = code + '\r\n' + chunk.code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
@@ -39,6 +71,10 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
define: {
|
||||
// 使用方法 (Windows CMD): set CUSTOM_APP_NAME=AppName && yarn run dev
|
||||
'process.env.CUSTOM_APP_NAME': JSON.stringify(process.env.CUSTOM_APP_NAME)
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
plugins: [
|
||||
|
||||
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -27,6 +27,7 @@
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
|
||||
"build:win7": "xcopy \"src\\patch\\windows7\\\" \"node_modules\\\" /E /Y && dotenv npm run build && electron-builder --win --x64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
|
||||
@@ -47,6 +48,7 @@
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
"test:renderer": "vitest run --project renderer",
|
||||
"test:update": "yarn test:renderer --update",
|
||||
"test:coverage": "vitest run --coverage --silent",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest",
|
||||
@@ -70,30 +72,44 @@
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@libsql/client": "^0.15.2",
|
||||
"@libsql/win32-x64-msvc": "^0.5.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@peculiar/webcrypto": "^1.5.0",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tanstack/react-query": "^5.27.0",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"blob-polyfill": "^9.0.20240710",
|
||||
"bufferutil": "^4.0.9",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"domexception": "^4.0.0",
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"franc": "^6.2.0",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"libsql": "^0.5.4",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-fetch": "2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"selection-hook": "^0.9.19",
|
||||
"selection-hook": "^0.9.21",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.4.0",
|
||||
"web-streams-polyfill": "^4.1.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
@@ -117,7 +133,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@peculiar/webcrypto": "^1.5.0",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
@@ -154,8 +170,8 @@
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "35.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron": "22.3.23",
|
||||
"electron-builder": "^24.9.1",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-vite": "^3.1.0",
|
||||
"emittery": "^1.0.3",
|
||||
@@ -177,7 +193,7 @@
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"playwright": "^1.52.0",
|
||||
"prettier": "^3.5.3",
|
||||
@@ -218,10 +234,13 @@
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"@types/domexception": "^4",
|
||||
"electron": "22.3.23",
|
||||
"electron-builder": "^24.9.1",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
import { isWindows7 } from './utils/runtime'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
@@ -12,12 +13,12 @@ export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
color: isWindows7() ? '#1c1c1c' : 'rgba(0,0,0,0)',
|
||||
symbolColor: '#ffffff'
|
||||
}
|
||||
|
||||
export const titleBarOverlayLight = {
|
||||
height: 40,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000'
|
||||
color: isWindows7() ? '#f4f4f4' : 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000000'
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
interface IBlacklist {
|
||||
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: IBlacklist = {
|
||||
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',
|
||||
@@ -32,6 +41,17 @@ export const SELECTION_PREDEFINED_BLACKLIST: IBlacklist = {
|
||||
'maya.exe',
|
||||
// CAD
|
||||
'acad.exe',
|
||||
'sldworks.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'
|
||||
@@ -24,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
|
||||
|
||||
File diff suppressed because one or more lines are too long
36
src/main/polyfill/windows7-patch.js
Normal file
36
src/main/polyfill/windows7-patch.js
Normal file
@@ -0,0 +1,36 @@
|
||||
console.info('inject polyfill win7')
|
||||
|
||||
// fix for node_modules\@libsql\isomorphic-fetch\node.cjs
|
||||
if (!globalThis.fetch) {
|
||||
globalThis.fetch = require('node-fetch')
|
||||
}
|
||||
|
||||
if (!globalThis.Request) {
|
||||
const { Request, Headers } = require('node-fetch')
|
||||
globalThis.Request = Request
|
||||
globalThis.Headers = Headers
|
||||
}
|
||||
// fix for node_modules/undici/lib/web/fetch/webidl.js
|
||||
if (!globalThis.Blob) {
|
||||
const { Blob } = require('blob-polyfill')
|
||||
globalThis.Blob = Blob
|
||||
}
|
||||
|
||||
// fix for node_modules/undici/lib/web/fetch/webidl.js
|
||||
if (!globalThis.ReadableStream) {
|
||||
const { ReadableStream, TransformStream } = require('web-streams-polyfill')
|
||||
globalThis.ReadableStream = ReadableStream
|
||||
globalThis.TransformStream = TransformStream
|
||||
console.log('ReadableStream', ReadableStream)
|
||||
}
|
||||
|
||||
if (!globalThis.DOMException) {
|
||||
globalThis.DOMException = require('domexception')
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
const { Crypto } = require('@peculiar/webcrypto')
|
||||
globalThis.crypto = new Crypto()
|
||||
}
|
||||
|
||||
console.info('inject polyfill win7 ok')
|
||||
@@ -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
|
||||
|
||||
@@ -373,7 +373,7 @@ class FileStorage {
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<string> => {
|
||||
): Promise<string | null | undefined> => {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ProxyConfig as _ProxyConfig, session } from 'electron'
|
||||
// import { ProxyConfig as _ProxyConfig, session } from 'electron'
|
||||
import { session } from 'electron'
|
||||
|
||||
declare type _ProxyConfig = any
|
||||
|
||||
// import { socksDispatcher } from 'fetch-socks'
|
||||
import { getSystemProxy } from 'os-proxy-config'
|
||||
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
|
||||
// import { ProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isWin } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
@@ -37,6 +37,11 @@ type RelativeOrientation =
|
||||
| 'middleRight'
|
||||
| 'center'
|
||||
|
||||
enum TriggerMode {
|
||||
Selected = 'selected',
|
||||
Ctrlkey = 'ctrlkey'
|
||||
}
|
||||
|
||||
/** SelectionService is a singleton class that manages the selection hook and the toolbar window
|
||||
*
|
||||
* Features:
|
||||
@@ -59,7 +64,7 @@ export class SelectionService {
|
||||
private initStatus: boolean = false
|
||||
private started: boolean = false
|
||||
|
||||
private triggerMode = 'selected'
|
||||
private triggerMode = TriggerMode.Selected
|
||||
private isFollowToolbar = true
|
||||
private isRemeberWinSize = false
|
||||
private filterMode = 'default'
|
||||
@@ -145,17 +150,25 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
private initConfig() {
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode()
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
|
||||
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
|
||||
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
|
||||
this.filterMode = configManager.getSelectionAssistantFilterMode()
|
||||
this.filterList = configManager.getSelectionAssistantFilterList()
|
||||
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
this.setHookFineTunedList()
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: TriggerMode) => {
|
||||
const oldTriggerMode = this.triggerMode
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => {
|
||||
this.triggerMode = triggerMode
|
||||
this.processTriggerMode()
|
||||
|
||||
//trigger mode changed, need to update the filter list
|
||||
if (oldTriggerMode !== triggerMode) {
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
}
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => {
|
||||
@@ -193,28 +206,31 @@ export class SelectionService {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const modeMap = {
|
||||
default: 0,
|
||||
whitelist: 1,
|
||||
blacklist: 2
|
||||
default: SelectionHook!.FilterMode.DEFAULT,
|
||||
whitelist: SelectionHook!.FilterMode.INCLUDE_LIST,
|
||||
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
|
||||
}
|
||||
|
||||
let combinedList: string[] = []
|
||||
let combinedList: string[] = list
|
||||
let combinedMode = mode
|
||||
|
||||
switch (mode) {
|
||||
case 'blacklist':
|
||||
//combine the predefined blacklist with the user-defined blacklist
|
||||
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
|
||||
break
|
||||
case 'whitelist':
|
||||
combinedList = [...list]
|
||||
break
|
||||
case 'default':
|
||||
default:
|
||||
//use the predefined blacklist as the default filter list
|
||||
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
|
||||
combinedMode = 'blacklist'
|
||||
break
|
||||
//only the selected mode need to combine the predefined blacklist with the user-defined blacklist
|
||||
if (this.triggerMode === TriggerMode.Selected) {
|
||||
switch (mode) {
|
||||
case 'blacklist':
|
||||
//combine the predefined blacklist with the user-defined blacklist
|
||||
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
|
||||
break
|
||||
case 'whitelist':
|
||||
combinedList = [...list]
|
||||
break
|
||||
case 'default':
|
||||
default:
|
||||
//use the predefined blacklist as the default filter list
|
||||
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
|
||||
combinedMode = 'blacklist'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.selectionHook.setGlobalFilterMode(modeMap[combinedMode], combinedList)) {
|
||||
@@ -222,6 +238,20 @@ export class SelectionService {
|
||||
}
|
||||
}
|
||||
|
||||
private setHookFineTunedList() {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
this.selectionHook.setFineTunedList(
|
||||
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
|
||||
SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
|
||||
)
|
||||
|
||||
this.selectionHook.setFineTunedList(
|
||||
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
|
||||
SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the selection service and initialize required windows
|
||||
* @returns {boolean} Success status of service start
|
||||
@@ -274,7 +304,12 @@ export class SelectionService {
|
||||
if (!this.selectionHook) return false
|
||||
|
||||
this.selectionHook.stop()
|
||||
this.selectionHook.cleanup()
|
||||
this.selectionHook.cleanup() //already remove all listeners
|
||||
|
||||
//reset the listener states
|
||||
this.isCtrlkeyListenerActive = false
|
||||
this.isHideByMouseKeyListenerActive = false
|
||||
|
||||
if (this.toolbarWindow) {
|
||||
this.toolbarWindow.close()
|
||||
this.toolbarWindow = null
|
||||
@@ -324,7 +359,7 @@ export class SelectionService {
|
||||
hasShadow: false,
|
||||
thickFrame: false,
|
||||
roundedCorners: true,
|
||||
backgroundMaterial: 'none',
|
||||
// backgroundMaterial: 'none',
|
||||
type: 'toolbar',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -774,7 +809,7 @@ export class SelectionService {
|
||||
*/
|
||||
private handleKeyDownHide = (data: KeyboardEventData) => {
|
||||
//dont hide toolbar when ctrlkey is pressed
|
||||
if (this.triggerMode === 'ctrlkey' && this.isCtrlkey(data.vkCode)) {
|
||||
if (this.triggerMode === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) {
|
||||
return
|
||||
}
|
||||
//dont hide toolbar when shiftkey is pressed, because it's used for selection
|
||||
@@ -806,6 +841,8 @@ export class SelectionService {
|
||||
//ctrlkey pressed
|
||||
if (this.lastCtrlkeyDownTime === 0) {
|
||||
this.lastCtrlkeyDownTime = Date.now()
|
||||
//add the mouse-wheel listener, detect if user is zooming in/out
|
||||
this.selectionHook!.on('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -829,9 +866,20 @@ export class SelectionService {
|
||||
*/
|
||||
private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => {
|
||||
if (!this.isCtrlkey(data.vkCode)) return
|
||||
//remove the mouse-wheel listener
|
||||
this.selectionHook!.off('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
|
||||
this.lastCtrlkeyDownTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel events in ctrlkey trigger mode
|
||||
* ignore CtrlKey pressing when mouse wheel is used
|
||||
* because user is zooming in/out
|
||||
*/
|
||||
private handleMouseWheelCtrlkeyMode = () => {
|
||||
this.lastCtrlkeyDownTime = -1
|
||||
}
|
||||
|
||||
//check if the key is ctrl key
|
||||
private isCtrlkey(vkCode: number) {
|
||||
return vkCode === 162 || vkCode === 163
|
||||
@@ -1042,7 +1090,7 @@ export class SelectionService {
|
||||
* Manages appropriate event listeners for each mode
|
||||
*/
|
||||
private processTriggerMode() {
|
||||
if (this.triggerMode === 'selected') {
|
||||
if (this.triggerMode === TriggerMode.Selected) {
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
@@ -1051,7 +1099,7 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(false)
|
||||
} else if (this.triggerMode === 'ctrlkey') {
|
||||
} else if (this.triggerMode === TriggerMode.Ctrlkey) {
|
||||
if (!this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
@@ -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')
|
||||
|
||||
7
src/main/utils/runtime.ts
Normal file
7
src/main/utils/runtime.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import os from 'os'
|
||||
|
||||
export function isWindows7() {
|
||||
if (process.platform !== 'win32') return false
|
||||
const version = os.release()
|
||||
return version.startsWith('6.1') // Windows 7 的版本号为 6.1
|
||||
}
|
||||
BIN
src/patch/windows7/@libsql/win32-x64-msvc/index.node
Normal file
BIN
src/patch/windows7/@libsql/win32-x64-msvc/index.node
Normal file
Binary file not shown.
@@ -2,7 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
@@ -81,7 +81,11 @@ const api = {
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
getPathForFile: (file: File) => {
|
||||
const electronFile = file as File & { path?: string }
|
||||
return electronFile.path || null
|
||||
}
|
||||
// getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
},
|
||||
fs: {
|
||||
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 550 KiB |
@@ -26,6 +26,7 @@
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-text-secondary: rgba(235, 235, 245, 0.7);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
@@ -43,6 +44,9 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: #222;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
@@ -67,7 +71,7 @@
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
--list-item-border-radius: 20px;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
@@ -98,6 +102,7 @@
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-text-secondary: rgba(0, 0, 0, 0.75);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000019;
|
||||
@@ -115,6 +120,9 @@
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--color-list-item: #eee;
|
||||
--color-list-item-hover: #f5f5f5;
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--color-highlight: initial;
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -306,9 +306,14 @@ mjx-container {
|
||||
|
||||
/* 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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -23,7 +23,8 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { delay } from '@renderer/utils'
|
||||
import { Avatar, Drawer, Tooltip } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
// import { WebviewTag } from 'electron'
|
||||
declare type WebviewTag = any
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebviewTag } from 'electron'
|
||||
// import { WebviewTag } from 'electron'
|
||||
declare type WebviewTag = any
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Scrollbar > props handling > should handle right prop correctly 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="scrollbar"
|
||||
>
|
||||
内容
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
Folder,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
MessageSquareQuote,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
Palette,
|
||||
Settings,
|
||||
@@ -62,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
|
||||
})
|
||||
}
|
||||
@@ -147,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" />,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as AppMeta from '../../../shared/app-meta'
|
||||
|
||||
export { default as UserAvatar } from '@renderer/assets/images/avatar.png'
|
||||
export { default as AppLogo } from '@renderer/assets/images/logo.png'
|
||||
|
||||
export const APP_NAME = 'Cherry Studio'
|
||||
export const APP_NAME = AppMeta.APP_NAME
|
||||
export const APP_IS_CUSTOM_PRODUCT = AppMeta.APP_IS_CUSTOM_PRODUCT
|
||||
export const isLocalAi = false
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
@@ -394,7 +394,7 @@ export const PROVIDER_CONFIG = {
|
||||
official: 'https://openrouter.ai/',
|
||||
apiKey: 'https://openrouter.ai/settings/keys',
|
||||
docs: 'https://openrouter.ai/docs/quick-start',
|
||||
models: 'https://openrouter.ai/docs/models'
|
||||
models: 'https://openrouter.ai/models'
|
||||
}
|
||||
},
|
||||
groq: {
|
||||
@@ -446,7 +446,7 @@ export const PROVIDER_CONFIG = {
|
||||
websites: {
|
||||
official: 'https://x.ai/',
|
||||
docs: 'https://docs.x.ai/',
|
||||
models: 'https://docs.x.ai/docs#getting-started'
|
||||
models: 'https://docs.x.ai/docs/models'
|
||||
}
|
||||
},
|
||||
hyperbolic: {
|
||||
|
||||
@@ -1,65 +1,127 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
export const TranslateLanguageOptions = [
|
||||
export interface TranslateLanguageOption {
|
||||
value: string
|
||||
langCode?: string
|
||||
label: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export const TranslateLanguageOptions: TranslateLanguageOption[] = [
|
||||
{
|
||||
value: 'english',
|
||||
langCode: 'en-us',
|
||||
label: i18n.t('languages.english'),
|
||||
emoji: '🇬🇧'
|
||||
},
|
||||
{
|
||||
value: 'chinese',
|
||||
langCode: 'zh-cn',
|
||||
label: i18n.t('languages.chinese'),
|
||||
emoji: '🇨🇳'
|
||||
},
|
||||
{
|
||||
value: 'chinese-traditional',
|
||||
langCode: 'zh-tw',
|
||||
label: i18n.t('languages.chinese-traditional'),
|
||||
emoji: '🇭🇰'
|
||||
},
|
||||
{
|
||||
value: 'japanese',
|
||||
langCode: 'ja-jp',
|
||||
label: i18n.t('languages.japanese'),
|
||||
emoji: '🇯🇵'
|
||||
},
|
||||
{
|
||||
value: 'korean',
|
||||
langCode: 'ko-kr',
|
||||
label: i18n.t('languages.korean'),
|
||||
emoji: '🇰🇷'
|
||||
},
|
||||
{
|
||||
value: 'russian',
|
||||
label: i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
},
|
||||
{
|
||||
value: 'spanish',
|
||||
label: i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
|
||||
{
|
||||
value: 'french',
|
||||
langCode: 'fr-fr',
|
||||
label: i18n.t('languages.french'),
|
||||
emoji: '🇫🇷'
|
||||
},
|
||||
{
|
||||
value: 'german',
|
||||
langCode: 'de-de',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
},
|
||||
{
|
||||
value: 'italian',
|
||||
langCode: 'it-it',
|
||||
label: i18n.t('languages.italian'),
|
||||
emoji: '🇮🇹'
|
||||
},
|
||||
{
|
||||
value: 'spanish',
|
||||
langCode: 'es-es',
|
||||
label: i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
{
|
||||
value: 'portuguese',
|
||||
langCode: 'pt-pt',
|
||||
label: i18n.t('languages.portuguese'),
|
||||
emoji: '🇵🇹'
|
||||
},
|
||||
{
|
||||
value: 'russian',
|
||||
langCode: 'ru-ru',
|
||||
label: i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
},
|
||||
{
|
||||
value: 'polish',
|
||||
langCode: 'pl-pl',
|
||||
label: i18n.t('languages.polish'),
|
||||
emoji: '🇵🇱'
|
||||
},
|
||||
{
|
||||
value: 'arabic',
|
||||
langCode: 'ar-ar',
|
||||
label: i18n.t('languages.arabic'),
|
||||
emoji: '🇸🇦'
|
||||
},
|
||||
{
|
||||
value: 'german',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
value: 'turkish',
|
||||
langCode: 'tr-tr',
|
||||
label: i18n.t('languages.turkish'),
|
||||
emoji: '🇹🇷'
|
||||
},
|
||||
{
|
||||
value: 'thai',
|
||||
langCode: 'th-th',
|
||||
label: i18n.t('languages.thai'),
|
||||
emoji: '🇹🇭'
|
||||
},
|
||||
{
|
||||
value: 'vietnamese',
|
||||
langCode: 'vi-vn',
|
||||
label: i18n.t('languages.vietnamese'),
|
||||
emoji: '🇻🇳'
|
||||
},
|
||||
{
|
||||
value: 'indonesian',
|
||||
langCode: 'id-id',
|
||||
label: i18n.t('languages.indonesian'),
|
||||
emoji: '🇮🇩'
|
||||
},
|
||||
{
|
||||
value: 'urdu',
|
||||
langCode: 'ur-pk',
|
||||
label: i18n.t('languages.urdu'),
|
||||
emoji: '🇵🇰'
|
||||
},
|
||||
{
|
||||
value: 'malay',
|
||||
langCode: 'ms-my',
|
||||
label: i18n.t('languages.malay'),
|
||||
emoji: '🇲🇾'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
},
|
||||
Tooltip: {
|
||||
fontSize: 13
|
||||
},
|
||||
ColorPicker: {
|
||||
fontFamily: 'var(--code-font-family)'
|
||||
},
|
||||
Segmented: {
|
||||
itemActiveBg: 'var(--color-background-mute)',
|
||||
itemHoverBg: 'var(--color-background-mute)'
|
||||
}
|
||||
},
|
||||
token: {
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
setLaunchToTray,
|
||||
setPinTopicsToTop,
|
||||
setSendMessageShortcut as _setSendMessageShortcut,
|
||||
setShowTokens,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTheme,
|
||||
@@ -83,6 +84,9 @@ export function useSettings() {
|
||||
},
|
||||
setAssistantIconType(assistantIconType: AssistantIconType) {
|
||||
dispatch(setAssistantIconType(assistantIconType))
|
||||
},
|
||||
setShowTokens(showTokens: boolean) {
|
||||
dispatch(setShowTokens(showTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -322,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...",
|
||||
@@ -586,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.",
|
||||
@@ -627,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",
|
||||
@@ -943,8 +952,17 @@
|
||||
"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."
|
||||
|
||||
"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",
|
||||
@@ -1097,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",
|
||||
@@ -1106,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",
|
||||
@@ -1125,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",
|
||||
@@ -1491,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",
|
||||
@@ -1794,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "Any language",
|
||||
"target_language": "Target Language",
|
||||
"alter_language": "Alternative Language",
|
||||
"button.translate": "Translate",
|
||||
"close": "Close",
|
||||
"closed": "Translation closed",
|
||||
@@ -1844,6 +1868,13 @@
|
||||
"show_window": "Show Window",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"update": {
|
||||
"title": "Update",
|
||||
"message": "New version {{version}} is ready, do you want to install it now?",
|
||||
"later": "Later",
|
||||
"install": "Install",
|
||||
"noReleaseNotes": "No release notes"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Selection Assistant",
|
||||
"action": {
|
||||
@@ -1866,6 +1897,9 @@
|
||||
"esc_stop": "Esc: Stop",
|
||||
"c_copy": "C: Copy",
|
||||
"r_regenerate": "R: Regenerate"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "Smart Translation: Content will be translated to the target language first; content already in the target language will be translated to the alternative language"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -322,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": "翻訳中...",
|
||||
@@ -586,7 +587,14 @@
|
||||
"korean": "韓国語",
|
||||
"portuguese": "ポルトガル語",
|
||||
"russian": "ロシア語",
|
||||
"spanish": "スペイン語"
|
||||
"spanish": "スペイン語",
|
||||
"polish": "ポーランド語",
|
||||
"turkish": "トルコ語",
|
||||
"thai": "タイ語",
|
||||
"vietnamese": "ベトナム語",
|
||||
"indonesian": "インドネシア語",
|
||||
"urdu": "ウルドゥー語",
|
||||
"malay": "マレー語"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
@@ -627,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キーです",
|
||||
@@ -943,7 +952,17 @@
|
||||
"text_desc_required": "画像の説明を先に入力してください",
|
||||
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
|
||||
"auto_create_paint": "画像を自動作成",
|
||||
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。"
|
||||
"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": "この概念を説明してください",
|
||||
@@ -1094,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数式に$$を強制使用",
|
||||
@@ -1103,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": "自動バックアップ",
|
||||
@@ -1245,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",
|
||||
@@ -1486,6 +1508,7 @@
|
||||
"advancedSettings": "詳細設定"
|
||||
},
|
||||
"messages.prompt": "プロンプト表示",
|
||||
"messages.tokens": "トークン使用量を表示",
|
||||
"messages.divider": "メッセージ間に区切り線を表示",
|
||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||
"messages.grid_popover_trigger": "グリッド詳細トリガー",
|
||||
@@ -1793,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意の言語",
|
||||
"target_language": "目標言語",
|
||||
"alter_language": "備用言語",
|
||||
"button.translate": "翻訳",
|
||||
"close": "閉じる",
|
||||
"closed": "翻訳は閉じられました",
|
||||
@@ -1843,6 +1868,13 @@
|
||||
"show_window": "ウィンドウを表示",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新",
|
||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||
"later": "後で",
|
||||
"install": "今すぐインストール",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "テキスト選択ツール",
|
||||
"action": {
|
||||
@@ -1865,6 +1897,9 @@
|
||||
"esc_stop": "Escで停止",
|
||||
"c_copy": "Cでコピー",
|
||||
"r_regenerate": "Rで再生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -322,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": "Перевод...",
|
||||
@@ -586,7 +587,14 @@
|
||||
"korean": "Корейский",
|
||||
"portuguese": "Португальский",
|
||||
"russian": "Русский",
|
||||
"spanish": "Испанский"
|
||||
"spanish": "Испанский",
|
||||
"polish": "Польский",
|
||||
"turkish": "Туркменский",
|
||||
"thai": "Тайский",
|
||||
"vietnamese": "Вьетнамский",
|
||||
"indonesian": "Индонезийский",
|
||||
"urdu": "Урду",
|
||||
"malay": "Малайзийский"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
@@ -627,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 ключ",
|
||||
@@ -943,7 +952,17 @@
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения",
|
||||
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
|
||||
"auto_create_paint": "Автоматическое создание изображения",
|
||||
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое."
|
||||
"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": "Объясните мне этот концепт",
|
||||
@@ -1094,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",
|
||||
@@ -1103,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",
|
||||
@@ -1122,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": "Автоматическое резервное копирование",
|
||||
@@ -1486,6 +1508,7 @@
|
||||
"advancedSettings": "Расширенные настройки"
|
||||
},
|
||||
"messages.prompt": "Показывать подсказки",
|
||||
"messages.tokens": "Показать использование токенов",
|
||||
"messages.divider": "Показывать разделитель между сообщениями",
|
||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
|
||||
@@ -1793,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "Любой язык",
|
||||
"target_language": "Целевой язык",
|
||||
"alter_language": "Альтернативный язык",
|
||||
"button.translate": "Перевести",
|
||||
"close": "Закрыть",
|
||||
"closed": "Перевод закрыт",
|
||||
@@ -1843,6 +1868,13 @@
|
||||
"show_window": "Показать окно",
|
||||
"visualization": "Визуализация"
|
||||
},
|
||||
"update": {
|
||||
"title": "Обновление",
|
||||
"message": "Новая версия {{version}} готова, установить сейчас?",
|
||||
"later": "Позже",
|
||||
"install": "Установить",
|
||||
"noReleaseNotes": "Нет заметок об обновлении"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Помощник выбора",
|
||||
"action": {
|
||||
@@ -1865,6 +1897,9 @@
|
||||
"esc_stop": "Esc - остановить",
|
||||
"c_copy": "C - копировать",
|
||||
"r_regenerate": "R - перегенерировать"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -325,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": "导出到语雀",
|
||||
@@ -586,7 +587,14 @@
|
||||
"korean": "韩文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"polish": "波兰文",
|
||||
"turkish": "土耳其文",
|
||||
"thai": "泰文",
|
||||
"vietnamese": "越南文",
|
||||
"indonesian": "印尼文",
|
||||
"urdu": "乌尔都文",
|
||||
"malay": "马来文"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
||||
@@ -627,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 密钥",
|
||||
@@ -646,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": "切换模型回答",
|
||||
@@ -941,9 +950,19 @@
|
||||
"magic_prompt_option_tip": "智能优化放大提示词"
|
||||
},
|
||||
"text_desc_required": "请先输入图片描述",
|
||||
"req_error_text" : "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"auto_create_paint": "自动新建图片",
|
||||
"auto_create_paint_tip": "在图片生成后,会自动新建图片"
|
||||
"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": "帮我解释一下这个概念",
|
||||
@@ -1096,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公式",
|
||||
@@ -1105,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",
|
||||
@@ -1126,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": "自动备份",
|
||||
@@ -1490,6 +1512,7 @@
|
||||
"advancedSettings": "高级设置"
|
||||
},
|
||||
"messages.prompt": "显示提示词",
|
||||
"messages.tokens": "显示Token用量",
|
||||
"messages.divider": "消息分割线",
|
||||
"messages.grid_columns": "消息网格展示列数",
|
||||
"messages.grid_popover_trigger": "网格详情触发",
|
||||
@@ -1793,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意语言",
|
||||
"target_language": "目标语言",
|
||||
"alter_language": "备用语言",
|
||||
"button.translate": "翻译",
|
||||
"close": "关闭",
|
||||
"closed": "翻译已关闭",
|
||||
@@ -1843,6 +1868,13 @@
|
||||
"show_window": "显示窗口",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "发现新版本 {{version}},是否立即安装?",
|
||||
"later": "稍后",
|
||||
"install": "立即安装",
|
||||
"noReleaseNotes": "暂无更新日志"
|
||||
},
|
||||
"selection": {
|
||||
"name": "划词助手",
|
||||
"action": {
|
||||
@@ -1865,6 +1897,9 @@
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 复制",
|
||||
"r_regenerate": "R 重新生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -322,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": "翻譯中...",
|
||||
@@ -586,7 +587,14 @@
|
||||
"korean": "韓文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"polish": "波蘭文",
|
||||
"turkish": "土耳其文",
|
||||
"thai": "泰文",
|
||||
"vietnamese": "越南文",
|
||||
"indonesian": "印尼文",
|
||||
"urdu": "烏爾都文",
|
||||
"malay": "馬來文"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||
@@ -627,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 金鑰",
|
||||
@@ -941,9 +950,19 @@
|
||||
},
|
||||
"rendering_speed": "渲染速度",
|
||||
"text_desc_required": "請先輸入圖片描述",
|
||||
"req_error_text" : "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"auto_create_paint": "自動新增圖片",
|
||||
"auto_create_paint_tip": "圖片生成後,會自動新增圖片"
|
||||
"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": "幫我解釋一下這個概念",
|
||||
@@ -1096,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公式強制使用$$",
|
||||
@@ -1105,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",
|
||||
@@ -1124,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": "自動備份",
|
||||
@@ -1489,6 +1511,7 @@
|
||||
"advancedSettings": "高級設定"
|
||||
},
|
||||
"messages.prompt": "提示詞顯示",
|
||||
"messages.tokens": "Token用量顯示",
|
||||
"messages.divider": "訊息間顯示分隔線",
|
||||
"messages.grid_columns": "訊息網格展示列數",
|
||||
"messages.grid_popover_trigger": "網格詳細資訊觸發",
|
||||
@@ -1793,6 +1816,8 @@
|
||||
},
|
||||
"translate": {
|
||||
"any.language": "任意語言",
|
||||
"target_language": "目標語言",
|
||||
"alter_language": "備用語言",
|
||||
"button.translate": "翻譯",
|
||||
"close": "關閉",
|
||||
"closed": "翻譯已關閉",
|
||||
@@ -1843,6 +1868,13 @@
|
||||
"show_window": "顯示視窗",
|
||||
"visualization": "視覺化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||
"later": "稍後",
|
||||
"install": "立即安裝",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "劃詞助手",
|
||||
"action": {
|
||||
@@ -1865,6 +1897,9 @@
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 複製",
|
||||
"r_regenerate": "R 重新生成"
|
||||
},
|
||||
"translate": {
|
||||
"smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -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",
|
||||
@@ -1657,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",
|
||||
@@ -1656,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",
|
||||
@@ -1657,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",
|
||||
@@ -1659,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import KeyvStorage from '@kangfenmao/keyv-storage'
|
||||
|
||||
import { APP_IS_CUSTOM_PRODUCT, APP_NAME } from './config/env'
|
||||
import { startAutoSync } from './services/BackupService'
|
||||
import { startNutstoreAutoSync } from './services/NutstoreService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
@@ -30,3 +31,7 @@ function initStoreSync() {
|
||||
initKeyv()
|
||||
initAutoSync()
|
||||
initStoreSync()
|
||||
|
||||
if (APP_IS_CUSTOM_PRODUCT) {
|
||||
document.title = APP_NAME
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c0:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
color: var(--color-text-1);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.c3 {
|
||||
font-size: 12px;
|
||||
color: var(--color-link);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c3:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
<div
|
||||
data-color="var(--color-background-mute)"
|
||||
data-placement="top"
|
||||
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
|
||||
data-testid="tooltip-wrapper"
|
||||
>
|
||||
<span>
|
||||
Test content
|
||||
</span>
|
||||
<div
|
||||
data-testid="tooltip-content"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Open Example Article in new tab"
|
||||
class="c0"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
alt="Example Article"
|
||||
data-testid="mock-favicon"
|
||||
hostname="example.com"
|
||||
/>
|
||||
<div
|
||||
aria-level="3"
|
||||
class="c1"
|
||||
role="heading"
|
||||
title="Example Article"
|
||||
>
|
||||
Example Article
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Citation content"
|
||||
class="c2"
|
||||
role="article"
|
||||
>
|
||||
This is the article content for testing purposes.
|
||||
</div>
|
||||
<div
|
||||
aria-label="Visit example.com"
|
||||
class="c3"
|
||||
role="button"
|
||||
>
|
||||
example.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,39 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Markdown > rendering > should match snapshot 1`] = `
|
||||
<div
|
||||
class="markdown"
|
||||
data-testid="markdown-content"
|
||||
>
|
||||
# Test Markdown
|
||||
|
||||
This is **bold** text.
|
||||
<span
|
||||
data-testid="has-link-component"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<div
|
||||
data-testid="has-code-component"
|
||||
>
|
||||
<div
|
||||
data-id="code-block-1"
|
||||
data-testid="code-block"
|
||||
>
|
||||
<code>
|
||||
test code
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-testid="has-img-component"
|
||||
>
|
||||
img
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -5,7 +5,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
|
||||
import { type Model, WebSearchSource } from '@renderer/types'
|
||||
import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage'
|
||||
import { cleanMarkdownContent } from '@renderer/utils/formats'
|
||||
import { cleanMarkdownContent, encodeHTML } from '@renderer/utils/formats'
|
||||
import { Flex } from 'antd'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@@ -13,18 +13,6 @@ import styled from 'styled-components'
|
||||
|
||||
import Markdown from '../../Markdown/Markdown'
|
||||
|
||||
// HTML实体编码辅助函数
|
||||
const encodeHTML = (str: string): string => {
|
||||
const entities: { [key: string]: string } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return str.replace(/[&<>"']/g, (match) => entities[match])
|
||||
}
|
||||
|
||||
interface Props {
|
||||
block: MainTextMessageBlock
|
||||
citationBlockId?: string
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import type { Model } from '@renderer/types'
|
||||
import { WebSearchSource } from '@renderer/types'
|
||||
import type { MainTextMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MainTextBlock from '../MainTextBlock'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseSelector = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', async () => {
|
||||
const actual = await import('react-redux')
|
||||
return {
|
||||
...actual,
|
||||
useSelector: () => mockUseSelector(),
|
||||
useDispatch: () => vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock store to avoid withTypes issues
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useAppSelector: vi.fn(),
|
||||
useAppDispatch: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
// Mock store selectors
|
||||
vi.mock('@renderer/store/messageBlock', async () => {
|
||||
const actual = await import('@renderer/store/messageBlock')
|
||||
return {
|
||||
...actual,
|
||||
selectFormattedCitationsByBlockId: vi.fn(() => [])
|
||||
}
|
||||
})
|
||||
|
||||
// Mock utilities
|
||||
vi.mock('@renderer/utils/formats', () => ({
|
||||
cleanMarkdownContent: vi.fn((content: string) => content),
|
||||
encodeHTML: vi.fn((content: string) => content.replace(/"/g, '"'))
|
||||
}))
|
||||
|
||||
// Mock services
|
||||
vi.mock('@renderer/services/ModelService', () => ({
|
||||
getModelUniqId: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock Markdown component
|
||||
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ block }: any) => (
|
||||
<div data-testid="mock-markdown" data-content={block.content}>
|
||||
Markdown: {block.content}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('MainTextBlock', () => {
|
||||
// Get references to mocked modules
|
||||
let mockGetModelUniqId: any
|
||||
let mockCleanMarkdownContent: any
|
||||
|
||||
// Create a mock store for Provider
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
messageBlocks: (state = {}) => state
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Get the mocked functions
|
||||
const { getModelUniqId } = await import('@renderer/services/ModelService')
|
||||
const { cleanMarkdownContent } = await import('@renderer/utils/formats')
|
||||
mockGetModelUniqId = getModelUniqId as any
|
||||
mockCleanMarkdownContent = cleanMarkdownContent as any
|
||||
|
||||
// Default mock implementations
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
mockUseSelector.mockReturnValue([]) // Empty citations by default
|
||||
mockGetModelUniqId.mockImplementation((model: Model) => `${model.id}-${model.name}`)
|
||||
})
|
||||
|
||||
// Test data factory functions
|
||||
const createMainTextBlock = (overrides: Partial<MainTextMessageBlock> = {}): MainTextMessageBlock => ({
|
||||
id: 'test-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.MAIN_TEXT,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'Test content',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model =>
|
||||
({
|
||||
id: 'test-model-1',
|
||||
name: 'Test Model',
|
||||
provider: 'test-provider',
|
||||
...overrides
|
||||
}) as Model
|
||||
|
||||
// Helper functions
|
||||
const renderMainTextBlock = (props: {
|
||||
block: MainTextMessageBlock
|
||||
role: 'user' | 'assistant'
|
||||
mentions?: Model[]
|
||||
citationBlockId?: string
|
||||
}) => {
|
||||
return render(
|
||||
<Provider store={mockStore}>
|
||||
<MainTextBlock {...props} />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// User-focused query helpers
|
||||
const getRenderedMarkdown = () => screen.queryByTestId('mock-markdown')
|
||||
const getRenderedPlainText = () => screen.queryByRole('paragraph')
|
||||
const getMentionElements = () => screen.queryAllByText(/@/)
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render in markdown mode for assistant messages', () => {
|
||||
const block = createMainTextBlock({ content: 'Assistant response' })
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
// User should see markdown-rendered content
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown: Assistant response')).toBeInTheDocument()
|
||||
expect(getRenderedPlainText()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in plain text mode for user messages when setting disabled', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
const block = createMainTextBlock({ content: 'User message\nWith line breaks' })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
// User should see plain text with preserved formatting
|
||||
expect(getRenderedPlainText()).toBeInTheDocument()
|
||||
expect(getRenderedPlainText()!.textContent).toBe('User message\nWith line breaks')
|
||||
expect(getRenderedMarkdown()).not.toBeInTheDocument()
|
||||
|
||||
// Check preserved whitespace
|
||||
const textElement = getRenderedPlainText()!
|
||||
expect(textElement).toHaveStyle({ whiteSpace: 'pre-wrap' })
|
||||
})
|
||||
|
||||
it('should render user messages as markdown when setting enabled', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
|
||||
const block = createMainTextBlock({ content: 'User **bold** content' })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown: User **bold** content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve complex formatting in plain text mode', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
const complexContent = `Line 1
|
||||
Indented line
|
||||
**Bold not parsed**
|
||||
- List not parsed`
|
||||
|
||||
const block = createMainTextBlock({ content: complexContent })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
const textElement = getRenderedPlainText()!
|
||||
expect(textElement.textContent).toBe(complexContent)
|
||||
expect(textElement).toHaveClass('markdown')
|
||||
})
|
||||
|
||||
it('should handle empty content gracefully', () => {
|
||||
const block = createMainTextBlock({ content: '' })
|
||||
expect(() => {
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mentions functionality', () => {
|
||||
it('should display model mentions when provided', () => {
|
||||
const block = createMainTextBlock({ content: 'Content with mentions' })
|
||||
const mentions = [
|
||||
createModel({ id: 'model-1', name: 'deepseek-r1' }),
|
||||
createModel({ id: 'model-2', name: 'claude-sonnet-4' })
|
||||
]
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions })
|
||||
|
||||
// User should see mention tags
|
||||
expect(screen.getByText('@deepseek-r1')).toBeInTheDocument()
|
||||
expect(screen.getByText('@claude-sonnet-4')).toBeInTheDocument()
|
||||
|
||||
// Service should be called for model processing
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledTimes(2)
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[0])
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[1])
|
||||
})
|
||||
|
||||
it('should not display mentions when none provided', () => {
|
||||
const block = createMainTextBlock({ content: 'No mentions content' })
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions: [] })
|
||||
expect(getMentionElements()).toHaveLength(0)
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions: undefined })
|
||||
expect(getMentionElements()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should style mentions correctly for user visibility', () => {
|
||||
const block = createMainTextBlock({ content: 'Styled mentions test' })
|
||||
const mentions = [createModel({ id: 'model-1', name: 'Test Model' })]
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions })
|
||||
|
||||
const mentionElement = screen.getByText('@Test Model')
|
||||
expect(mentionElement).toHaveStyle({ color: 'var(--color-link)' })
|
||||
|
||||
// Check container layout
|
||||
const container = mentionElement.closest('[style*="gap"]')
|
||||
expect(container).toHaveStyle({
|
||||
gap: '8px',
|
||||
marginBottom: '10px'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('content processing', () => {
|
||||
it('should filter tool_use tags from content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'single tool_use tag',
|
||||
content: 'Before <tool_use>tool content</tool_use> after',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiple tool_use tags',
|
||||
content: 'Start <tool_use>tool1</tool_use> middle <tool_use>tool2</tool_use> end',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiline tool_use',
|
||||
content: `Text before
|
||||
<tool_use>
|
||||
multiline
|
||||
tool content
|
||||
</tool_use>
|
||||
text after`,
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'malformed tool_use',
|
||||
content: 'Before <tool_use>unclosed tag',
|
||||
expectsFiltering: false // Should preserve malformed tags
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ content, expectsFiltering }) => {
|
||||
const block = createMainTextBlock({ content })
|
||||
const { unmount } = renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
const renderedContent = getRenderedMarkdown()
|
||||
expect(renderedContent).toBeInTheDocument()
|
||||
|
||||
if (expectsFiltering) {
|
||||
// Check that tool_use content is not visible to user
|
||||
expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should process content through format utilities', () => {
|
||||
const block = createMainTextBlock({ content: 'Content to process' })
|
||||
mockUseSelector.mockReturnValue([{ id: '1', content: 'Citation content', number: 1 }])
|
||||
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'test-citations'
|
||||
})
|
||||
|
||||
// Verify utility functions are called
|
||||
expect(mockCleanMarkdownContent).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('citation integration', () => {
|
||||
it('should display content normally when no citations are present', () => {
|
||||
const block = createMainTextBlock({ content: 'Content without citations' })
|
||||
mockUseSelector.mockReturnValue([])
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
expect(screen.getByText('Markdown: Content without citations')).toBeInTheDocument()
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with citation system when citations exist', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Content with citation [1]',
|
||||
citationReferences: [{ citationBlockSource: WebSearchSource.OPENAI }]
|
||||
})
|
||||
|
||||
const mockCitations = [
|
||||
{
|
||||
id: '1',
|
||||
number: 1,
|
||||
url: 'https://example.com',
|
||||
title: 'Example Citation',
|
||||
content: 'Citation content'
|
||||
}
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(mockCitations)
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'citation-test'
|
||||
})
|
||||
|
||||
// Verify citation integration works
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
|
||||
// Verify content processing occurred
|
||||
expect(mockCleanMarkdownContent).toHaveBeenCalledWith('Citation content')
|
||||
})
|
||||
|
||||
it('should handle different citation sources correctly', () => {
|
||||
const testSources = [WebSearchSource.OPENAI, 'DEFAULT' as any, 'CUSTOM' as any]
|
||||
|
||||
testSources.forEach((source) => {
|
||||
const block = createMainTextBlock({
|
||||
content: `Citation test for ${source}`,
|
||||
citationReferences: [{ citationBlockSource: source }]
|
||||
})
|
||||
|
||||
mockUseSelector.mockReturnValue([{ id: '1', number: 1, url: 'https://test.com', title: 'Test' }])
|
||||
|
||||
const { unmount } = renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: `test-${source}`
|
||||
})
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple citations gracefully', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Multiple citations [1] and [2]',
|
||||
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
|
||||
})
|
||||
|
||||
const multipleCitations = [
|
||||
{ id: '1', number: 1, url: 'https://first.com', title: 'First' },
|
||||
{ id: '2', number: 2, url: 'https://second.com', title: 'Second' }
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(multipleCitations)
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'multi-test' })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings integration', () => {
|
||||
it('should respond to markdown rendering setting changes', () => {
|
||||
const block = createMainTextBlock({ content: 'Settings test content' })
|
||||
|
||||
// Test with markdown enabled
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
|
||||
const { unmount } = renderMainTextBlock({ block, role: 'user' })
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test with markdown disabled
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
expect(getRenderedPlainText()).toBeInTheDocument()
|
||||
expect(getRenderedMarkdown()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and robustness', () => {
|
||||
it('should handle large content without performance issues', () => {
|
||||
const largeContent = 'A'.repeat(1000) + ' with citations [1]'
|
||||
const block = createMainTextBlock({ content: largeContent })
|
||||
|
||||
const largeCitations = [
|
||||
{
|
||||
id: '1',
|
||||
number: 1,
|
||||
url: 'https://large.com',
|
||||
title: 'Large',
|
||||
content: 'B'.repeat(500)
|
||||
}
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(largeCitations)
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'large-test'
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters and Unicode gracefully', () => {
|
||||
const specialContent = '测试内容 🚀 📝 ✨ <>&"\'` [1]'
|
||||
const block = createMainTextBlock({ content: specialContent })
|
||||
|
||||
mockUseSelector.mockReturnValue([{ id: '1', number: 1, title: '特殊字符测试', content: '内容 with 🎉' }])
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'unicode-test'
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null and undefined values gracefully', () => {
|
||||
const block = createMainTextBlock({ content: 'Null safety test' })
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
mentions: undefined,
|
||||
citationBlockId: undefined
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should integrate properly with Redux store', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Redux integration test',
|
||||
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
|
||||
})
|
||||
|
||||
mockUseSelector.mockReturnValue([])
|
||||
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'redux-test' })
|
||||
|
||||
// Verify Redux integration
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ThinkingBlock from '../ThinkingBlock'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseTranslation = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation()
|
||||
}))
|
||||
|
||||
// Mock antd components
|
||||
vi.mock('antd', () => ({
|
||||
Collapse: ({ activeKey, onChange, items, className, size, expandIconPosition }: any) => (
|
||||
<div
|
||||
data-testid="collapse-container"
|
||||
className={className}
|
||||
data-active-key={activeKey}
|
||||
data-size={size}
|
||||
data-expand-icon-position={expandIconPosition}>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
|
||||
<div data-testid={`collapse-header-${item.key}`} onClick={() => onChange()}>
|
||||
{item.label}
|
||||
</div>
|
||||
{activeKey === item.key && <div data-testid={`collapse-content-${item.key}`}>{item.children}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ title, children, mouseEnterDelay }: any) => (
|
||||
<div data-testid="tooltip" title={title} data-mouse-enter-delay={mouseEnterDelay}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
CheckOutlined: ({ style }: any) => (
|
||||
<span data-testid="check-icon" style={style}>
|
||||
✓
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Lightbulb: ({ size }: any) => (
|
||||
<span data-testid="lightbulb-icon" data-size={size}>
|
||||
💡
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
|
||||
// Mock motion
|
||||
vi.mock('motion/react', () => ({
|
||||
motion: {
|
||||
span: ({ children, variants, animate, initial, style }: any) => (
|
||||
<span
|
||||
data-testid="motion-span"
|
||||
data-variants={JSON.stringify(variants)}
|
||||
data-animate={animate}
|
||||
data-initial={initial}
|
||||
style={style}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock motion variants
|
||||
vi.mock('@renderer/utils/motionVariants', () => ({
|
||||
lightbulbVariants: {
|
||||
active: { rotate: 10, scale: 1.1 },
|
||||
idle: { rotate: 0, scale: 1 }
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Markdown component
|
||||
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ block }: any) => (
|
||||
<div data-testid="mock-markdown" data-block-id={block.id}>
|
||||
Markdown: {block.content}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('ThinkingBlock', () => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Default mock implementations
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === 'chat.thinking' && params?.seconds) {
|
||||
return `Thinking... ${params.seconds}s`
|
||||
}
|
||||
if (key === 'chat.deeply_thought' && params?.seconds) {
|
||||
return `Thought for ${params.seconds}s`
|
||||
}
|
||||
if (key === 'message.copied') return 'Copied!'
|
||||
if (key === 'message.copy.failed') return 'Copy failed'
|
||||
if (key === 'common.copy') return 'Copy'
|
||||
return key
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Test data factory functions
|
||||
const createThinkingBlock = (overrides: Partial<ThinkingMessageBlock> = {}): ThinkingMessageBlock => ({
|
||||
id: 'test-thinking-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.THINKING,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'I need to think about this carefully...',
|
||||
thinking_millsec: 5000,
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const renderThinkingBlock = (block: ThinkingMessageBlock) => {
|
||||
return render(<ThinkingBlock block={block} />)
|
||||
}
|
||||
|
||||
const getThinkingContent = () => screen.queryByText(/markdown:/i)
|
||||
const getCopyButton = () => screen.queryByRole('button', { name: /copy/i })
|
||||
const getThinkingTimeText = () => screen.getByText(/thinking|thought/i)
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render thinking content when provided', () => {
|
||||
const block = createThinkingBlock({ content: 'Deep thoughts about AI' })
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// User should see the thinking content
|
||||
expect(screen.getByText('Markdown: Deep thoughts about AI')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('lightbulb-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when content is empty', () => {
|
||||
const testCases = ['', undefined]
|
||||
|
||||
testCases.forEach((content) => {
|
||||
const block = createThinkingBlock({ content: content as any })
|
||||
const { container, unmount } = renderThinkingBlock(block)
|
||||
expect(container.firstChild).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show copy button only when thinking is complete', () => {
|
||||
// When thinking (streaming)
|
||||
const thinkingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(thinkingBlock)
|
||||
|
||||
expect(getCopyButton()).not.toBeInTheDocument()
|
||||
|
||||
// When thinking is complete
|
||||
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
expect(getCopyButton()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const block = createThinkingBlock()
|
||||
const { container } = renderThinkingBlock(block)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinking time display', () => {
|
||||
it('should display appropriate time messages based on status', () => {
|
||||
// Completed thinking
|
||||
const completedBlock = createThinkingBlock({
|
||||
thinking_millsec: 3500,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const { unmount } = renderThinkingBlock(completedBlock)
|
||||
|
||||
const timeText = getThinkingTimeText()
|
||||
expect(timeText).toHaveTextContent('3.5s')
|
||||
expect(timeText).toHaveTextContent('Thought for')
|
||||
unmount()
|
||||
|
||||
// Active thinking
|
||||
const thinkingBlock = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
renderThinkingBlock(thinkingBlock)
|
||||
|
||||
const activeTimeText = getThinkingTimeText()
|
||||
expect(activeTimeText).toHaveTextContent('1.0s')
|
||||
expect(activeTimeText).toHaveTextContent('Thinking...')
|
||||
})
|
||||
|
||||
it('should update thinking time in real-time when active', () => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// Initial state
|
||||
expect(getThinkingTimeText()).toHaveTextContent('1.0s')
|
||||
|
||||
// After time passes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(getThinkingTimeText()).toHaveTextContent('1.5s')
|
||||
})
|
||||
|
||||
it('should handle extreme thinking times correctly', () => {
|
||||
const testCases = [
|
||||
{ thinking_millsec: 0, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: undefined, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day
|
||||
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
|
||||
]
|
||||
|
||||
testCases.forEach(({ thinking_millsec, expectedTime }) => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
expect(getThinkingTimeText()).toHaveTextContent(expectedTime)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop timer when thinking status changes to completed', () => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
const { rerender } = renderThinkingBlock(block)
|
||||
|
||||
// Advance timer while thinking
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(getThinkingTimeText()).toHaveTextContent('2.0s')
|
||||
|
||||
// Complete thinking
|
||||
const completedBlock = createThinkingBlock({
|
||||
thinking_millsec: 1000, // Original time doesn't matter
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
// Timer should stop - text should change from "Thinking..." to "Thought for"
|
||||
const timeText = getThinkingTimeText()
|
||||
expect(timeText).toHaveTextContent('Thought for')
|
||||
expect(timeText).toHaveTextContent('2.0s')
|
||||
|
||||
// Further time advancement shouldn't change the display
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(timeText).toHaveTextContent('2.0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapse behavior', () => {
|
||||
it('should respect auto-collapse setting for initial state', () => {
|
||||
// Test expanded by default (auto-collapse disabled)
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
const block = createThinkingBlock()
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
// Content should be visible when expanded
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test collapsed by default (auto-collapse enabled)
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: true
|
||||
})
|
||||
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// Content should not be visible when collapsed
|
||||
expect(getThinkingContent()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should auto-collapse when thinking completes if setting enabled', () => {
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: true
|
||||
})
|
||||
|
||||
const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(streamingBlock)
|
||||
|
||||
// Should be expanded while thinking
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
|
||||
// Stop thinking
|
||||
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
// Should be collapsed after thinking completes
|
||||
expect(getThinkingContent()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('font and styling', () => {
|
||||
it('should apply font settings to thinking content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
settings: { messageFont: 'serif', fontSize: 16 },
|
||||
expectedFont: 'var(--font-family-serif)',
|
||||
expectedSize: '16px'
|
||||
},
|
||||
{
|
||||
settings: { messageFont: 'sans-serif', fontSize: 14 },
|
||||
expectedFont: 'var(--font-family)',
|
||||
expectedSize: '14px'
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ settings, expectedFont, expectedSize }) => {
|
||||
mockUseSettings.mockReturnValue({
|
||||
...settings,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
const block = createThinkingBlock()
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
// Find the styled content container
|
||||
const contentContainer = screen.getByTestId('collapse-content-thought')
|
||||
const styledDiv = contentContainer.querySelector('div')
|
||||
|
||||
expect(styledDiv).toHaveStyle({
|
||||
fontFamily: expectedFont,
|
||||
fontSize: expectedSize
|
||||
})
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration and edge cases', () => {
|
||||
it('should handle content updates correctly', () => {
|
||||
const block1 = createThinkingBlock({ content: 'Original thought' })
|
||||
const { rerender } = renderThinkingBlock(block1)
|
||||
|
||||
expect(screen.getByText('Markdown: Original thought')).toBeInTheDocument()
|
||||
|
||||
const block2 = createThinkingBlock({ content: 'Updated thought' })
|
||||
rerender(<ThinkingBlock block={block2} />)
|
||||
|
||||
expect(screen.getByText('Markdown: Updated thought')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Markdown: Original thought')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clean up timer on unmount', () => {
|
||||
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
|
||||
unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid status changes gracefully', () => {
|
||||
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(block)
|
||||
|
||||
// Rapidly toggle between states
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.STREAMING })} />)
|
||||
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.SUCCESS })} />)
|
||||
}
|
||||
|
||||
// Should still render correctly
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
expect(getCopyButton()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.c3 {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.c3:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.c3:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.c3 .iconfont {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 message-thought-container"
|
||||
data-active-key="thought"
|
||||
data-expand-icon-position="end"
|
||||
data-size="small"
|
||||
data-testid="collapse-container"
|
||||
>
|
||||
<div
|
||||
data-testid="collapse-item-thought"
|
||||
>
|
||||
<div
|
||||
data-testid="collapse-header-thought"
|
||||
>
|
||||
<div
|
||||
class="c1"
|
||||
>
|
||||
<span
|
||||
data-animate="idle"
|
||||
data-initial="idle"
|
||||
data-testid="motion-span"
|
||||
data-variants="{"active":{"rotate":10,"scale":1.1},"idle":{"rotate":0,"scale":1}}"
|
||||
style="height: 18px;"
|
||||
>
|
||||
<span
|
||||
data-size="18"
|
||||
data-testid="lightbulb-icon"
|
||||
>
|
||||
💡
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="c2"
|
||||
>
|
||||
Thought for 5.0s
|
||||
</span>
|
||||
<div
|
||||
data-mouse-enter-delay="0.8"
|
||||
data-testid="tooltip"
|
||||
title="Copy"
|
||||
>
|
||||
<button
|
||||
aria-label="Copy"
|
||||
class="c3 message-action-button"
|
||||
>
|
||||
<i
|
||||
class="iconfont icon-copy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="collapse-content-thought"
|
||||
>
|
||||
<div
|
||||
style="font-family: var(--font-family); font-size: 14px;"
|
||||
>
|
||||
<div
|
||||
data-block-id="test-thinking-block-1"
|
||||
data-testid="mock-markdown"
|
||||
>
|
||||
Markdown:
|
||||
I need to think about this carefully...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -18,7 +18,17 @@ import styled from 'styled-components'
|
||||
import ChatFlowHistory from './ChatFlowHistory'
|
||||
|
||||
// Exclude some areas from the navigation
|
||||
const EXCLUDED_SELECTORS = ['.MessageFooter', '.code-toolbar', '.ant-collapse-header', '.group-menu-bar', '.code-block']
|
||||
const EXCLUDED_SELECTORS = [
|
||||
'.MessageFooter',
|
||||
'.code-toolbar',
|
||||
'.ant-collapse-header',
|
||||
'.group-menu-bar',
|
||||
'.code-block',
|
||||
'.message-editor'
|
||||
]
|
||||
|
||||
// Gap between the navigation bar and the right element
|
||||
const RIGHT_GAP = 16
|
||||
|
||||
interface ChatNavigationProps {
|
||||
containerId: string
|
||||
@@ -264,10 +274,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
const triggerWidth = 60 // Same as the width in styled component
|
||||
|
||||
// Safe way to calculate position when using calc expressions
|
||||
let rightOffset = 16 // Default right offset
|
||||
let rightOffset = RIGHT_GAP // Default right offset
|
||||
if (showRightTopics) {
|
||||
// When topics are shown on right, we need to account for topic list width
|
||||
rightOffset = 16 + 300 // Assuming topic list width is 300px, adjust if different
|
||||
rightOffset += 275 // --topic-list-width
|
||||
}
|
||||
|
||||
const rightPosition = window.innerWidth - rightOffset - triggerWidth
|
||||
@@ -280,7 +290,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
const isInTriggerArea =
|
||||
!isInExcludedArea &&
|
||||
e.clientX > rightPosition &&
|
||||
e.clientX < rightPosition + triggerWidth &&
|
||||
e.clientX < rightPosition + triggerWidth + RIGHT_GAP &&
|
||||
e.clientY > topPosition &&
|
||||
e.clientY < topPosition + height
|
||||
|
||||
@@ -412,7 +422,7 @@ interface NavigationContainerProps {
|
||||
|
||||
const NavigationContainer = styled.div<NavigationContainerProps>`
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
right: ${RIGHT_GAP}px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(${(props) => (props.$isVisible ? 0 : '100%')});
|
||||
z-index: 999;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { classNames } from '@renderer/utils'
|
||||
@@ -52,7 +53,7 @@ const MessageItem: FC<Props> = ({
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings()
|
||||
const { editMessageBlocks, resendUserMessageWithEdit } = useMessageOperations(topic)
|
||||
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { editingMessageId, stopEditing } = useMessageEditing()
|
||||
const isEditing = editingMessageId === message.id
|
||||
@@ -69,14 +70,15 @@ const MessageItem: FC<Props> = ({
|
||||
const handleEditSave = useCallback(
|
||||
async (blocks: MessageBlock[]) => {
|
||||
try {
|
||||
console.log('after save blocks', blocks)
|
||||
await editMessageBlocks(message.id, blocks)
|
||||
const usage = await estimateMessageUsage(message)
|
||||
editMessage(message.id, { usage: usage })
|
||||
stopEditing()
|
||||
} catch (error) {
|
||||
console.error('Failed to save message blocks:', error)
|
||||
}
|
||||
},
|
||||
[message, editMessageBlocks, stopEditing]
|
||||
[message, editMessageBlocks, stopEditing, editMessage]
|
||||
)
|
||||
|
||||
const handleEditResend = useCallback(
|
||||
|
||||
@@ -172,7 +172,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContainer onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
|
||||
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
|
||||
{editedBlocks
|
||||
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
.map((block) => (
|
||||
|
||||
@@ -287,7 +287,7 @@ const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageSty
|
||||
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
|
||||
grid-template-columns: repeat(
|
||||
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
|
||||
minmax(550px, 1fr)
|
||||
minmax(480px, 1fr)
|
||||
);
|
||||
@media (max-width: 800px) {
|
||||
grid-template-columns: repeat(
|
||||
|
||||
@@ -16,10 +16,10 @@ import type { Message } from '@renderer/types/newMessage'
|
||||
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils'
|
||||
import {
|
||||
exportMarkdownToJoplin,
|
||||
exportMarkdownToNotion,
|
||||
exportMarkdownToSiyuan,
|
||||
exportMarkdownToYuque,
|
||||
exportMessageAsMarkdown,
|
||||
exportMessageToNotion,
|
||||
messageToMarkdown
|
||||
} from '@renderer/utils/export'
|
||||
// import { withMessageThought } from '@renderer/utils/formats'
|
||||
@@ -244,7 +244,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
onClick: async () => {
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToNotion(title, markdown)
|
||||
exportMessageToNotion(title, markdown, message)
|
||||
}
|
||||
},
|
||||
exportMenuOptions.yuque && {
|
||||
@@ -260,9 +260,8 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.export.obsidian'),
|
||||
key: 'obsidian',
|
||||
onClick: async () => {
|
||||
const markdown = messageToMarkdown(message)
|
||||
const title = topic.name?.replace(/\//g, '_') || 'Untitled'
|
||||
await ObsidianExportPopup.show({ title, markdown, processingMethod: '1' })
|
||||
await ObsidianExportPopup.show({ title, message, processingMethod: '1' })
|
||||
}
|
||||
},
|
||||
exportMenuOptions.joplin && {
|
||||
@@ -270,8 +269,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
key: 'joplin',
|
||||
onClick: async () => {
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToJoplin(title, markdown)
|
||||
exportMarkdownToJoplin(title, message)
|
||||
}
|
||||
},
|
||||
exportMenuOptions.siyuan && {
|
||||
|
||||
@@ -59,6 +59,7 @@ const CheckboxWrapper = styled.div`
|
||||
|
||||
const MessageContent = styled.div<{ isMultiSelectMode: boolean }>`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
${(props) => props.isMultiSelectMode && 'margin-left: 8px;'}
|
||||
`
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { Popover } from 'antd'
|
||||
@@ -11,6 +12,7 @@ interface MessageTokensProps {
|
||||
}
|
||||
|
||||
const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
const { showTokens } = useSettings()
|
||||
// const { generating } = useRuntime()
|
||||
const locateMessage = () => {
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
|
||||
@@ -23,7 +25,7 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
Tokens: {message?.usage?.total_tokens}
|
||||
{showTokens && `Tokens: ${message?.usage?.total_tokens}`}
|
||||
</MessageMetadata>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +56,7 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
{hasMetrics ? (
|
||||
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
|
||||
{tokensInfo}
|
||||
{showTokens && tokensInfo}
|
||||
</Popover>
|
||||
) : (
|
||||
tokensInfo
|
||||
|
||||
@@ -34,7 +34,7 @@ const Container = styled.div<{ $isDark: boolean }>`
|
||||
margin: 5px 20px 0 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const Text = styled.div`
|
||||
|
||||
@@ -5,6 +5,7 @@ import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
|
||||
const { assistant } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const isFullscreen = useFullscreen()
|
||||
const { topicPosition, sidebarIcons, narrowMode } = useSettings()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -90,7 +92,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
{showAssistants && (
|
||||
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={handleToggleShowAssistants} style={{ marginLeft: isMac ? 16 : 0 }}>
|
||||
<NavbarIcon onClick={handleToggleShowAssistants} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
@@ -113,7 +115,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
|
||||
<NavbarIcon
|
||||
onClick={() => toggleShowAssistants()}
|
||||
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}>
|
||||
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}>
|
||||
<PanelRightClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
@@ -123,7 +125,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon
|
||||
onClick={() => toggleShowAssistants()}
|
||||
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}
|
||||
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}
|
||||
onMouseOut={() => setSidebarHideCooldown(false)}>
|
||||
<PanelRightClose size={18} />
|
||||
</NavbarIcon>
|
||||
|
||||
@@ -58,12 +58,14 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
{getGroupedAssistants.map((group) => (
|
||||
<TagsContainer key={group.tag}>
|
||||
<GroupTitle>
|
||||
<Tooltip title={group.tag}>
|
||||
<GroupTitleName>{group.tag}</GroupTitleName>
|
||||
</Tooltip>
|
||||
<Divider style={{ margin: '12px 0' }}></Divider>
|
||||
</GroupTitle>
|
||||
{group.tag !== t('assistants.tags.untagged') && (
|
||||
<GroupTitle>
|
||||
<Tooltip title={group.tag}>
|
||||
<GroupTitleName>{group.tag}</GroupTitleName>
|
||||
</Tooltip>
|
||||
<Divider style={{ margin: '12px 0' }}></Divider>
|
||||
</GroupTitle>
|
||||
)}
|
||||
{group.assistants.map((assistant) => (
|
||||
<AssistantItem
|
||||
key={assistant.id}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
setShowInputEstimatedTokens,
|
||||
setShowMessageDivider,
|
||||
setShowPrompt,
|
||||
setShowTokens,
|
||||
setShowTranslateConfirm,
|
||||
setThoughtAutoCollapse
|
||||
} from '@renderer/store/settings'
|
||||
@@ -59,7 +60,7 @@ import {
|
||||
} from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
|
||||
import { CircleHelp, RotateCcw, Settings2 } from 'lucide-react'
|
||||
import { CircleHelp, Settings2 } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -71,7 +72,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const SettingsTab: FC<Props> = (props) => {
|
||||
const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id)
|
||||
const { assistant, updateAssistantSettings } = useAssistant(props.assistant.id)
|
||||
const { provider } = useProvider(assistant.model.provider)
|
||||
|
||||
const { messageStyle, fontSize, language } = useSettings()
|
||||
@@ -113,7 +114,8 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
messageNavigation,
|
||||
enableQuickPanelTriggers,
|
||||
enableBackspaceDeleteModel,
|
||||
showTranslateConfirm
|
||||
showTranslateConfirm,
|
||||
showTokens
|
||||
} = useSettings()
|
||||
|
||||
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
|
||||
@@ -138,24 +140,6 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
setTemperature(DEFAULT_TEMPERATURE)
|
||||
setContextCount(DEFAULT_CONTEXTCOUNT)
|
||||
updateAssistant({
|
||||
...assistant,
|
||||
settings: {
|
||||
...assistant.settings,
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
contextCount: DEFAULT_CONTEXTCOUNT,
|
||||
enableMaxTokens: false,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
streamOutput: true,
|
||||
hideMessages: false,
|
||||
customParameters: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const codeStyle = useMemo(() => {
|
||||
return codeEditor.enabled
|
||||
? theme === ThemeMode.light
|
||||
@@ -209,14 +193,6 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
defaultExpanded={true}
|
||||
extra={
|
||||
<HStack alignItems="center" gap={2}>
|
||||
<Tooltip title={t('chat.settings.reset')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
icon={<RotateCcw size={20} style={{ cursor: 'pointer', padding: '0 3px', opacity: 0.8 }} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -336,6 +312,11 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Switch size="small" checked={showPrompt} onChange={(checked) => dispatch(setShowPrompt(checked))} />
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.tokens')}</SettingRowTitleSmall>
|
||||
<Switch size="small" checked={showTokens} onChange={(checked) => dispatch(setShowTokens(checked))} />
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.divider')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
@@ -707,6 +688,7 @@ const Container = styled(Scrollbar)`
|
||||
padding-right: 0;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 10px;
|
||||
margin-top: 3px;
|
||||
`
|
||||
|
||||
const SettingRowTitleSmall = styled(SettingRowTitle)`
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
MenuOutlined,
|
||||
PushpinOutlined,
|
||||
QuestionCircleOutlined,
|
||||
UploadOutlined
|
||||
@@ -54,7 +55,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
const { assistants } = useAssistants()
|
||||
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
|
||||
const { t } = useTranslation()
|
||||
const { showTopicTime, pinTopicsToTop } = useSettings()
|
||||
const { showTopicTime, pinTopicsToTop, setTopicPosition } = useSettings()
|
||||
|
||||
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
|
||||
|
||||
@@ -174,7 +175,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
if (messages.length >= 2) {
|
||||
const summaryText = await fetchMessagesSummary({ messages, assistant })
|
||||
if (summaryText) {
|
||||
updateTopic({ ...topic, name: summaryText, isNameManuallyEdited: false })
|
||||
const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false }
|
||||
updateTopic(updatedTopic)
|
||||
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
|
||||
} else {
|
||||
window.message?.error(t('message.error.fetchTopicName'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +195,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
defaultValue: topic?.name || ''
|
||||
})
|
||||
if (name && topic?.name !== name) {
|
||||
updateTopic({ ...topic, name, isNameManuallyEdited: true })
|
||||
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
|
||||
updateTopic(updatedTopic)
|
||||
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -242,6 +249,23 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('settings.topic.position'),
|
||||
key: 'topic-position',
|
||||
icon: <MenuOutlined />,
|
||||
children: [
|
||||
{
|
||||
label: t('settings.topic.position.left'),
|
||||
key: 'left',
|
||||
onClick: () => setTopicPosition('left')
|
||||
},
|
||||
{
|
||||
label: t('settings.topic.position.right'),
|
||||
key: 'right',
|
||||
onClick: () => setTopicPosition('right')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.copy.title'),
|
||||
key: 'copy',
|
||||
@@ -306,16 +330,15 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
label: t('chat.topics.export.obsidian'),
|
||||
key: 'obsidian',
|
||||
onClick: async () => {
|
||||
const markdown = await topicToMarkdown(topic)
|
||||
await ObsidianExportPopup.show({ title: topic.name, markdown, processingMethod: '3' })
|
||||
await ObsidianExportPopup.show({ title: topic.name, topic, processingMethod: '3' })
|
||||
}
|
||||
},
|
||||
exportMenuOptions.joplin && {
|
||||
label: t('chat.topics.export.joplin'),
|
||||
key: 'joplin',
|
||||
onClick: async () => {
|
||||
const markdown = await topicToMarkdown(topic)
|
||||
exportMarkdownToJoplin(topic.name, markdown)
|
||||
const topicMessages = await TopicManager.getTopicMessages(topic.id)
|
||||
exportMarkdownToJoplin(topic.name, topicMessages)
|
||||
}
|
||||
},
|
||||
exportMenuOptions.siyuan && {
|
||||
@@ -358,26 +381,27 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
|
||||
return menus
|
||||
}, [
|
||||
activeTopic.id,
|
||||
assistant,
|
||||
assistants,
|
||||
exportMenuOptions.docx,
|
||||
targetTopic,
|
||||
t,
|
||||
exportMenuOptions.image,
|
||||
exportMenuOptions.joplin,
|
||||
exportMenuOptions.markdown,
|
||||
exportMenuOptions.markdown_reason,
|
||||
exportMenuOptions.docx,
|
||||
exportMenuOptions.notion,
|
||||
exportMenuOptions.obsidian,
|
||||
exportMenuOptions.siyuan,
|
||||
exportMenuOptions.yuque,
|
||||
onClearMessages,
|
||||
onDeleteTopic,
|
||||
onMoveTopic,
|
||||
onPinTopic,
|
||||
setActiveTopic,
|
||||
t,
|
||||
exportMenuOptions.obsidian,
|
||||
exportMenuOptions.joplin,
|
||||
exportMenuOptions.siyuan,
|
||||
assistants,
|
||||
assistant,
|
||||
updateTopic,
|
||||
targetTopic
|
||||
activeTopic.id,
|
||||
setActiveTopic,
|
||||
onPinTopic,
|
||||
onClearMessages,
|
||||
setTopicPosition,
|
||||
onMoveTopic,
|
||||
onDeleteTopic
|
||||
])
|
||||
|
||||
// Sort topics based on pinned status if pinTopicsToTop is enabled
|
||||
@@ -481,7 +505,6 @@ const TopicListItem = styled.div`
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 0.5px solid transparent;
|
||||
position: relative;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
.menu {
|
||||
@@ -489,15 +512,10 @@ const TopicListItem = styled.div`
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.name {
|
||||
}
|
||||
background-color: var(--color-list-item-hover);
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
.name {
|
||||
}
|
||||
background-color: var(--color-list-item);
|
||||
.menu {
|
||||
opacity: 1;
|
||||
&:hover {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MinusCircleOutlined,
|
||||
@@ -19,7 +20,7 @@ import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||
import { getDefaultModel, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant, AssistantsSortType } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import { Dropdown, MenuProps } from 'antd'
|
||||
import { omit } from 'lodash'
|
||||
@@ -151,7 +152,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
) : (
|
||||
assistantIconType === 'emoji' && (
|
||||
<EmojiIcon
|
||||
emoji={assistant.emoji || assistantName.slice(0, 1)}
|
||||
emoji={assistant.emoji || getLeadingEmoji(assistantName)}
|
||||
className={isPending && !isActive ? 'animation-pulse' : ''}
|
||||
/>
|
||||
)
|
||||
@@ -185,10 +186,9 @@ const handleTagOperation = (
|
||||
updateAssistants: (assistants: Assistant[]) => void
|
||||
) => {
|
||||
if (assistant.tags?.includes(tag)) {
|
||||
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [] } : a)))
|
||||
} else {
|
||||
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tag] } : a)))
|
||||
return
|
||||
}
|
||||
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tag] } : a)))
|
||||
}
|
||||
|
||||
// 提取创建菜单项的函数
|
||||
@@ -202,8 +202,7 @@ const createTagMenuItems = (
|
||||
const items: MenuProps['items'] = [
|
||||
...allTags.map((tag) => ({
|
||||
label: tag,
|
||||
icon: assistant.tags?.includes(tag) ? <DeleteOutlined size={14} /> : <Tag size={12} />,
|
||||
danger: assistant.tags?.includes(tag),
|
||||
icon: assistant.tags?.includes(tag) ? <CheckOutlined size={14} /> : <Tag size={12} />,
|
||||
key: `all-tag-${tag}`,
|
||||
onClick: () => handleTagOperation(tag, assistant, assistants, updateAssistants)
|
||||
}))
|
||||
@@ -383,23 +382,18 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
padding: 0 8px;
|
||||
height: 37px;
|
||||
position: relative;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
cursor: pointer;
|
||||
.iconfont {
|
||||
opacity: 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
background-color: var(--color-list-item-hover);
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
background-color: var(--color-list-item);
|
||||
}
|
||||
`
|
||||
|
||||
@@ -423,7 +417,6 @@ const MenuButton = styled.div`
|
||||
align-items: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
border-radius: 11px;
|
||||
position: absolute;
|
||||
|
||||
@@ -58,6 +58,7 @@ const HomeTabs: FC<Props> = ({
|
||||
const assistantTab = {
|
||||
label: t('assistants.abbr'),
|
||||
value: 'assistants'
|
||||
// icon: <BotIcon size={16} />
|
||||
}
|
||||
|
||||
const onCreateAssistant = async () => {
|
||||
@@ -104,28 +105,35 @@ const HomeTabs: FC<Props> = ({
|
||||
return (
|
||||
<Container style={{ ...border, ...style }} className="home-tabs">
|
||||
{(showTab || (forceToSeeAllTab == true && !showTopics)) && (
|
||||
<Segmented
|
||||
value={tab}
|
||||
style={{ borderRadius: 16, paddingTop: 10, margin: '0 10px', gap: 2 }}
|
||||
options={
|
||||
[
|
||||
(position === 'left' && topicPosition === 'left') || (forceToSeeAllTab == true && position === 'left')
|
||||
? assistantTab
|
||||
: undefined,
|
||||
{
|
||||
label: t('common.topics'),
|
||||
value: 'topic'
|
||||
},
|
||||
{
|
||||
label: t('settings.title'),
|
||||
value: 'settings'
|
||||
}
|
||||
].filter(Boolean) as SegmentedProps['options']
|
||||
}
|
||||
onChange={(value) => setTab(value as 'topic' | 'settings')}
|
||||
block
|
||||
/>
|
||||
<>
|
||||
<Segmented
|
||||
value={tab}
|
||||
style={{ borderRadius: 50 }}
|
||||
shape="round"
|
||||
options={
|
||||
[
|
||||
(position === 'left' && topicPosition === 'left') || (forceToSeeAllTab == true && position === 'left')
|
||||
? assistantTab
|
||||
: undefined,
|
||||
{
|
||||
label: t('common.topics'),
|
||||
value: 'topic'
|
||||
// icon: <MessageSquareQuote size={16} />
|
||||
},
|
||||
{
|
||||
label: t('settings.title'),
|
||||
value: 'settings'
|
||||
// icon: <SettingsIcon size={16} />
|
||||
}
|
||||
].filter(Boolean) as SegmentedProps['options']
|
||||
}
|
||||
onChange={(value) => setTab(value as 'topic' | 'settings')}
|
||||
block
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TabContent className="home-tabs-content">
|
||||
{tab === 'assistants' && (
|
||||
<Assistants
|
||||
@@ -149,7 +157,7 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
max-width: var(--assistants-width);
|
||||
min-width: var(--assistants-width);
|
||||
background-color: var(--color-background);
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
.collapsed {
|
||||
width: 0;
|
||||
@@ -165,14 +173,21 @@ const TabContent = styled.div`
|
||||
overflow-x: hidden;
|
||||
`
|
||||
|
||||
const Divider = styled.div`
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
`
|
||||
|
||||
const Segmented = styled(AntSegmented)`
|
||||
font-family: var(--font-family);
|
||||
|
||||
&.ant-segmented {
|
||||
background-color: transparent;
|
||||
border-radius: 0 !important;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
padding-bottom: 10px;
|
||||
margin: 0 10px;
|
||||
margin-top: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
.ant-segmented-item {
|
||||
overflow: hidden;
|
||||
@@ -184,10 +199,10 @@ const Segmented = styled(AntSegmented)`
|
||||
border-radius: var(--list-item-border-radius);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ant-segmented-item-selected {
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
.ant-segmented-item-selected,
|
||||
.ant-segmented-item-selected:active {
|
||||
transition: none !important;
|
||||
background-color: var(--color-list-item);
|
||||
}
|
||||
.ant-segmented-item-label {
|
||||
align-items: center;
|
||||
@@ -200,25 +215,17 @@ const Segmented = styled(AntSegmented)`
|
||||
.ant-segmented-item-label[aria-selected='true'] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 13px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.anticon-setting {
|
||||
font-size: 12px;
|
||||
}
|
||||
.icon-business-smart-assistant {
|
||||
margin-right: -2px;
|
||||
}
|
||||
.ant-segmented-item-icon + * {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.ant-segmented-thumb {
|
||||
transition: none !important;
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
background-color: var(--color-list-item);
|
||||
border-radius: var(--list-item-border-radius);
|
||||
box-shadow: none;
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.ant-segmented-item-label,
|
||||
.ant-segmented-item-icon {
|
||||
|
||||
@@ -93,7 +93,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
.map((file) => ({
|
||||
id: file.name,
|
||||
name: file.name,
|
||||
path: window.api.file.getPathForFile(file),
|
||||
path: window.api.file.getPathForFile(file) || '',
|
||||
size: file.size,
|
||||
ext: `.${file.name.split('.').pop()}`.toLowerCase(),
|
||||
count: 1,
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Route, Routes, useParams } from 'react-router-dom'
|
||||
import AihubmixPage from './AihubmixPage'
|
||||
import DmxapiPage from './DmxapiPage'
|
||||
import SiliconPage from './SiliconPage'
|
||||
import TokenFluxPage from './TokenFluxPage'
|
||||
|
||||
const Options = ['aihubmix', 'silicon', 'dmxapi']
|
||||
const Options = ['aihubmix', 'silicon', 'dmxapi', 'tokenflux']
|
||||
|
||||
const PaintingsRoutePage: FC = () => {
|
||||
const params = useParams()
|
||||
@@ -28,6 +29,7 @@ const PaintingsRoutePage: FC = () => {
|
||||
<Route path="/aihubmix" element={<AihubmixPage Options={Options} />} />
|
||||
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
|
||||
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
|
||||
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
786
src/renderer/src/pages/paintings/TokenFluxPage.tsx
Normal file
786
src/renderer/src/pages/paintings/TokenFluxPage.tsx
Normal file
@@ -0,0 +1,786 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import type { TokenFluxPainting } from '@renderer/types'
|
||||
import { getErrorMessage, uuid } from '@renderer/utils'
|
||||
import { Avatar, Button, Select, Tooltip } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { Info } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
||||
import { SettingHelpLink, SettingTitle } from '../settings'
|
||||
import Artboard from './components/Artboard'
|
||||
import { DynamicFormRender } from './components/DynamicFormRender'
|
||||
import PaintingsList from './components/PaintingsList'
|
||||
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
|
||||
import TokenFluxService from './utils/TokenFluxService'
|
||||
|
||||
const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const [models, setModels] = useState<TokenFluxModel[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<TokenFluxModel | null>(null)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({})
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
|
||||
const { t, i18n } = useTranslation()
|
||||
const providers = useAllProviders()
|
||||
const { addPainting, removePainting, updatePainting, persistentData } = usePaintings()
|
||||
const tokenFluxPaintings = useMemo(() => persistentData.tokenFluxPaintings || [], [persistentData.tokenFluxPaintings])
|
||||
const [painting, setPainting] = useState<TokenFluxPainting>(
|
||||
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() }
|
||||
)
|
||||
|
||||
const providerOptions = Options.map((option) => {
|
||||
const provider = providers.find((p) => p.id === option)
|
||||
return {
|
||||
label: t(`provider.${provider?.id}`),
|
||||
value: provider?.id
|
||||
}
|
||||
})
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const { generating } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { autoTranslateWithSpace } = useSettings()
|
||||
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
|
||||
const tokenfluxProvider = providers.find((p) => p.id === 'tokenflux')!
|
||||
const textareaRef = useRef<any>(null)
|
||||
const tokenFluxService = useMemo(
|
||||
() => new TokenFluxService(tokenfluxProvider.apiHost, tokenfluxProvider.apiKey),
|
||||
[tokenfluxProvider]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
tokenFluxService.fetchModels().then((models) => {
|
||||
setModels(models)
|
||||
if (models.length > 0) {
|
||||
setSelectedModel(models[0])
|
||||
}
|
||||
})
|
||||
}, [tokenFluxService])
|
||||
|
||||
const getNewPainting = useCallback(() => {
|
||||
return {
|
||||
...DEFAULT_TOKENFLUX_PAINTING,
|
||||
id: uuid(),
|
||||
model: selectedModel?.id || '',
|
||||
inputParams: {},
|
||||
generationId: undefined
|
||||
}
|
||||
}, [selectedModel])
|
||||
|
||||
const updatePaintingState = useCallback(
|
||||
(updates: Partial<TokenFluxPainting>) => {
|
||||
setPainting((prevPainting) => {
|
||||
const updatedPainting = { ...prevPainting, ...updates }
|
||||
updatePainting('tokenFluxPaintings', updatedPainting)
|
||||
return updatedPainting
|
||||
})
|
||||
},
|
||||
[updatePainting]
|
||||
)
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
window.modal.error({
|
||||
content: getErrorMessage(error),
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
const model = models.find((m) => m.id === modelId)
|
||||
if (model) {
|
||||
setSelectedModel(model)
|
||||
setFormData({})
|
||||
updatePaintingState({ model: model.id, inputParams: {} })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormFieldChange = (field: string, value: any) => {
|
||||
const newFormData = { ...formData, [field]: value }
|
||||
setFormData(newFormData)
|
||||
updatePaintingState({ inputParams: newFormData })
|
||||
}
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (painting.files.length > 0) {
|
||||
const confirmed = await window.modal.confirm({
|
||||
content: t('paintings.regenerate.confirm'),
|
||||
centered: true
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
await FileManager.deleteFiles(painting.files)
|
||||
}
|
||||
|
||||
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
|
||||
|
||||
if (!tokenfluxProvider.enabled) {
|
||||
window.modal.error({
|
||||
content: t('error.provider_disabled'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!tokenfluxProvider.apiKey) {
|
||||
window.modal.error({
|
||||
content: t('error.no_api_key'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedModel || !prompt) {
|
||||
window.modal.error({
|
||||
content: t('paintings.text_desc_required'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
setIsLoading(true)
|
||||
dispatch(setGenerating(true))
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
model: selectedModel.id,
|
||||
input: {
|
||||
prompt,
|
||||
...formData
|
||||
}
|
||||
}
|
||||
|
||||
const inputParams = { prompt, ...formData }
|
||||
updatePaintingState({
|
||||
model: selectedModel.id,
|
||||
prompt,
|
||||
status: 'processing',
|
||||
inputParams
|
||||
})
|
||||
|
||||
const result = await tokenFluxService.generateAndWait(requestBody, {
|
||||
signal: controller.signal,
|
||||
onStatusUpdate: (updates) => {
|
||||
updatePaintingState(updates)
|
||||
}
|
||||
})
|
||||
|
||||
if (result && result.images && result.images.length > 0) {
|
||||
const urls = result.images.map((img: { url: string }) => img.url)
|
||||
const validFiles = await tokenFluxService.downloadImages(urls)
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls, status: 'succeeded' })
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
} catch (error: unknown) {
|
||||
handleError(error)
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
abortController?.abort()
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev + 1) % painting.files.length)
|
||||
}
|
||||
|
||||
const prevImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length)
|
||||
}
|
||||
|
||||
const handleAddPainting = () => {
|
||||
const newPainting = addPainting('tokenFluxPaintings', getNewPainting())
|
||||
updatePainting('tokenFluxPaintings', newPainting)
|
||||
setPainting(newPainting as TokenFluxPainting)
|
||||
return newPainting
|
||||
}
|
||||
|
||||
const onDeletePainting = (paintingToDelete: TokenFluxPainting) => {
|
||||
if (paintingToDelete.id === painting.id) {
|
||||
const currentIndex = tokenFluxPaintings.findIndex((p) => p.id === paintingToDelete.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
setPainting(tokenFluxPaintings[currentIndex - 1])
|
||||
} else if (tokenFluxPaintings.length > 1) {
|
||||
setPainting(tokenFluxPaintings[1])
|
||||
}
|
||||
}
|
||||
|
||||
removePainting('tokenFluxPaintings', paintingToDelete)
|
||||
}
|
||||
|
||||
const translate = async () => {
|
||||
if (isTranslating) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!painting.prompt) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
const translatedText = await translateText(painting.prompt, 'english')
|
||||
updatePaintingState({ prompt: translatedText })
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
} finally {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (autoTranslateWithSpace && event.key === ' ') {
|
||||
setSpaceClickCount((prev) => prev + 1)
|
||||
|
||||
if (spaceClickTimer.current) {
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
}
|
||||
|
||||
spaceClickTimer.current = setTimeout(() => {
|
||||
setSpaceClickCount(0)
|
||||
}, 200)
|
||||
|
||||
if (spaceClickCount === 2) {
|
||||
setSpaceClickCount(0)
|
||||
setIsTranslating(true)
|
||||
translate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
const routeName = location.pathname.split('/').pop()
|
||||
if (providerId !== routeName) {
|
||||
navigate('../' + providerId, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectPainting = (newPainting: TokenFluxPainting) => {
|
||||
if (generating) return
|
||||
setPainting(newPainting)
|
||||
setCurrentImageIndex(0)
|
||||
|
||||
// Set form data from painting's input params
|
||||
if (newPainting.inputParams) {
|
||||
// Filter out the prompt from inputParams since it's handled separately
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { prompt, ...formInputParams } = newPainting.inputParams
|
||||
setFormData(formInputParams)
|
||||
} else {
|
||||
setFormData({})
|
||||
}
|
||||
|
||||
// Set selected model if available
|
||||
if (newPainting.model) {
|
||||
const model = models.find((m) => m.id === newPainting.model)
|
||||
if (model) {
|
||||
setSelectedModel(model)
|
||||
}
|
||||
} else {
|
||||
setSelectedModel(null)
|
||||
}
|
||||
}
|
||||
|
||||
const readI18nContext = (property: Record<string, any>, key: string): string => {
|
||||
const lang = i18n.language.split('-')[0] // Get the base language code (e.g., 'en' from 'en-US')
|
||||
console.log('readI18nContext', { property, key, lang })
|
||||
return property[`${key}_${lang}`] || property[key]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (tokenFluxPaintings.length === 0) {
|
||||
const newPainting = getNewPainting()
|
||||
addPainting('tokenFluxPaintings', newPainting)
|
||||
setPainting(newPainting)
|
||||
}
|
||||
}, [tokenFluxPaintings, addPainting, getNewPainting])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = spaceClickTimer.current
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (painting.status === 'processing' && painting.generationId) {
|
||||
tokenFluxService
|
||||
.pollGenerationResult(painting.generationId, {
|
||||
onStatusUpdate: (updates) => {
|
||||
console.log('Polling status update:', updates)
|
||||
updatePaintingState(updates)
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
if (result && result.images && result.images.length > 0) {
|
||||
const urls = result.images.map((img: { url: string }) => img.url)
|
||||
tokenFluxService.downloadImages(urls).then(async (validFiles) => {
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls, status: 'succeeded' })
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Polling failed:', error)
|
||||
updatePaintingState({ status: 'failed' })
|
||||
})
|
||||
}
|
||||
}, [painting.generationId, painting.status, tokenFluxService, updatePaintingState])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
|
||||
{isMac && (
|
||||
<NavbarRight style={{ justifyContent: 'flex-end' }}>
|
||||
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={handleAddPainting}>
|
||||
{t('paintings.button.new.image')}
|
||||
</Button>
|
||||
</NavbarRight>
|
||||
)}
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<LeftContainer>
|
||||
{/* Provider Section */}
|
||||
<ProviderTitleContainer>
|
||||
<SettingTitle style={{ marginBottom: 8 }}>{t('common.provider')}</SettingTitle>
|
||||
<SettingHelpLink target="_blank" href="https://tokenflux.ai">
|
||||
{t('paintings.learn_more')}
|
||||
<ProviderLogo shape="square" src={getProviderLogo('tokenflux')} size={16} style={{ marginLeft: 5 }} />
|
||||
</SettingHelpLink>
|
||||
</ProviderTitleContainer>
|
||||
|
||||
<Select
|
||||
value={providerOptions.find((p) => p.value === 'tokenflux')?.value}
|
||||
onChange={handleProviderChange}
|
||||
style={{ width: '100%' }}>
|
||||
{providerOptions.map((provider) => (
|
||||
<Select.Option value={provider.value} key={provider.value}>
|
||||
<SelectOptionContainer>
|
||||
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
|
||||
{provider.label}
|
||||
</SelectOptionContainer>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Model & Pricing Section */}
|
||||
<SectionTitle
|
||||
style={{ marginBottom: 5, marginTop: 15, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{t('paintings.model_and_pricing')}
|
||||
{selectedModel && selectedModel.pricing && (
|
||||
<PricingContainer>
|
||||
<PricingBadge>
|
||||
{selectedModel.pricing.price} {selectedModel.pricing.currency}{' '}
|
||||
{selectedModel.pricing.unit > 1 ? t('paintings.per_images') : t('paintings.per_image')}
|
||||
</PricingBadge>
|
||||
</PricingContainer>
|
||||
)}
|
||||
</SectionTitle>
|
||||
<Select
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
value={selectedModel?.id}
|
||||
onChange={handleModelChange}
|
||||
placeholder={t('paintings.select_model')}>
|
||||
{Object.entries(
|
||||
models.reduce(
|
||||
(acc, model) => {
|
||||
const provider = model.model_provider || 'Other'
|
||||
if (!acc[provider]) {
|
||||
acc[provider] = []
|
||||
}
|
||||
acc[provider].push(model)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof models>
|
||||
)
|
||||
).map(([provider, providerModels]) => (
|
||||
<Select.OptGroup key={provider} label={provider}>
|
||||
{providerModels.map((model) => (
|
||||
<Select.Option key={model.id} value={model.id}>
|
||||
<Tooltip title={model.description} placement="right">
|
||||
<ModelOptionContainer>
|
||||
<ModelName>{model.name}</ModelName>
|
||||
</ModelOptionContainer>
|
||||
</Tooltip>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Input Parameters Section */}
|
||||
{selectedModel && selectedModel.input_schema && (
|
||||
<>
|
||||
<SectionTitle style={{ marginBottom: 5, marginTop: 10 }}>{t('paintings.input_parameters')}</SectionTitle>
|
||||
<ParametersContainer>
|
||||
{Object.entries(selectedModel.input_schema.properties).map(([key, property]: [string, any]) => {
|
||||
if (key === 'prompt') return null // Skip prompt as it's handled separately
|
||||
|
||||
const isRequired = selectedModel.input_schema.required?.includes(key)
|
||||
|
||||
return (
|
||||
<ParameterField key={key}>
|
||||
<ParameterLabel>
|
||||
<ParameterName>
|
||||
{readI18nContext(property, 'title')}
|
||||
{isRequired && <RequiredIndicator> *</RequiredIndicator>}
|
||||
</ParameterName>
|
||||
{property.description && (
|
||||
<Tooltip title={readI18nContext(property, 'description')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</ParameterLabel>
|
||||
<DynamicFormRender
|
||||
schemaProperty={property}
|
||||
propertyName={key}
|
||||
value={formData[key]}
|
||||
onChange={handleFormFieldChange}
|
||||
/>
|
||||
</ParameterField>
|
||||
)
|
||||
})}
|
||||
</ParametersContainer>
|
||||
</>
|
||||
)}
|
||||
</LeftContainer>
|
||||
|
||||
<MainContainer>
|
||||
{/* Check if any form field contains an uploaded image */}
|
||||
{Object.keys(formData).some((key) => key.toLowerCase().includes('image') && formData[key]) ? (
|
||||
<ComparisonContainer>
|
||||
<ImageComparisonSection>
|
||||
<SectionLabel>{t('paintings.input_image')}</SectionLabel>
|
||||
<UploadedImageContainer>
|
||||
{Object.entries(formData).map(([key, value]) => {
|
||||
if (key.toLowerCase().includes('image') && value) {
|
||||
return (
|
||||
<ImageWrapper key={key}>
|
||||
<img
|
||||
src={value}
|
||||
alt={t('paintings.uploaded_input')}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: 'var(--color-background-soft)'
|
||||
}}
|
||||
/>
|
||||
</ImageWrapper>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</UploadedImageContainer>
|
||||
</ImageComparisonSection>
|
||||
<ImageComparisonSection>
|
||||
<SectionLabel>{t('paintings.generated_image')}</SectionLabel>
|
||||
<Artboard
|
||||
painting={painting}
|
||||
isLoading={isLoading}
|
||||
currentImageIndex={currentImageIndex}
|
||||
onPrevImage={prevImage}
|
||||
onNextImage={nextImage}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</ImageComparisonSection>
|
||||
</ComparisonContainer>
|
||||
) : (
|
||||
<Artboard
|
||||
painting={painting}
|
||||
isLoading={isLoading}
|
||||
currentImageIndex={currentImageIndex}
|
||||
onPrevImage={prevImage}
|
||||
onNextImage={nextImage}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
<InputContainer>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
variant="borderless"
|
||||
disabled={isLoading}
|
||||
value={painting.prompt || ''}
|
||||
spellCheck={false}
|
||||
onChange={(e) => updatePaintingState({ prompt: e.target.value })}
|
||||
placeholder={isTranslating ? t('paintings.translating') : t('paintings.prompt_placeholder')}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Toolbar>
|
||||
<ToolbarMenu>
|
||||
<TranslateButton
|
||||
text={textareaRef.current?.resizableTextArea?.textArea?.value}
|
||||
onTranslated={(translatedText) => updatePaintingState({ prompt: translatedText })}
|
||||
disabled={isLoading || isTranslating}
|
||||
isLoading={isTranslating}
|
||||
style={{ marginRight: 6, borderRadius: '50%' }}
|
||||
/>
|
||||
<SendMessageButton sendMessage={onGenerate} disabled={isLoading} />
|
||||
</ToolbarMenu>
|
||||
</Toolbar>
|
||||
</InputContainer>
|
||||
</MainContainer>
|
||||
|
||||
<PaintingsList
|
||||
namespace="tokenFluxPaintings"
|
||||
paintings={tokenFluxPaintings}
|
||||
selectedPainting={painting}
|
||||
onSelectPainting={onSelectPainting as any}
|
||||
onDeletePainting={onDeletePainting as any}
|
||||
onNewPainting={handleAddPainting}
|
||||
/>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const SectionTitle = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const ModelOptionContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const ModelName = styled.div`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const PricingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const PricingBadge = styled.div`
|
||||
background-color: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-primary-border);
|
||||
`
|
||||
|
||||
const ParametersContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const ParameterField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const ParameterLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
`
|
||||
|
||||
const ParameterName = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
text-transform: capitalize;
|
||||
`
|
||||
|
||||
const RequiredIndicator = styled.span`
|
||||
color: var(--color-error);
|
||||
font-weight: 600;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const LeftContainer = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background-color: var(--color-background);
|
||||
max-width: var(--assistants-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const MainContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const ComparisonContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
gap: 1px;
|
||||
`
|
||||
|
||||
const ImageComparisonSection = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
&:first-child {
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
}
|
||||
`
|
||||
|
||||
const SectionLabel = styled.div`
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
background-color: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const UploadedImageContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 95px;
|
||||
max-height: 95px;
|
||||
position: relative;
|
||||
border: 1px solid var(--color-border-soft);
|
||||
transition: all 0.3s ease;
|
||||
margin: 0 20px 15px 20px;
|
||||
border-radius: 10px;
|
||||
`
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
padding: 10px;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
`
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8px;
|
||||
padding-bottom: 0;
|
||||
height: 40px;
|
||||
`
|
||||
|
||||
const ToolbarMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const InfoIcon = styled(Info)`
|
||||
margin-left: 5px;
|
||||
cursor: help;
|
||||
color: var(--color-text-2);
|
||||
opacity: 0.6;
|
||||
width: 14px;
|
||||
height: 16px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ProviderTitleContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const SelectOptionContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
export default TokenFluxPage
|
||||
@@ -0,0 +1,213 @@
|
||||
import { CloseOutlined, LinkOutlined, RedoOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { convertToBase64 } from '@renderer/utils'
|
||||
import { Button, Input, InputNumber, Select, Switch, Upload } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
interface DynamicFormRenderProps {
|
||||
schemaProperty: any
|
||||
propertyName: string
|
||||
value: any
|
||||
onChange: (field: string, value: any) => void
|
||||
}
|
||||
|
||||
export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({
|
||||
schemaProperty,
|
||||
propertyName,
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
const { type, enum: enumValues, description, default: defaultValue, format } = schemaProperty
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
async (
|
||||
propertyName: string,
|
||||
fileOrUrl: File | string,
|
||||
onChange: (field: string, value: any) => void
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (typeof fileOrUrl === 'string') {
|
||||
// Handle URL case - validate and set directly
|
||||
if (fileOrUrl.match(/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i)) {
|
||||
onChange(propertyName, fileOrUrl)
|
||||
} else {
|
||||
window.message?.error('Invalid image URL format')
|
||||
}
|
||||
} else {
|
||||
// Handle File case - convert to base64
|
||||
const base64Image = await convertToBase64(fileOrUrl)
|
||||
if (typeof base64Image === 'string') {
|
||||
onChange(propertyName, base64Image)
|
||||
} else {
|
||||
console.error('Failed to convert image to base64')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing image:', error)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (type === 'string' && propertyName.toLowerCase().includes('image') && format === 'uri') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '0' }}>
|
||||
<Input
|
||||
style={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderRight: 'none'
|
||||
}}
|
||||
value={value || defaultValue || ''}
|
||||
onChange={(e) => onChange(propertyName, e.target.value)}
|
||||
placeholder="Enter image URL or upload file"
|
||||
prefix={<LinkOutlined style={{ color: '#999' }} />}
|
||||
/>
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
handleImageUpload(propertyName, file, onChange)
|
||||
return false
|
||||
}}>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
title="Upload image file"
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
height: '32px'
|
||||
}}
|
||||
/>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: 'var(--color-fill-quaternary)',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-border)'
|
||||
}}>
|
||||
<img
|
||||
src={value}
|
||||
alt="Image preview"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--color-border-secondary)',
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.1)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{value.startsWith('data:') ? 'Uploaded image' : 'Image URL'}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => onChange(propertyName, '')}
|
||||
title="Remove image"
|
||||
style={{ flexShrink: 0, minWidth: 'auto', padding: '0 8px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'string' && enumValues) {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={value || defaultValue}
|
||||
options={enumValues.map((val: string) => ({ label: val, value: val }))}
|
||||
onChange={(v) => onChange(propertyName, v)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
if (propertyName.toLowerCase().includes('prompt') && propertyName !== 'prompt') {
|
||||
return (
|
||||
<TextArea
|
||||
value={value || defaultValue || ''}
|
||||
onChange={(e) => onChange(propertyName, e.target.value)}
|
||||
rows={3}
|
||||
placeholder={description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
value={value || defaultValue || ''}
|
||||
onChange={(e) => onChange(propertyName, e.target.value)}
|
||||
placeholder={description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'integer' && propertyName === 'seed') {
|
||||
const generateRandomSeed = () => Math.floor(Math.random() * 1000000)
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<InputNumber
|
||||
style={{ flex: 1 }}
|
||||
value={value || defaultValue}
|
||||
onChange={(v) => onChange(propertyName, v)}
|
||||
step={1}
|
||||
min={schemaProperty.minimum}
|
||||
max={schemaProperty.maximum}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
onClick={() => onChange(propertyName, generateRandomSeed())}
|
||||
title="Generate random seed"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'integer' || type === 'number') {
|
||||
const step = type === 'number' ? 0.1 : 1
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={value || defaultValue}
|
||||
onChange={(v) => onChange(propertyName, v)}
|
||||
step={step}
|
||||
min={schemaProperty.minimum}
|
||||
max={schemaProperty.maximum}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<Switch
|
||||
checked={value !== undefined ? value : defaultValue}
|
||||
onChange={(checked) => onChange(propertyName, checked)}
|
||||
style={{ width: '2px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
27
src/renderer/src/pages/paintings/config/tokenFluxConfig.ts
Normal file
27
src/renderer/src/pages/paintings/config/tokenFluxConfig.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { TokenFluxPainting } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
|
||||
export interface TokenFluxModel {
|
||||
id: string
|
||||
name: string
|
||||
model_provider: string
|
||||
description: string
|
||||
tags: string[]
|
||||
pricing: any
|
||||
input_schema: {
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
required: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_TOKENFLUX_PAINTING: TokenFluxPainting = {
|
||||
id: uuid(),
|
||||
model: '',
|
||||
prompt: '',
|
||||
inputParams: {},
|
||||
status: 'starting',
|
||||
generationId: undefined,
|
||||
urls: [],
|
||||
files: []
|
||||
}
|
||||
237
src/renderer/src/pages/paintings/utils/TokenFluxService.ts
Normal file
237
src/renderer/src/pages/paintings/utils/TokenFluxService.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { CacheService } from '@renderer/services/CacheService'
|
||||
import { FileType, TokenFluxPainting } from '@renderer/types'
|
||||
|
||||
import type { TokenFluxModel } from '../config/tokenFluxConfig'
|
||||
|
||||
export interface TokenFluxGenerationRequest {
|
||||
model: string
|
||||
input: {
|
||||
prompt: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenFluxGenerationResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
id: string
|
||||
status: string
|
||||
images?: Array<{ url: string }>
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface TokenFluxModelsResponse {
|
||||
success: boolean
|
||||
data?: TokenFluxModel[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export class TokenFluxService {
|
||||
private apiHost: string
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiHost: string, apiKey: string) {
|
||||
this.apiHost = apiHost
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }))
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: Request failed`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from TokenFlux API
|
||||
*/
|
||||
async fetchModels(): Promise<TokenFluxModel[]> {
|
||||
const cacheKey = `tokenflux_models_${this.apiHost}`
|
||||
|
||||
// Check cache first
|
||||
const cachedModels = CacheService.get<TokenFluxModel[]>(cacheKey)
|
||||
if (cachedModels) {
|
||||
return cachedModels
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiHost}/v1/images/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`
|
||||
}
|
||||
})
|
||||
|
||||
const data: TokenFluxModelsResponse = await this.handleResponse(response)
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error('Failed to fetch models')
|
||||
}
|
||||
|
||||
// Cache for 60 minutes (3,600,000 milliseconds)
|
||||
CacheService.set(cacheKey, data.data, 60 * 60 * 1000)
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new image generation request
|
||||
*/
|
||||
async createGeneration(request: TokenFluxGenerationRequest, signal?: AbortSignal): Promise<string> {
|
||||
const response = await fetch(`${this.apiHost}/v1/images/generations`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
signal
|
||||
})
|
||||
|
||||
const data: TokenFluxGenerationResponse = await this.handleResponse(response)
|
||||
|
||||
if (!data.success || !data.data?.id) {
|
||||
throw new Error(data.message || 'Generation failed')
|
||||
}
|
||||
|
||||
return data.data.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status and result of a generation
|
||||
*/
|
||||
async getGenerationResult(generationId: string): Promise<TokenFluxGenerationResponse['data']> {
|
||||
const response = await fetch(`${this.apiHost}/v1/images/generations/${generationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`
|
||||
}
|
||||
})
|
||||
|
||||
const data: TokenFluxGenerationResponse = await this.handleResponse(response)
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error('Invalid response from generation service')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for generation result with automatic retry logic
|
||||
*/
|
||||
async pollGenerationResult(
|
||||
generationId: string,
|
||||
options: {
|
||||
onStatusUpdate?: (updates: Partial<TokenFluxPainting>) => void
|
||||
maxRetries?: number
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
} = {}
|
||||
): Promise<TokenFluxGenerationResponse['data']> {
|
||||
const {
|
||||
onStatusUpdate,
|
||||
maxRetries = 10,
|
||||
timeoutMs = 120000, // 2 minutes
|
||||
intervalMs = 2000
|
||||
} = options
|
||||
|
||||
const startTime = Date.now()
|
||||
let retryCount = 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
// Check for timeout
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
reject(new Error('Image generation timed out. Please try again.'))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.getGenerationResult(generationId)
|
||||
|
||||
// Reset retry count on successful response
|
||||
retryCount = 0
|
||||
|
||||
if (result) {
|
||||
onStatusUpdate?.({ status: result.status as TokenFluxPainting['status'] })
|
||||
|
||||
if (result.status === 'succeeded') {
|
||||
resolve(result)
|
||||
return
|
||||
} else if (result.status === 'failed') {
|
||||
reject(new Error('Image generation failed'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Continue polling for other statuses (processing, queued, etc.)
|
||||
setTimeout(poll, intervalMs)
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error)
|
||||
retryCount++
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
reject(new Error('Failed to check generation status after multiple attempts. Please try again.'))
|
||||
return
|
||||
}
|
||||
|
||||
// Retry after interval
|
||||
setTimeout(poll, intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create generation and poll for result in one call
|
||||
*/
|
||||
async generateAndWait(
|
||||
request: TokenFluxGenerationRequest,
|
||||
options: {
|
||||
onStatusUpdate?: (updates: Partial<TokenFluxPainting>) => void
|
||||
signal?: AbortSignal
|
||||
maxRetries?: number
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
} = {}
|
||||
): Promise<TokenFluxGenerationResponse['data']> {
|
||||
const { signal, onStatusUpdate, ...pollOptions } = options
|
||||
const generationId = await this.createGeneration(request, signal)
|
||||
if (onStatusUpdate) {
|
||||
onStatusUpdate({ generationId })
|
||||
}
|
||||
return this.pollGenerationResult(generationId, { ...pollOptions, onStatusUpdate })
|
||||
}
|
||||
|
||||
async downloadImages(urls: string[]) {
|
||||
const downloadedFiles = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
if (!url?.trim()) {
|
||||
console.error('Image URL is empty')
|
||||
window.message.warning({
|
||||
content: 'Image URL is empty',
|
||||
key: 'empty-url-warning'
|
||||
})
|
||||
return null
|
||||
}
|
||||
return await window.api.file.download(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return downloadedFiles.filter((file): file is FileType => file !== null)
|
||||
}
|
||||
}
|
||||
|
||||
export default TokenFluxService
|
||||
@@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingDivider } from '..'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
updateAssistant: (assistant: Assistant) => void
|
||||
@@ -90,7 +92,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack mt={8} mb={8} alignItems="center" gap={4}>
|
||||
<SettingDivider />
|
||||
<HStack mb={8} alignItems="center" gap={4}>
|
||||
<Box style={{ fontWeight: 'bold' }}>{t('common.prompt')}</Box>
|
||||
<Tooltip title={t('agents.add.prompt.variables.tip')}>
|
||||
<QuestionCircleOutlined size={14} color="var(--color-text-2)" />
|
||||
@@ -139,7 +142,6 @@ const Container = styled.div`
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
`
|
||||
|
||||
const EmojiButtonWrapper = styled.div`
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '..'
|
||||
import { SettingDivider, SettingRow, SettingTitle } from '..'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
@@ -79,52 +79,50 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
|
||||
const reversedPrompts = [...promptsList].reverse()
|
||||
|
||||
return (
|
||||
<SettingContainer style={{ padding: 0, background: '#0000' }}>
|
||||
<SettingGroup style={{ marginBottom: 0, padding: 0, border: 'none' }}>
|
||||
<SettingTitle>
|
||||
{t('assistants.settings.regular_phrases.title', 'Regular Prompts')}
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={handleAdd} />
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<StyledPromptList>
|
||||
<DragableList
|
||||
list={reversedPrompts}
|
||||
onUpdate={(newPrompts) => handleUpdateOrder([...newPrompts].reverse())}
|
||||
style={{ paddingBottom: dragging ? '34px' : 0 }}
|
||||
onDragStart={() => setDragging(true)}
|
||||
onDragEnd={() => setDragging(false)}>
|
||||
{(prompt) => (
|
||||
<FileItem
|
||||
key={prompt.id}
|
||||
fileInfo={{
|
||||
name: prompt.title,
|
||||
ext: '.txt',
|
||||
extra: prompt.content,
|
||||
actions: (
|
||||
<Flex gap={4} style={{ opacity: 0.6 }}>
|
||||
<Button key="edit" type="text" icon={<EditOutlined />} onClick={() => handleEdit(prompt)} />
|
||||
<Popconfirm
|
||||
title={t('assistants.settings.regular_phrases.delete', 'Delete Prompt')}
|
||||
description={t(
|
||||
'assistants.settings.regular_phrases.deleteConfirm',
|
||||
'Are you sure to delete this prompt?'
|
||||
)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleDelete(prompt.id)}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button key="delete" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
</StyledPromptList>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<Container>
|
||||
<SettingTitle>
|
||||
{t('assistants.settings.regular_phrases.title', 'Regular Prompts')}
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={handleAdd} />
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<StyledPromptList>
|
||||
<DragableList
|
||||
list={reversedPrompts}
|
||||
onUpdate={(newPrompts) => handleUpdateOrder([...newPrompts].reverse())}
|
||||
style={{ paddingBottom: dragging ? '34px' : 0 }}
|
||||
onDragStart={() => setDragging(true)}
|
||||
onDragEnd={() => setDragging(false)}>
|
||||
{(prompt) => (
|
||||
<FileItem
|
||||
key={prompt.id}
|
||||
fileInfo={{
|
||||
name: prompt.title,
|
||||
ext: '.txt',
|
||||
extra: prompt.content,
|
||||
actions: (
|
||||
<Flex gap={4} style={{ opacity: 0.6 }}>
|
||||
<Button key="edit" type="text" icon={<EditOutlined />} onClick={() => handleEdit(prompt)} />
|
||||
<Popconfirm
|
||||
title={t('assistants.settings.regular_phrases.delete', 'Delete Prompt')}
|
||||
description={t(
|
||||
'assistants.settings.regular_phrases.deleteConfirm',
|
||||
'Are you sure to delete this prompt?'
|
||||
)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleDelete(prompt.id)}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button key="delete" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
</StyledPromptList>
|
||||
</SettingRow>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
@@ -159,10 +157,16 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</SettingContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
@@ -171,8 +175,6 @@ const Label = styled.div`
|
||||
|
||||
const StyledPromptList = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100vh - 162px); // Adjusted height to match other settings pages
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
@@ -3,14 +3,14 @@ import { HStack } from '@renderer/components/Layout'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
|
||||
import { Button, Switch, Tooltip } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
const JoplinSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -20,6 +20,7 @@ const JoplinSettings: FC = () => {
|
||||
|
||||
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
|
||||
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
|
||||
const joplinExportReasoning = useSelector((state: RootState) => state.settings.joplinExportReasoning)
|
||||
|
||||
const handleJoplinTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setJoplinToken(e.target.value))
|
||||
@@ -72,6 +73,10 @@ const JoplinSettings: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleJoplinExportReasoning = (checked: boolean) => {
|
||||
dispatch(setJoplinExportReasoning(checked))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.joplin.title')}</SettingTitle>
|
||||
@@ -111,6 +116,14 @@ const JoplinSettings: FC = () => {
|
||||
<Button onClick={handleJoplinConnectionCheck}>{t('settings.data.joplin.check.button')}</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.joplin.export_reasoning.title')}</SettingRowTitle>
|
||||
<Switch checked={joplinExportReasoning} onChange={handleToggleJoplinExportReasoning} />
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.joplin.export_reasoning.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setForceDollarMathInMarkdown,
|
||||
setmarkdownExportPath,
|
||||
setShowModelNameInMarkdown,
|
||||
setShowModelProviderInMarkdown,
|
||||
setUseTopicNamingForMessageTitle
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, Switch } from 'antd'
|
||||
@@ -23,6 +25,8 @@ const MarkdownExportSettings: FC = () => {
|
||||
const markdownExportPath = useSelector((state: RootState) => state.settings.markdownExportPath)
|
||||
const forceDollarMathInMarkdown = useSelector((state: RootState) => state.settings.forceDollarMathInMarkdown)
|
||||
const useTopicNamingForMessageTitle = useSelector((state: RootState) => state.settings.useTopicNamingForMessageTitle)
|
||||
const showModelNameInExport = useSelector((state: RootState) => state.settings.showModelNameInMarkdown)
|
||||
const showModelProviderInMarkdown = useSelector((state: RootState) => state.settings.showModelProviderInMarkdown)
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
const path = await window.api.file.selectFolder()
|
||||
@@ -43,6 +47,14 @@ const MarkdownExportSettings: FC = () => {
|
||||
dispatch(setUseTopicNamingForMessageTitle(checked))
|
||||
}
|
||||
|
||||
const handleToggleShowModelName = (checked: boolean) => {
|
||||
dispatch(setShowModelNameInMarkdown(checked))
|
||||
}
|
||||
|
||||
const handleToggleShowModelProvider = (checked: boolean) => {
|
||||
dispatch(setShowModelProviderInMarkdown(checked))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
|
||||
@@ -86,6 +98,22 @@ const MarkdownExportSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.message_title.use_topic_naming.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.markdown_export.show_model_name.title')}</SettingRowTitle>
|
||||
<Switch checked={showModelNameInExport} onChange={handleToggleShowModelName} />
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.markdown_export.show_model_name.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.markdown_export.show_model_provider.title')}</SettingRowTitle>
|
||||
<Switch checked={showModelProviderInMarkdown} onChange={handleToggleShowModelProvider} />
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.markdown_export.show_model_provider.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setNotionApiKey,
|
||||
setNotionAutoSplit,
|
||||
setNotionDatabaseID,
|
||||
setNotionPageNameKey,
|
||||
setNotionSplitSize
|
||||
setNotionExportReasoning,
|
||||
setNotionPageNameKey
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, InputNumber, Switch, Tooltip } from 'antd'
|
||||
import { Button, Switch, Tooltip } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -27,8 +26,7 @@ const NotionSettings: FC = () => {
|
||||
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
|
||||
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
|
||||
const notionPageNameKey = useSelector((state: RootState) => state.settings.notionPageNameKey)
|
||||
const notionAutoSplit = useSelector((state: RootState) => state.settings.notionAutoSplit)
|
||||
const notionSplitSize = useSelector((state: RootState) => state.settings.notionSplitSize)
|
||||
const notionExportReasoning = useSelector((state: RootState) => state.settings.notionExportReasoning)
|
||||
|
||||
const handleNotionTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setNotionApiKey(e.target.value))
|
||||
@@ -76,14 +74,8 @@ const NotionSettings: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleNotionAutoSplitChange = (checked: boolean) => {
|
||||
dispatch(setNotionAutoSplit(checked))
|
||||
}
|
||||
|
||||
const handleNotionSplitSizeChange = (value: number | null) => {
|
||||
if (value !== null) {
|
||||
dispatch(setNotionSplitSize(value))
|
||||
}
|
||||
const handleNotionExportReasoningChange = (checked: boolean) => {
|
||||
dispatch(setNotionExportReasoning(checked))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -140,38 +132,14 @@ const NotionSettings: FC = () => {
|
||||
<Button onClick={handleNotionConnectionCheck}>{t('settings.data.notion.check.button')}</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider /> {/* 添加分割线 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
<Tooltip title={t('settings.data.notion.auto_split_tip')} placement="right">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{t('settings.data.notion.auto_split')}
|
||||
<InfoCircleOutlined style={{ cursor: 'pointer' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</SettingRowTitle>
|
||||
<Switch checked={notionAutoSplit} onChange={handleNotionAutoSplitChange} />
|
||||
<SettingRowTitle>{t('settings.data.notion.export_reasoning.title')}</SettingRowTitle>
|
||||
<Switch checked={notionExportReasoning} onChange={handleNotionExportReasoningChange} />
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.notion.export_reasoning.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{notionAutoSplit && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.notion.split_size')}</SettingRowTitle>
|
||||
<InputNumber
|
||||
min={30}
|
||||
max={25000}
|
||||
value={notionSplitSize}
|
||||
onChange={handleNotionSplitSizeChange}
|
||||
keyboard={true}
|
||||
controls={true}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText style={{ marginLeft: 10 }}>{t('settings.data.notion.split_size_help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CheckCircleOutlined, QuestionCircleOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { Center, VStack } from '@renderer/components/Layout'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsBunInstalled, setIsUvInstalled } from '@renderer/store/mcp'
|
||||
import { Alert, Button } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
@@ -13,8 +15,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const InstallNpxUv: FC<Props> = ({ mini = false }) => {
|
||||
const [isUvInstalled, setIsUvInstalled] = useState(true)
|
||||
const [isBunInstalled, setIsBunInstalled] = useState(true)
|
||||
const dispatch = useAppDispatch()
|
||||
const isUvInstalled = useAppSelector((state) => state.mcp.isUvInstalled)
|
||||
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
|
||||
|
||||
const [isInstallingUv, setIsInstallingUv] = useState(false)
|
||||
const [isInstallingBun, setIsInstallingBun] = useState(false)
|
||||
const [uvPath, setUvPath] = useState<string | null>(null)
|
||||
@@ -22,18 +26,17 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
|
||||
const [binariesDir, setBinariesDir] = useState<string | null>(null)
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const checkBinaries = async () => {
|
||||
const checkBinaries = useCallback(async () => {
|
||||
const uvExists = await window.api.isBinaryExist('uv')
|
||||
const bunExists = await window.api.isBinaryExist('bun')
|
||||
const { uvPath, bunPath, dir } = await window.api.mcp.getInstallInfo()
|
||||
|
||||
setIsUvInstalled(uvExists)
|
||||
setIsBunInstalled(bunExists)
|
||||
dispatch(setIsUvInstalled(uvExists))
|
||||
dispatch(setIsBunInstalled(bunExists))
|
||||
setUvPath(uvPath)
|
||||
setBunPath(bunPath)
|
||||
setBinariesDir(dir)
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
const installUV = async () => {
|
||||
try {
|
||||
@@ -66,7 +69,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
|
||||
|
||||
useEffect(() => {
|
||||
checkBinaries()
|
||||
}, [])
|
||||
}, [checkBinaries])
|
||||
|
||||
if (mini) {
|
||||
const installed = isUvInstalled && isBunInstalled
|
||||
|
||||
@@ -307,17 +307,21 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
|
||||
</CustomCollapse>
|
||||
</CustomCollapseWrapper>
|
||||
))}
|
||||
{docsWebsite && (
|
||||
{(docsWebsite || modelsWebsite) && (
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
|
||||
<SettingHelpLink target="_blank" href={docsWebsite}>
|
||||
{t(`provider.${provider.id}`) + ' '}
|
||||
{t('common.docs')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('common.and')}</SettingHelpText>
|
||||
<SettingHelpLink target="_blank" href={modelsWebsite}>
|
||||
{t('common.models')}
|
||||
</SettingHelpLink>
|
||||
{docsWebsite && (
|
||||
<SettingHelpLink target="_blank" href={docsWebsite}>
|
||||
{t(`provider.${provider.id}`) + ' '}
|
||||
{t('common.docs')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
{docsWebsite && modelsWebsite && <SettingHelpText>{t('common.and')}</SettingHelpText>}
|
||||
{modelsWebsite && (
|
||||
<SettingHelpLink target="_blank" href={modelsWebsite}>
|
||||
{t('common.models')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
|
||||
@@ -445,6 +445,14 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
)
|
||||
}
|
||||
|
||||
if (thinking_content) {
|
||||
onChunk({
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
text: thinking_content,
|
||||
thinking_millsec: new Date().getTime() - time_first_token_millsec
|
||||
})
|
||||
}
|
||||
|
||||
userMessages.push({
|
||||
role: message.role,
|
||||
content: message.content
|
||||
@@ -464,18 +472,31 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
}
|
||||
}
|
||||
|
||||
finalUsage.prompt_tokens += message.usage?.input_tokens || 0
|
||||
finalUsage.completion_tokens += message.usage?.output_tokens || 0
|
||||
finalUsage.total_tokens += finalUsage.prompt_tokens + finalUsage.completion_tokens
|
||||
finalMetrics.completion_tokens = finalUsage.completion_tokens
|
||||
finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec
|
||||
finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec
|
||||
// 直接修改finalUsage对象会报错,TypeError: Cannot assign to read only property 'prompt_tokens' of object '#<Object>'
|
||||
// 暂未找到原因
|
||||
const updatedUsage: Usage = {
|
||||
...finalUsage,
|
||||
prompt_tokens: finalUsage.prompt_tokens + (message.usage?.input_tokens || 0),
|
||||
completion_tokens: finalUsage.completion_tokens + (message.usage?.output_tokens || 0)
|
||||
}
|
||||
updatedUsage.total_tokens = updatedUsage.prompt_tokens + updatedUsage.completion_tokens
|
||||
|
||||
const updatedMetrics: Metrics = {
|
||||
...finalMetrics,
|
||||
completion_tokens: updatedUsage.completion_tokens,
|
||||
time_completion_millsec:
|
||||
finalMetrics.time_completion_millsec + (new Date().getTime() - start_time_millsec),
|
||||
time_first_token_millsec: time_first_token_millsec - start_time_millsec
|
||||
}
|
||||
|
||||
Object.assign(finalUsage, updatedUsage)
|
||||
Object.assign(finalMetrics, updatedMetrics)
|
||||
|
||||
onChunk({
|
||||
type: ChunkType.BLOCK_COMPLETE,
|
||||
response: {
|
||||
usage: finalUsage,
|
||||
metrics: finalMetrics
|
||||
usage: updatedUsage,
|
||||
metrics: updatedMetrics
|
||||
}
|
||||
})
|
||||
resolve()
|
||||
@@ -488,7 +509,9 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
}
|
||||
onChunk({ type: ChunkType.LLM_RESPONSE_CREATED })
|
||||
const start_time_millsec = new Date().getTime()
|
||||
await processStream(body, 0).finally(cleanup)
|
||||
await processStream(body, 0).finally(() => {
|
||||
cleanup()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -300,7 +300,11 @@ export default class GeminiProvider extends BaseProvider {
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
|
||||
if (effortRatio > 1) {
|
||||
return {}
|
||||
return {
|
||||
thinkingConfig: {
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { max } = findTokenLimit(model.id) || { max: 0 }
|
||||
|
||||
@@ -644,8 +644,6 @@ export default class OpenAIProvider extends BaseOpenAIProvider {
|
||||
yield { type: 'finish', finishReason, usage: chunk.usage, delta, chunk }
|
||||
break
|
||||
}
|
||||
} else {
|
||||
yield { type: 'unknown', chunk }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,14 +1020,20 @@ export default class OpenAIProvider extends BaseOpenAIProvider {
|
||||
|
||||
await this.checkIsCopilot()
|
||||
|
||||
// @ts-ignore key is not typed
|
||||
const response = await this.sdk.chat.completions.create({
|
||||
const params = {
|
||||
model: model.id,
|
||||
messages: [systemMessage, userMessage] as ChatCompletionMessageParam[],
|
||||
stream: false,
|
||||
keep_alive: this.keepAliveTime,
|
||||
max_tokens: 1000
|
||||
})
|
||||
}
|
||||
|
||||
if (isSupportedThinkingTokenQwenModel(model)) {
|
||||
params['enable_thinking'] = false
|
||||
}
|
||||
|
||||
// @ts-ignore key is not typed
|
||||
const response = await this.sdk.chat.completions.create(params as ChatCompletionCreateParamsNonStreaming)
|
||||
|
||||
// 针对思考类模型的返回,总结仅截取</think>之后的内容
|
||||
let content = response.choices[0].message?.content || ''
|
||||
|
||||
@@ -49,11 +49,13 @@ import {
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { Base64 } from 'js-base64'
|
||||
import { isEmpty, takeRight } from 'lodash'
|
||||
import mime from 'mime'
|
||||
import OpenAI from 'openai'
|
||||
import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources/chat/completions'
|
||||
import { Stream } from 'openai/streaming'
|
||||
import { FileLike, toFile } from 'openai/uploads'
|
||||
import { toFile, Uploadable } from 'openai/uploads'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
import BaseProvider from './BaseProvider'
|
||||
@@ -569,6 +571,16 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
|
||||
if (time_first_token_millsec === 0) {
|
||||
time_first_token_millsec = new Date().getTime()
|
||||
}
|
||||
// Insert separation between summary parts
|
||||
if (thinkContent.length > 0) {
|
||||
const separator = '\n\n'
|
||||
onChunk({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: separator,
|
||||
thinking_millsec: new Date().getTime() - time_first_token_millsec
|
||||
})
|
||||
thinkContent += separator
|
||||
}
|
||||
break
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
onChunk({
|
||||
@@ -942,28 +954,32 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
|
||||
if (!model) {
|
||||
return { valid: false, error: new Error('No model found') }
|
||||
}
|
||||
if (stream) {
|
||||
const response = await this.sdk.responses.create({
|
||||
model: model.id,
|
||||
input: [{ role: 'user', content: 'hi' }],
|
||||
stream: true
|
||||
})
|
||||
for await (const chunk of response) {
|
||||
if (chunk.type === 'response.output_text.delta') {
|
||||
return { valid: true, error: null }
|
||||
try {
|
||||
if (stream) {
|
||||
const response = await this.sdk.responses.create({
|
||||
model: model.id,
|
||||
input: [{ role: 'user', content: 'hi' }],
|
||||
stream: true
|
||||
})
|
||||
for await (const chunk of response) {
|
||||
if (chunk.type === 'response.output_text.delta') {
|
||||
return { valid: true, error: null }
|
||||
}
|
||||
}
|
||||
return { valid: false, error: new Error('No streaming response') }
|
||||
} else {
|
||||
const response = await this.sdk.responses.create({
|
||||
model: model.id,
|
||||
input: [{ role: 'user', content: 'hi' }],
|
||||
stream: false
|
||||
})
|
||||
if (!response.output_text) {
|
||||
return { valid: false, error: new Error('No response') }
|
||||
}
|
||||
return { valid: true, error: null }
|
||||
}
|
||||
throw new Error('Empty streaming response')
|
||||
} else {
|
||||
const response = await this.sdk.responses.create({
|
||||
model: model.id,
|
||||
input: [{ role: 'user', content: 'hi' }],
|
||||
stream: false
|
||||
})
|
||||
if (!response.output_text) {
|
||||
throw new Error('Empty response')
|
||||
}
|
||||
return { valid: true, error: null }
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,7 +1052,7 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
|
||||
const { signal } = abortController
|
||||
const content = getMainTextContent(lastUserMessage!)
|
||||
let response: OpenAI.Images.ImagesResponse | null = null
|
||||
let images: FileLike[] = []
|
||||
let images: Uploadable[] = []
|
||||
|
||||
try {
|
||||
if (lastUserMessage) {
|
||||
@@ -1059,19 +1075,16 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
|
||||
const assistantFiles = findImageBlocks(lastAssistantMessage)
|
||||
const assistantImages = await Promise.all(
|
||||
assistantFiles.filter(Boolean).map(async (f) => {
|
||||
const base64Data = f?.url?.replace(/^data:image\/\w+;base64,/, '')
|
||||
if (!base64Data) return null
|
||||
const binary = atob(base64Data)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return await toFile(bytes, 'assistant_image.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
const match = f?.url?.match(/^data:(image\/\w+);base64,(.+)$/)
|
||||
if (!match) return null
|
||||
const mimeType = match[1]
|
||||
const extension = mime.getExtension(mimeType) || 'bin'
|
||||
const bytes = Base64.toUint8Array(match[2])
|
||||
const fileName = `assistant_image.${extension}`
|
||||
return await toFile(bytes, fileName, { type: mimeType })
|
||||
})
|
||||
)
|
||||
images = images.concat(assistantImages.filter(Boolean) as FileLike[])
|
||||
images = images.concat(assistantImages.filter(Boolean) as Uploadable[])
|
||||
}
|
||||
|
||||
onChunk({
|
||||
|
||||
@@ -471,7 +471,7 @@ export function checkApiProvider(provider: Provider): {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkApi(provider: Provider, model: Model) {
|
||||
export async function checkApi(provider: Provider, model: Model): Promise<{ valid: boolean; error: Error | null }> {
|
||||
const validation = checkApiProvider(provider)
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
@@ -484,9 +484,16 @@ export async function checkApi(provider: Provider, model: Model) {
|
||||
|
||||
// Try streaming check first
|
||||
const result = await ai.check(model, true)
|
||||
|
||||
if (result.valid && !result.error) {
|
||||
return result
|
||||
}
|
||||
|
||||
return ai.check(model, false)
|
||||
// 不应该假设错误由流式引发。多次发起检测请求可能触发429,掩盖了真正的问题。
|
||||
// 但这里错误类型做的很粗糙,暂时先这样
|
||||
if (result.error && result.error.message.includes('stream')) {
|
||||
return ai.check(model, false)
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,8 @@ export async function getMessageTitle(message: Message, length = 30): Promise<st
|
||||
if (title) {
|
||||
window.message.success({ content: t('chat.topics.export.title_naming_success'), key: 'message-title-naming' })
|
||||
return title
|
||||
} else {
|
||||
window.message?.error(t('message.error.fetchTopicName'))
|
||||
}
|
||||
} catch (e) {
|
||||
window.message.error({ content: t('chat.topics.export.title_naming_failed'), key: 'message-title-naming' })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user