Compare commits
300 Commits
v1.3.7
...
feat/sideb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e5cc9be9 | ||
|
|
39f74cab3c | ||
|
|
4a0924ce15 | ||
|
|
b7eef3b753 | ||
|
|
d3f5887980 | ||
|
|
8db2059605 | ||
|
|
d11b98dfbb | ||
|
|
38330c4c81 | ||
|
|
b762cfd60b | ||
|
|
278397f7c8 | ||
|
|
c6d5faff73 | ||
|
|
9cac8fba56 | ||
|
|
b7d9949832 | ||
|
|
b4665509ab | ||
|
|
21e88b02ea | ||
|
|
10caef2c4c | ||
|
|
6ea1bcc7d1 | ||
|
|
06a60c4871 | ||
|
|
684367bf7c | ||
|
|
75b9e2f408 | ||
|
|
e13b136484 | ||
|
|
9c5fa57936 | ||
|
|
7e201522d0 | ||
|
|
df35f25502 | ||
|
|
f9e557763e | ||
|
|
eafd814caf | ||
|
|
b84f7bf596 | ||
|
|
c1d753b7fe | ||
|
|
3350f58422 | ||
|
|
8c617872e0 | ||
|
|
a333c635cb | ||
|
|
a244057b3a | ||
|
|
79d7ffcbad | ||
|
|
2d985c1f91 | ||
|
|
5879ccbeb2 | ||
|
|
7887f4867d | ||
|
|
c38a6cdfbf | ||
|
|
ea7766db44 | ||
|
|
a5012ce49e | ||
|
|
d3da4f4623 | ||
|
|
7f12c2f8b8 | ||
|
|
9ba2dea148 | ||
|
|
653bfa1f17 | ||
|
|
fa00b5b173 | ||
|
|
70fb6393b6 | ||
|
|
5b379666f4 | ||
|
|
3cb34d30a9 | ||
|
|
d47c93b4d8 | ||
|
|
bc5cc4bf02 | ||
|
|
8efa7d25f8 | ||
|
|
59195fec1a | ||
|
|
14e6a80049 | ||
|
|
67ab36e0ea | ||
|
|
dfc32967ed | ||
|
|
aa3c376def | ||
|
|
61c58caf78 | ||
|
|
b402cdf7ff | ||
|
|
d80513d011 | ||
|
|
4bcfbf785f | ||
|
|
b722dab56b | ||
|
|
6165e4a47f | ||
|
|
b829abed2d | ||
|
|
36f56ba9aa | ||
|
|
022b11cf6c | ||
|
|
8d6662cb48 | ||
|
|
a59a45f109 | ||
|
|
6337561f65 | ||
|
|
fbbc94028d | ||
|
|
93d955c4b9 | ||
|
|
1c71e6d474 | ||
|
|
b2d10b7a6b | ||
|
|
1215bcb046 | ||
|
|
9195a0324e | ||
|
|
acbec213e8 | ||
|
|
e2a08e31e8 | ||
|
|
e479ee3dbc | ||
|
|
f6462ef998 | ||
|
|
dcdf49a5ce | ||
|
|
74f72fa5b6 | ||
|
|
36f33fed75 | ||
|
|
eb7c05fd4c | ||
|
|
cb746fd722 | ||
|
|
0449bc359a | ||
|
|
d3e51ffb1c | ||
|
|
77eb70626c | ||
|
|
345c4f096e | ||
|
|
a4aab3fd4e | ||
|
|
ecf770e183 | ||
|
|
d58911ac60 | ||
|
|
bb0a35b920 | ||
|
|
403649f2ea | ||
|
|
958f8387d0 | ||
|
|
9c89676030 | ||
|
|
34ec018840 | ||
|
|
1be103a249 | ||
|
|
f83f8bb789 | ||
|
|
cc2810b117 | ||
|
|
be1dae7ef0 | ||
|
|
446d26d8dc | ||
|
|
7724b49ec4 | ||
|
|
ecbd283779 | ||
|
|
389f750d7b | ||
|
|
23eaae80c8 | ||
|
|
8f8c2f852e | ||
|
|
13f7269e36 | ||
|
|
0cd62a07fb | ||
|
|
20b55693cb | ||
|
|
74cccf2c09 | ||
|
|
54d20aa99b | ||
|
|
2c8086f078 | ||
|
|
ea061a3ba6 | ||
|
|
28a6ba1b5d | ||
|
|
8b793a9ca9 | ||
|
|
fe1cf5d605 | ||
|
|
f0335b5aaa | ||
|
|
6c394ec375 | ||
|
|
9f49ce6dc9 | ||
|
|
0df331cf8a | ||
|
|
a5a04e1df7 | ||
|
|
170d1a3a9c | ||
|
|
ce941b6532 | ||
|
|
c5fc7df258 | ||
|
|
30844b8e21 | ||
|
|
99b00cedb4 | ||
|
|
63242384d6 | ||
|
|
e83d31a232 | ||
|
|
65c7b720de | ||
|
|
77ecfbac9f | ||
|
|
1a090a7c51 | ||
|
|
a88bf104df | ||
|
|
c9caa5f46b | ||
|
|
96ae5df1f1 | ||
|
|
6048f42740 | ||
|
|
5b199aa736 | ||
|
|
a6bb58bb45 | ||
|
|
a78db10798 | ||
|
|
479b3ccfb7 | ||
|
|
f916002a71 | ||
|
|
c5208eeaef | ||
|
|
2e8cbdc4aa | ||
|
|
77b0dfc8d3 | ||
|
|
c5c5681cfd | ||
|
|
808afa053f | ||
|
|
cb75d01fd3 | ||
|
|
3ae7bbf304 | ||
|
|
fc3d536433 | ||
|
|
36abf3f099 | ||
|
|
3d7fd5a30c | ||
|
|
f83d9fc03c | ||
|
|
94e6ba759e | ||
|
|
c8c30f327b | ||
|
|
72fae1af25 | ||
|
|
98f8bacdc8 | ||
|
|
06f6da725d | ||
|
|
d24eabb97c | ||
|
|
eca3f1d71e | ||
|
|
87d178773a | ||
|
|
02cb005668 | ||
|
|
cf1d5c098f | ||
|
|
65273b055c | ||
|
|
f171839830 | ||
|
|
8f9a5642f2 | ||
|
|
e906d5db25 | ||
|
|
80c09a07dc | ||
|
|
af6145600a | ||
|
|
42bda59392 | ||
|
|
e73f6505e9 | ||
|
|
332aa45618 | ||
|
|
253075e332 | ||
|
|
737b8f02b1 | ||
|
|
2a996e2c9a | ||
|
|
c77d627077 | ||
|
|
11daf93094 | ||
|
|
44b07ee35d | ||
|
|
b24de23219 | ||
|
|
431e2aaa13 | ||
|
|
9896c75a2e | ||
|
|
94cec70737 | ||
|
|
2ba4e51e93 | ||
|
|
665a62080b | ||
|
|
a05a7e45cc | ||
|
|
f8e9216270 | ||
|
|
11d72f14dc | ||
|
|
f36735f6db | ||
|
|
1b0b08c4c4 | ||
|
|
13d440b0b6 | ||
|
|
2dc81ab8c8 | ||
|
|
b2b0fe9072 | ||
|
|
da30b52334 | ||
|
|
e854ef8757 | ||
|
|
d90ac44945 | ||
|
|
55852cb0a1 | ||
|
|
c28afebdfd | ||
|
|
07407f751f | ||
|
|
4726673508 | ||
|
|
5dc48580a0 | ||
|
|
676c1cbe83 | ||
|
|
6d61bcd605 | ||
|
|
ee78dbd27e | ||
|
|
d88d78e143 | ||
|
|
458f017517 | ||
|
|
f462b7f94e | ||
|
|
94792c9bb1 | ||
|
|
adef817e86 | ||
|
|
2f312d68a0 | ||
|
|
a7520169e6 | ||
|
|
59e3082642 | ||
|
|
795d12c91e | ||
|
|
8eb0be7562 | ||
|
|
bca4fbe7de | ||
|
|
2b99d6066f | ||
|
|
9357bd6e0f | ||
|
|
c4e0744806 | ||
|
|
1b6cba454d | ||
|
|
77cd958d08 | ||
|
|
d8aac9ecb8 | ||
|
|
2758321821 | ||
|
|
9dfa81f11f | ||
|
|
3693e115a6 | ||
|
|
2f3a3c8c48 | ||
|
|
f6d71868cb | ||
|
|
e5bf6916a6 | ||
|
|
7c119e2747 | ||
|
|
03ef52c6a7 | ||
|
|
e21e0d238e | ||
|
|
631fa3a42a | ||
|
|
ee042e11f1 | ||
|
|
178d164ff9 | ||
|
|
e5ded81d9b | ||
|
|
c8e6872fb8 | ||
|
|
2820f85be4 | ||
|
|
1989f246fd | ||
|
|
3ac414e97b | ||
|
|
bcb93fc2d0 | ||
|
|
9f579334f8 | ||
|
|
6f75b1738d | ||
|
|
72d939721d | ||
|
|
7d6ef1d69a | ||
|
|
e162da55bd | ||
|
|
fc5209723f | ||
|
|
75153ce83c | ||
|
|
fd1cf1331f | ||
|
|
a5738fdae5 | ||
|
|
86b95ee17a | ||
|
|
587e1d9971 | ||
|
|
b8e978f2a1 | ||
|
|
bd8452032e | ||
|
|
0079f4f437 | ||
|
|
0436ea671e | ||
|
|
05c29b2bc1 | ||
|
|
3f97aef93f | ||
|
|
2e3adb0b1b | ||
|
|
507da84b80 | ||
|
|
b30a30efa8 | ||
|
|
c29ff577ed | ||
|
|
f9512d6ef2 | ||
|
|
326163d798 | ||
|
|
7099bee833 | ||
|
|
b1839b722f | ||
|
|
46ae4f9b55 | ||
|
|
a9a0ae87d3 | ||
|
|
d9661602b2 | ||
|
|
877a1f8306 | ||
|
|
5dd508b4f4 | ||
|
|
406bf6a509 | ||
|
|
f0ae2aa6dc | ||
|
|
0884caea97 | ||
|
|
8e870710b5 | ||
|
|
27a92463bf | ||
|
|
3298b3f403 | ||
|
|
894c20dd05 | ||
|
|
a0945af285 | ||
|
|
6e12d2fa2e | ||
|
|
2798bd9d9d | ||
|
|
d07c6ecc6b | ||
|
|
57f10bf56f | ||
|
|
d4fc5d6503 | ||
|
|
55c57d72ba | ||
|
|
c468c3cfd5 | ||
|
|
bb3bfafe7e | ||
|
|
0641857f26 | ||
|
|
0d5b9d8d62 | ||
|
|
09952a4d3b | ||
|
|
e8753e0cf3 | ||
|
|
05dc5da7b0 | ||
|
|
12f5d9acfd | ||
|
|
30885655dd | ||
|
|
49d4ae6e9b | ||
|
|
2d7caf9fc5 | ||
|
|
5cf0a43b2c | ||
|
|
98426e084a | ||
|
|
475c1e38df | ||
|
|
80289f1dc3 | ||
|
|
ef16558947 | ||
|
|
c799f15fcc | ||
|
|
802402e922 | ||
|
|
37482bca7b | ||
|
|
184713dba8 | ||
|
|
0a0956cfc4 | ||
|
|
0a0bbad77f |
86
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 7
|
||||
target-branch: "main"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
groups:
|
||||
# 核心框架
|
||||
core-framework:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "electron"
|
||||
- "typescript"
|
||||
- "@types/react*"
|
||||
- "@types/node"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Electron 生态和构建工具
|
||||
electron-build:
|
||||
patterns:
|
||||
- "electron-*"
|
||||
- "@electron*"
|
||||
- "vite"
|
||||
- "@vitejs/*"
|
||||
- "dotenv-cli"
|
||||
- "rollup-plugin-*"
|
||||
- "@swc/*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# 测试工具
|
||||
testing-tools:
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
- "playwright"
|
||||
- "@playwright/*"
|
||||
- "eslint*"
|
||||
- "@eslint*"
|
||||
- "prettier"
|
||||
- "husky"
|
||||
- "lint-staged"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# CherryStudio 自定义包
|
||||
cherrystudio-packages:
|
||||
patterns:
|
||||
- "@cherrystudio/*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# 兜底其他 dependencies
|
||||
other-dependencies:
|
||||
dependency-type: "production"
|
||||
|
||||
# 兜底其他 devDependencies
|
||||
other-dev-dependencies:
|
||||
dependency-type: "development"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
2
.github/workflows/issue-management.yml
vendored
@@ -54,5 +54,5 @@ jobs:
|
||||
days-before-pr-close: -1 # Completely disable closing for PRs
|
||||
|
||||
# Temporary to reduce the huge issues number
|
||||
operations-per-run: 100
|
||||
operations-per-run: 1000
|
||||
debug-only: false
|
||||
|
||||
2
.github/workflows/nightly-build.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
ref: main
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
37
.github/workflows/release.yml
vendored
@@ -113,5 +113,40 @@ jobs:
|
||||
allowUpdates: true
|
||||
makeLatest: false
|
||||
tag: ${{ steps.get-tag.outputs.tag }}
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
dispatch-docs-update:
|
||||
needs: release
|
||||
if: success() && github.repository == 'CherryHQ/cherry-studio' # 确保所有构建成功且在主仓库中运行
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get release tag
|
||||
id: get-tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if tag is pre-release
|
||||
id: check-tag
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${{ steps.get-tag.outputs.tag }}"
|
||||
if [[ "$TAG" == *"rc"* || "$TAG" == *"pre-release"* ]]; then
|
||||
echo "is_pre_release=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_pre_release=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Dispatch update-download-version workflow to cherry-studio-docs
|
||||
if: steps.check-tag.outputs.is_pre_release == 'false'
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
|
||||
repository: CherryHQ/cherry-studio-docs
|
||||
event-type: update-download-version
|
||||
client-payload: '{"version": "${{ steps.get-tag.outputs.tag }}"}'
|
||||
10
.gitignore
vendored
@@ -45,9 +45,15 @@ stats.html
|
||||
local
|
||||
.aider*
|
||||
.cursorrules
|
||||
.cursor/rules
|
||||
.cursor/*
|
||||
|
||||
# test
|
||||
# vitest
|
||||
coverage
|
||||
.vitest-cache
|
||||
vitest.config.*.timestamp-*
|
||||
|
||||
# playwright
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
YOUR_MEMORY_FILE_PATH
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"printWidth": 120,
|
||||
"trailingComma": "none",
|
||||
"endOfLine": "lf",
|
||||
"bracketSameLine": true
|
||||
"bracketSameLine": true,
|
||||
"tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css",
|
||||
"tailwindFunctions": ["clsx"],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
}
|
||||
|
||||
71
.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs
|
||||
index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644
|
||||
--- a/dist/utils/tiktoken.cjs
|
||||
+++ b/dist/utils/tiktoken.cjs
|
||||
@@ -1,25 +1,14 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encodingForModel = exports.getEncoding = void 0;
|
||||
-const lite_1 = require("js-tiktoken/lite");
|
||||
const async_caller_js_1 = require("./async_caller.cjs");
|
||||
const cache = {};
|
||||
const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({});
|
||||
async function getEncoding(encoding) {
|
||||
- if (!(encoding in cache)) {
|
||||
- cache[encoding] = caller
|
||||
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
|
||||
- .then((res) => res.json())
|
||||
- .then((data) => new lite_1.Tiktoken(data))
|
||||
- .catch((e) => {
|
||||
- delete cache[encoding];
|
||||
- throw e;
|
||||
- });
|
||||
- }
|
||||
- return await cache[encoding];
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
exports.getEncoding = getEncoding;
|
||||
async function encodingForModel(model) {
|
||||
- return getEncoding((0, lite_1.getEncodingNameForModel)(model));
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
exports.encodingForModel = encodingForModel;
|
||||
diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js
|
||||
index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644
|
||||
--- a/dist/utils/tiktoken.js
|
||||
+++ b/dist/utils/tiktoken.js
|
||||
@@ -1,20 +1,9 @@
|
||||
-import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite";
|
||||
import { AsyncCaller } from "./async_caller.js";
|
||||
const cache = {};
|
||||
const caller = /* #__PURE__ */ new AsyncCaller({});
|
||||
export async function getEncoding(encoding) {
|
||||
- if (!(encoding in cache)) {
|
||||
- cache[encoding] = caller
|
||||
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
|
||||
- .then((res) => res.json())
|
||||
- .then((data) => new Tiktoken(data))
|
||||
- .catch((e) => {
|
||||
- delete cache[encoding];
|
||||
- throw e;
|
||||
- });
|
||||
- }
|
||||
- return await cache[encoding];
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
export async function encodingForModel(model) {
|
||||
- return getEncoding(getEncodingNameForModel(model));
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
diff --git a/package.json b/package.json
|
||||
index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -37,7 +37,6 @@
|
||||
"ansi-styles": "^5.0.0",
|
||||
"camelcase": "6",
|
||||
"decamelize": "1.2.0",
|
||||
- "js-tiktoken": "^1.0.12",
|
||||
"langsmith": ">=0.2.8 <0.4.0",
|
||||
"mustache": "^4.2.0",
|
||||
"p-queue": "^6.6.2",
|
||||
85
.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch
vendored
@@ -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
@@ -0,0 +1,279 @@
|
||||
diff --git a/client.js b/client.js
|
||||
index 33b4ff6309d5f29187dab4e285d07dac20340bab..8f568637ee9e4677585931fb0284c8165a933f69 100644
|
||||
--- a/client.js
|
||||
+++ b/client.js
|
||||
@@ -433,7 +433,7 @@ class OpenAI {
|
||||
'User-Agent': this.getUserAgent(),
|
||||
'X-Stainless-Retry-Count': String(retryCount),
|
||||
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
|
||||
- ...(0, detect_platform_1.getPlatformHeaders)(),
|
||||
+ // ...(0, detect_platform_1.getPlatformHeaders)(),
|
||||
'OpenAI-Organization': this.organization,
|
||||
'OpenAI-Project': this.project,
|
||||
},
|
||||
diff --git a/client.mjs b/client.mjs
|
||||
index c34c18213073540ebb296ea540b1d1ad39527906..1ce1a98256d7e90e26ca963582f235b23e996e73 100644
|
||||
--- a/client.mjs
|
||||
+++ b/client.mjs
|
||||
@@ -430,7 +430,7 @@ export class OpenAI {
|
||||
'User-Agent': this.getUserAgent(),
|
||||
'X-Stainless-Retry-Count': String(retryCount),
|
||||
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
'OpenAI-Organization': this.organization,
|
||||
'OpenAI-Project': this.project,
|
||||
},
|
||||
diff --git a/core/error.js b/core/error.js
|
||||
index a12d9d9ccd242050161adeb0f82e1b98d9e78e20..fe3a5462480558bc426deea147f864f12b36f9bd 100644
|
||||
--- a/core/error.js
|
||||
+++ b/core/error.js
|
||||
@@ -40,7 +40,7 @@ class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: (0, errors_1.castToError)(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/core/error.mjs b/core/error.mjs
|
||||
index 83cefbaffeb8c657536347322d8de9516af479a2..63334b7972ec04882aa4a0800c1ead5982345045 100644
|
||||
--- a/core/error.mjs
|
||||
+++ b/core/error.mjs
|
||||
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
|
||||
if (!status || !headers) {
|
||||
return new APIConnectionError({ message, cause: castToError(errorResponse) });
|
||||
}
|
||||
- const error = errorResponse?.['error'];
|
||||
+ const error = errorResponse?.['error'] || errorResponse;
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
diff --git a/resources/embeddings.js b/resources/embeddings.js
|
||||
index 2404264d4ba0204322548945ebb7eab3bea82173..8f1bc45cc45e0797d50989d96b51147b90ae6790 100644
|
||||
--- a/resources/embeddings.js
|
||||
+++ b/resources/embeddings.js
|
||||
@@ -5,52 +5,64 @@ exports.Embeddings = void 0;
|
||||
const resource_1 = require("../core/resource.js");
|
||||
const utils_1 = require("../internal/utils.js");
|
||||
class Embeddings extends resource_1.APIResource {
|
||||
- /**
|
||||
- * Creates an embedding vector representing the input text.
|
||||
- *
|
||||
- * @example
|
||||
- * ```ts
|
||||
- * const createEmbeddingResponse =
|
||||
- * await client.embeddings.create({
|
||||
- * input: 'The quick brown fox jumped over the lazy dog',
|
||||
- * model: 'text-embedding-3-small',
|
||||
- * });
|
||||
- * ```
|
||||
- */
|
||||
- create(body, options) {
|
||||
- const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
- // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
- // See https://github.com/openai/openai-node/pull/1312
|
||||
- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- (0, utils_1.loggerFor)(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
|
||||
- }
|
||||
- const response = this._client.post('/embeddings', {
|
||||
- body: {
|
||||
- ...body,
|
||||
- encoding_format: encoding_format,
|
||||
- },
|
||||
- ...options,
|
||||
- });
|
||||
- // if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- return response;
|
||||
- }
|
||||
- // in this stage, we are sure the user did not specify an encoding_format
|
||||
- // and we defaulted to base64 for performance reasons
|
||||
- // we are sure then that the response is base64 encoded, let's decode it
|
||||
- // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
- (0, utils_1.loggerFor)(this._client).debug('embeddings/decoding base64 embeddings from base64');
|
||||
- return response._thenUnwrap((response) => {
|
||||
- if (response && response.data) {
|
||||
- response.data.forEach((embeddingBase64Obj) => {
|
||||
- const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
- embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(embeddingBase64Str);
|
||||
- });
|
||||
- }
|
||||
- return response;
|
||||
- });
|
||||
- }
|
||||
+ /**
|
||||
+ * Creates an embedding vector representing the input text.
|
||||
+ *
|
||||
+ * @example
|
||||
+ * ```ts
|
||||
+ * const createEmbeddingResponse =
|
||||
+ * await client.embeddings.create({
|
||||
+ * input: 'The quick brown fox jumped over the lazy dog',
|
||||
+ * model: 'text-embedding-3-small',
|
||||
+ * });
|
||||
+ * ```
|
||||
+ */
|
||||
+ create(body, options) {
|
||||
+ const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
+ // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
+ // See https://github.com/openai/openai-node/pull/1312
|
||||
+ let encoding_format = hasUserProvidedEncodingFormat
|
||||
+ ? body.encoding_format
|
||||
+ : "base64";
|
||||
+ if (body.model.includes("jina")) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
+ if (hasUserProvidedEncodingFormat) {
|
||||
+ (0, utils_1.loggerFor)(this._client).debug(
|
||||
+ "embeddings/user defined encoding_format:",
|
||||
+ body.encoding_format
|
||||
+ );
|
||||
+ }
|
||||
+ const response = this._client.post("/embeddings", {
|
||||
+ body: {
|
||||
+ ...body,
|
||||
+ encoding_format: encoding_format,
|
||||
+ },
|
||||
+ ...options,
|
||||
+ });
|
||||
+ // if the user specified an encoding_format, return the response as-is
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
|
||||
+ return response;
|
||||
+ }
|
||||
+ // in this stage, we are sure the user did not specify an encoding_format
|
||||
+ // and we defaulted to base64 for performance reasons
|
||||
+ // we are sure then that the response is base64 encoded, let's decode it
|
||||
+ // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
+ (0, utils_1.loggerFor)(this._client).debug(
|
||||
+ "embeddings/decoding base64 embeddings from base64"
|
||||
+ );
|
||||
+ return response._thenUnwrap((response) => {
|
||||
+ if (response && response.data) {
|
||||
+ response.data.forEach((embeddingBase64Obj) => {
|
||||
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
+ embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(
|
||||
+ embeddingBase64Str
|
||||
+ );
|
||||
+ });
|
||||
+ }
|
||||
+ return response;
|
||||
+ });
|
||||
+ }
|
||||
}
|
||||
exports.Embeddings = Embeddings;
|
||||
//# sourceMappingURL=embeddings.js.map
|
||||
diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
|
||||
index 19dcaef578c194a89759c4360073cfd4f7dd2cbf..0284e9cc615c900eff508eb595f7360a74bd9200 100644
|
||||
--- a/resources/embeddings.mjs
|
||||
+++ b/resources/embeddings.mjs
|
||||
@@ -2,51 +2,61 @@
|
||||
import { APIResource } from "../core/resource.mjs";
|
||||
import { loggerFor, toFloat32Array } from "../internal/utils.mjs";
|
||||
export class Embeddings extends APIResource {
|
||||
- /**
|
||||
- * Creates an embedding vector representing the input text.
|
||||
- *
|
||||
- * @example
|
||||
- * ```ts
|
||||
- * const createEmbeddingResponse =
|
||||
- * await client.embeddings.create({
|
||||
- * input: 'The quick brown fox jumped over the lazy dog',
|
||||
- * model: 'text-embedding-3-small',
|
||||
- * });
|
||||
- * ```
|
||||
- */
|
||||
- create(body, options) {
|
||||
- const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
- // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
- // See https://github.com/openai/openai-node/pull/1312
|
||||
- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- loggerFor(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
|
||||
- }
|
||||
- const response = this._client.post('/embeddings', {
|
||||
- body: {
|
||||
- ...body,
|
||||
- encoding_format: encoding_format,
|
||||
- },
|
||||
- ...options,
|
||||
- });
|
||||
- // if the user specified an encoding_format, return the response as-is
|
||||
- if (hasUserProvidedEncodingFormat) {
|
||||
- return response;
|
||||
- }
|
||||
- // in this stage, we are sure the user did not specify an encoding_format
|
||||
- // and we defaulted to base64 for performance reasons
|
||||
- // we are sure then that the response is base64 encoded, let's decode it
|
||||
- // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
- loggerFor(this._client).debug('embeddings/decoding base64 embeddings from base64');
|
||||
- return response._thenUnwrap((response) => {
|
||||
- if (response && response.data) {
|
||||
- response.data.forEach((embeddingBase64Obj) => {
|
||||
- const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
- embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
|
||||
- });
|
||||
- }
|
||||
- return response;
|
||||
- });
|
||||
- }
|
||||
+ /**
|
||||
+ * Creates an embedding vector representing the input text.
|
||||
+ *
|
||||
+ * @example
|
||||
+ * ```ts
|
||||
+ * const createEmbeddingResponse =
|
||||
+ * await client.embeddings.create({
|
||||
+ * input: 'The quick brown fox jumped over the lazy dog',
|
||||
+ * model: 'text-embedding-3-small',
|
||||
+ * });
|
||||
+ * ```
|
||||
+ */
|
||||
+ create(body, options) {
|
||||
+ const hasUserProvidedEncodingFormat = !!body.encoding_format;
|
||||
+ // No encoding_format specified, defaulting to base64 for performance reasons
|
||||
+ // See https://github.com/openai/openai-node/pull/1312
|
||||
+ let encoding_format = hasUserProvidedEncodingFormat
|
||||
+ ? body.encoding_format
|
||||
+ : "base64";
|
||||
+ if (body.model.includes("jina")) {
|
||||
+ encoding_format = undefined;
|
||||
+ }
|
||||
+ if (hasUserProvidedEncodingFormat) {
|
||||
+ loggerFor(this._client).debug(
|
||||
+ "embeddings/user defined encoding_format:",
|
||||
+ body.encoding_format
|
||||
+ );
|
||||
+ }
|
||||
+ const response = this._client.post("/embeddings", {
|
||||
+ body: {
|
||||
+ ...body,
|
||||
+ encoding_format: encoding_format,
|
||||
+ },
|
||||
+ ...options,
|
||||
+ });
|
||||
+ // if the user specified an encoding_format, return the response as-is
|
||||
+ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
|
||||
+ return response;
|
||||
+ }
|
||||
+ // in this stage, we are sure the user did not specify an encoding_format
|
||||
+ // and we defaulted to base64 for performance reasons
|
||||
+ // we are sure then that the response is base64 encoded, let's decode it
|
||||
+ // the returned result will be a float32 array since this is OpenAI API's default encoding
|
||||
+ loggerFor(this._client).debug(
|
||||
+ "embeddings/decoding base64 embeddings from base64"
|
||||
+ );
|
||||
+ return response._thenUnwrap((response) => {
|
||||
+ if (response && response.data) {
|
||||
+ response.data.forEach((embeddingBase64Obj) => {
|
||||
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
|
||||
+ embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
|
||||
+ });
|
||||
+ }
|
||||
+ return response;
|
||||
+ });
|
||||
+ }
|
||||
}
|
||||
//# sourceMappingURL=embeddings.mjs.map
|
||||
934
.yarn/releases/yarn-4.6.0.cjs
vendored
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
@@ -4,4 +4,4 @@ httpTimeout: 300000
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.6.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.9.1.cjs
|
||||
|
||||
154
README.md
@@ -3,10 +3,42 @@
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p>
|
||||
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/dev.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
|
||||
|
||||
<!-- 题头徽章组合 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][deepwiki-shield]][deepwiki-link]
|
||||
[![][twitter-shield]][twitter-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
[![][telegram-shield]][telegram-link]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 项目统计徽章 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][license-shield]][license-link]
|
||||
[![][commercial-shield]][commercial-link]
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
@@ -17,15 +49,13 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
|
||||
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
|
||||
|
||||
# 📖 Guide
|
||||
|
||||
<https://docs.cherry-ai.com>
|
||||
|
||||
# 🌠 Screenshot
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 Key Features
|
||||
|
||||
@@ -65,20 +95,42 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
- 📝 Complete Markdown Rendering
|
||||
- 🤲 Easy Content Sharing
|
||||
|
||||
# 📝 TODO
|
||||
# 📝 Roadmap
|
||||
|
||||
- [x] Quick popup (read clipboard, quick question, explain, translate, summarize)
|
||||
- [x] Comparison of multi-model answers
|
||||
- [x] Support login using SSO provided by service providers
|
||||
- [x] All models support networking
|
||||
- [x] Launch of the first official version
|
||||
- [x] Bug fixes and improvements (In progress...)
|
||||
- [ ] Plugin functionality (JavaScript)
|
||||
- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base)
|
||||
- [ ] iOS & Android client
|
||||
- [ ] AI notes
|
||||
- [ ] Voice input and output (AI call)
|
||||
- [ ] Data backup supports custom backup content
|
||||
We're actively working on the following features and improvements:
|
||||
|
||||
1. 🎯 **Core Features**
|
||||
|
||||
- Selection Assistant - Smart content selection enhancement
|
||||
- Deep Research - Advanced research capabilities
|
||||
- Memory System - Global context awareness
|
||||
- Document Preprocessing - Improved document handling
|
||||
- MCP Marketplace - Model Context Protocol ecosystem
|
||||
|
||||
2. 🗂 **Knowledge Management**
|
||||
|
||||
- Notes and Collections
|
||||
- Dynamic Canvas visualization
|
||||
- OCR capabilities
|
||||
- TTS (Text-to-Speech) support
|
||||
|
||||
3. 📱 **Platform Support**
|
||||
|
||||
- HarmonyOS Edition (PC)
|
||||
- Android App (Phase 1)
|
||||
- iOS App (Phase 1)
|
||||
- Multi-Window support
|
||||
- Window Pinning functionality
|
||||
|
||||
4. 🔌 **Advanced Features**
|
||||
|
||||
- Plugin System
|
||||
- ASR (Automatic Speech Recognition)
|
||||
- Assistant and Topic Interaction Refactoring
|
||||
|
||||
Track our progress and contribute on our [project board](https://github.com/orgs/CherryHQ/projects/7).
|
||||
|
||||
Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/CherryHQ/cherry-studio/discussions) to share your ideas and feedback!
|
||||
|
||||
# 🌈 Theme
|
||||
|
||||
@@ -90,14 +142,6 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
|
||||
Welcome PR for more themes
|
||||
|
||||
# 🖥️ Develop
|
||||
|
||||
Refer to the [development documentation](docs/dev.md)
|
||||
|
||||
Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio)
|
||||
|
||||
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
|
||||
|
||||
# 🤝 Contributing
|
||||
|
||||
We welcome contributions to Cherry Studio! Here are some ways you can contribute:
|
||||
@@ -110,6 +154,8 @@ We welcome contributions to Cherry Studio! Here are some ways you can contribute
|
||||
6. **Community Engagement**: Join discussions and help users.
|
||||
7. **Promote Usage**: Spread the word about Cherry Studio.
|
||||
|
||||
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork the Repository**: Fork and clone it to your local machine.
|
||||
@@ -121,7 +167,7 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB
|
||||
|
||||
Thank you for your support and contributions!
|
||||
|
||||
## Related Projects
|
||||
# 🔗 Related Projects
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution.
|
||||
|
||||
@@ -134,22 +180,34 @@ Thank you for your support and contributions!
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 Community
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ Sponsor
|
||||
|
||||
[Buy Me a Coffee](docs/sponsor.md)
|
||||
|
||||
# 📃 License
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
# ✉️ Contact
|
||||
|
||||
<yinsenho@cherry-ai.com>
|
||||
|
||||
# ⭐️ Star History
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
|
||||
|
||||
<!-- Links & Images -->
|
||||
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
|
||||
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
|
||||
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
|
||||
[twitter-link]: https://twitter.com/CherryStudioApp
|
||||
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
|
||||
[discord-link]: https://discord.gg/wez8HtpxqQ
|
||||
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
|
||||
[telegram-link]: https://t.me/CherryStudioAI
|
||||
|
||||
<!-- Links & Images -->
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
|
||||
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
|
||||
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
|
||||
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
|
||||
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
|
||||
|
||||
<!-- Links & Images -->
|
||||
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
|
||||
[license-link]: https://www.gnu.org/licenses/agpl-3.0
|
||||
[commercial-shield]: https://img.shields.io/badge/License-Contact-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
|
||||
[commercial-link]: mailto:license@cherry-ai.com?subject=Commercial%20License%20Inquiry
|
||||
[sponsor-shield]: https://img.shields.io/badge/Sponsor-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
|
||||
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md
|
||||
|
||||
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/renderer/src/assets/styles/tailwind.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@renderer/ui/third-party",
|
||||
"utils": "@renderer/utils",
|
||||
"ui": "@renderer/ui",
|
||||
"lib": "@renderer/lib",
|
||||
"hooks": "@renderer/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,15 +1,46 @@
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/releases">
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 <br>
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 | <a href="https://cherry-ai.com">公式サイト</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/ja">ドキュメント</a> | <a href="./dev.md">開発</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">フィードバック</a><br>
|
||||
</p>
|
||||
|
||||
<!-- バッジコレクション -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][deepwiki-shield]][deepwiki-link]
|
||||
[![][twitter-shield]][twitter-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
[![][telegram-shield]][telegram-link]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- プロジェクト統計 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][license-shield]][license-link]
|
||||
[![][commercial-shield]][commercial-link]
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
@@ -20,15 +51,13 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
|
||||
|
||||
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!
|
||||
|
||||
# 📖 ガイド
|
||||
|
||||
https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 スクリーンショット
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 主な機能
|
||||
|
||||
@@ -68,20 +97,42 @@ https://docs.cherry-ai.com
|
||||
- 📝 完全な Markdown レンダリング
|
||||
- 🤲 簡単な共有機能
|
||||
|
||||
# 📝 TODO
|
||||
# 📝 開発計画
|
||||
|
||||
- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約)
|
||||
- [x] 複数モデルの回答の比較
|
||||
- [x] サービスプロバイダーが提供する SSO を使用したログイン対応
|
||||
- [x] すべてのモデルのネットワーク対応
|
||||
- [x] 最初の公式バージョンのリリース
|
||||
- [x] バグ修正と改善(進行中...)
|
||||
- [ ] プラグイン機能(JavaScript)
|
||||
- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加)
|
||||
- [ ] iOS & Android クライアント
|
||||
- [ ] AI ノート
|
||||
- [ ] 音声入出力(AI コール)
|
||||
- [ ] データバックアップのカスタマイズ対応
|
||||
以下の機能と改善に積極的に取り組んでいます:
|
||||
|
||||
1. 🎯 **コア機能**
|
||||
|
||||
- 選択アシスタント - スマートな内容選択の強化
|
||||
- ディープリサーチ - 高度な研究能力
|
||||
- メモリーシステム - グローバルコンテキスト認識
|
||||
- ドキュメント前処理 - 文書処理の改善
|
||||
- MCP マーケットプレイス - モデルコンテキストプロトコルエコシステム
|
||||
|
||||
2. 🗂 **ナレッジ管理**
|
||||
|
||||
- ノートとコレクション
|
||||
- ダイナミックキャンバス可視化
|
||||
- OCR 機能
|
||||
- TTS(テキスト読み上げ)サポート
|
||||
|
||||
3. 📱 **プラットフォーム対応**
|
||||
|
||||
- HarmonyOS エディション
|
||||
- Android アプリ(フェーズ1)
|
||||
- iOS アプリ(フェーズ1)
|
||||
- マルチウィンドウ対応
|
||||
- ウィンドウピン留め機能
|
||||
|
||||
4. 🔌 **高度な機能**
|
||||
|
||||
- プラグインシステム
|
||||
- ASR(音声認識)
|
||||
- アシスタントとトピックの対話機能リファクタリング
|
||||
|
||||
[プロジェクトボード](https://github.com/orgs/CherryHQ/projects/7)で進捗を確認し、貢献することができます。
|
||||
|
||||
開発計画に影響を与えたいですか?[GitHub ディスカッション](https://github.com/CherryHQ/cherry-studio/discussions)に参加して、アイデアやフィードバックを共有してください!
|
||||
|
||||
# 🌈 テーマ
|
||||
|
||||
@@ -93,14 +144,6 @@ https://docs.cherry-ai.com
|
||||
|
||||
より多くのテーマの PR を歓迎します
|
||||
|
||||
# 🖥️ 開発
|
||||
|
||||
[開発ドキュメント](dev.md)を参照してください
|
||||
|
||||
[アーキテクチャ概要ドキュメント](https://deepwiki.com/CherryHQ/cherry-studio)を参照してください
|
||||
|
||||
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
|
||||
|
||||
# 🤝 貢献
|
||||
|
||||
Cherry Studio への貢献を歓迎します!以下の方法で貢献できます:
|
||||
@@ -113,6 +156,8 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
|
||||
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します
|
||||
7. **使用の促進**:Cherry Studio を広めます
|
||||
|
||||
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
|
||||
|
||||
## 始め方
|
||||
|
||||
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします
|
||||
@@ -124,7 +169,7 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
|
||||
|
||||
ご支援と貢献に感謝します!
|
||||
|
||||
## 関連プロジェクト
|
||||
# 🔗 関連プロジェクト
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api):LLM API の管理・配信システム。OpenAI、Azure、Anthropic などの主要モデルに対応し、統一 API インターフェースを提供。API キー管理と再配布に利用可能。
|
||||
|
||||
@@ -137,22 +182,34 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 コミュニティ
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ スポンサー
|
||||
|
||||
[開発者を支援する](sponsor.md)
|
||||
|
||||
# 📃 ライセンス
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ✉️ お問い合わせ
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ スター履歴
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
|
||||
|
||||
<!-- リンクと画像 -->
|
||||
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
|
||||
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
|
||||
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
|
||||
[twitter-link]: https://twitter.com/CherryStudioApp
|
||||
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
|
||||
[discord-link]: https://discord.gg/wez8HtpxqQ
|
||||
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
|
||||
[telegram-link]: https://t.me/CherryStudioAI
|
||||
|
||||
<!-- プロジェクト統計 -->
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
|
||||
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
|
||||
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
|
||||
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
|
||||
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
|
||||
|
||||
<!-- ライセンスとスポンサー -->
|
||||
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
|
||||
[license-link]: https://www.gnu.org/licenses/agpl-3.0
|
||||
[commercial-shield]: https://img.shields.io/badge/商用ライセンス-お問い合わせ-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
|
||||
[commercial-link]: mailto:license@cherry-ai.com?subject=商業ライセンスについて
|
||||
[sponsor-shield]: https://img.shields.io/badge/スポンサー-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
|
||||
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio/releases">
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
|
||||
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
|
||||
</a>
|
||||
</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br>
|
||||
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a> | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/zh-cn">文档</a> | <a href="./dev.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
|
||||
</p>
|
||||
|
||||
<!-- 题头徽章组合 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][deepwiki-shield]][deepwiki-link]
|
||||
[![][twitter-shield]][twitter-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
[![][telegram-shield]][telegram-link]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 项目统计徽章 -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-contributors-shield]][github-contributors-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[![][license-shield]][license-link]
|
||||
[![][commercial-shield]][commercial-link]
|
||||
[![][sponsor-shield]][sponsor-link]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" style="width: 200px; height: 43px;" width="200" height="43" /></a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
@@ -33,9 +65,11 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 界面
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 主要特性
|
||||
|
||||
@@ -75,20 +109,42 @@ https://docs.cherry-ai.com
|
||||
- 📝 完整的 Markdown 渲染
|
||||
- 🤲 便捷的内容分享功能
|
||||
|
||||
# 📝 待办事项
|
||||
# 📝 开发计划
|
||||
|
||||
- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结)
|
||||
- [x] 多模型回答对比
|
||||
- [x] 支持使用服务供应商提供的 SSO 进行登录
|
||||
- [x] 所有模型支持联网
|
||||
- [x] 推出第一个正式版
|
||||
- [x] 错误修复和改进(开发中...)
|
||||
- [ ] 插件功能(JavaScript)
|
||||
- [ ] 浏览器插件(划词翻译、总结、新增至知识库)
|
||||
- [ ] iOS & Android 客户端
|
||||
- [ ] AI 笔记
|
||||
- [ ] 语音输入输出(AI 通话)
|
||||
- [ ] 数据备份支持自定义备份内容
|
||||
我们正在积极开发以下功能和改进:
|
||||
|
||||
1. 🎯 **核心功能**
|
||||
|
||||
- 选择助手 - 智能内容选择增强
|
||||
- 深度研究 - 高级研究能力
|
||||
- 全局记忆 - 全局上下文感知
|
||||
- 文档预处理 - 改进文档处理能力
|
||||
- MCP 市场 - 模型上下文协议生态系统
|
||||
|
||||
2. 🗂 **知识管理**
|
||||
|
||||
- 笔记与收藏功能
|
||||
- 动态画布可视化
|
||||
- OCR 光学字符识别
|
||||
- TTS 文本转语音支持
|
||||
|
||||
3. 📱 **平台支持**
|
||||
|
||||
- 鸿蒙版本 (PC)
|
||||
- Android 应用(第一期)
|
||||
- iOS 应用(第一期)
|
||||
- 多窗口支持
|
||||
- 窗口置顶功能
|
||||
|
||||
4. 🔌 **高级特性**
|
||||
|
||||
- 插件系统
|
||||
- ASR 语音识别
|
||||
- 助手与话题交互重构
|
||||
|
||||
在我们的[项目面板](https://github.com/orgs/CherryHQ/projects/7)上跟踪进展并参与贡献。
|
||||
|
||||
想要影响开发计划?欢迎加入我们的 [GitHub 讨论区](https://github.com/CherryHQ/cherry-studio/discussions) 分享您的想法和反馈!
|
||||
|
||||
# 🌈 主题
|
||||
|
||||
@@ -100,14 +156,6 @@ https://docs.cherry-ai.com
|
||||
|
||||
欢迎 PR 更多主题
|
||||
|
||||
# 🖥️ 开发
|
||||
|
||||
参考[开发文档](dev.md)
|
||||
|
||||
参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio)
|
||||
|
||||
参考[分支策略](branching-strategy-zh.md)了解贡献指南
|
||||
|
||||
# 🤝 贡献
|
||||
|
||||
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
|
||||
@@ -120,6 +168,8 @@ https://docs.cherry-ai.com
|
||||
6. **社区参与**:加入讨论并帮助用户
|
||||
7. **推广使用**:宣传 Cherry Studio
|
||||
|
||||
参考[分支策略](branching-strategy-zh.md)了解贡献指南
|
||||
|
||||
## 入门
|
||||
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器
|
||||
@@ -131,7 +181,7 @@ https://docs.cherry-ai.com
|
||||
|
||||
感谢您的支持和贡献!
|
||||
|
||||
## 相关项目
|
||||
# 🔗 相关项目
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api):LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。
|
||||
|
||||
@@ -144,22 +194,34 @@ https://docs.cherry-ai.com
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 社区
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# ☕ 赞助
|
||||
|
||||
[赞助开发者](sponsor.md)
|
||||
|
||||
# 📃 许可证
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ✉️ 联系我们
|
||||
|
||||
yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
|
||||
|
||||
<!-- Links & Images -->
|
||||
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
|
||||
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
|
||||
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
|
||||
[twitter-link]: https://twitter.com/CherryStudioApp
|
||||
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
|
||||
[discord-link]: https://discord.gg/wez8HtpxqQ
|
||||
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
|
||||
[telegram-link]: https://t.me/CherryStudioAI
|
||||
|
||||
<!-- 项目统计徽章 -->
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
|
||||
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
|
||||
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
|
||||
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
|
||||
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
|
||||
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
|
||||
|
||||
<!-- 许可和赞助徽章 -->
|
||||
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
|
||||
[license-link]: https://www.gnu.org/licenses/agpl-3.0
|
||||
[commercial-shield]: https://img.shields.io/badge/商用授权-联系-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
|
||||
[commercial-link]: mailto:license@cherry-ai.com?subject=商业授权咨询
|
||||
[sponsor-shield]: https://img.shields.io/badge/赞助支持-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
|
||||
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md
|
||||
|
||||
@@ -37,6 +37,14 @@ yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Debug
|
||||
|
||||
```bash
|
||||
yarn debug
|
||||
```
|
||||
|
||||
Then input chrome://inspect in browser
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,30 +12,43 @@ electronLanguages:
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!{.vscode,.yarn,.github}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '**/*'
|
||||
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}}'
|
||||
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!**/{.editorconfig,.jekyll-metadata}'
|
||||
- '!src'
|
||||
- '!scripts'
|
||||
- '!local'
|
||||
- '!docs'
|
||||
- '!packages'
|
||||
- '!.swc'
|
||||
- '!.bin'
|
||||
- '!._*'
|
||||
- '!*.log'
|
||||
- '!stats.html'
|
||||
- '!*.md'
|
||||
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
|
||||
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
|
||||
- '!**/{test,tests,__tests__,coverage}/**'
|
||||
- '!**/{test,tests,__tests__,powered-test,coverage}/**'
|
||||
- '!**/{example,examples}/**'
|
||||
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||
- '!**/*.min.*.map'
|
||||
- '!**/*.d.ts'
|
||||
- '!**/{.DS_Store,Thumbs.db}'
|
||||
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}'
|
||||
- '!**/dist/es6/**'
|
||||
- '!**/dist/demo/**'
|
||||
- '!**/amd/**'
|
||||
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
|
||||
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
|
||||
- '!node_modules/rollup-plugin-visualizer'
|
||||
- '!node_modules/js-tiktoken'
|
||||
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
|
||||
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
@@ -45,6 +58,9 @@ win:
|
||||
target:
|
||||
- target: nsis
|
||||
- target: portable
|
||||
signtoolOptions:
|
||||
sign: scripts/win-sign.js
|
||||
verifyUpdateCodeSignature: false
|
||||
nsis:
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
@@ -91,10 +107,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
⚠️ 注意:升级前请备份数据,否则将无法降级
|
||||
优化软件启动速度
|
||||
优化软件进入后台后性能问题
|
||||
修复导出对话时自动重命名失败问题
|
||||
防止输入法切换期间误发消息问题
|
||||
修复群组消息重发功能问题及富文本粘贴兼容性问题
|
||||
改进 MCP 服务处理及 IPC 注册逻辑
|
||||
新增划词助手
|
||||
助手支持分组
|
||||
支持主题颜色切换
|
||||
划词助手支持应用过滤
|
||||
翻译模块功能改进
|
||||
|
||||
@@ -9,25 +9,7 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@cherrystudio/embedjs',
|
||||
'@cherrystudio/embedjs-openai',
|
||||
'@cherrystudio/embedjs-loader-web',
|
||||
'@cherrystudio/embedjs-loader-markdown',
|
||||
'@cherrystudio/embedjs-loader-msoffice',
|
||||
'@cherrystudio/embedjs-loader-xml',
|
||||
'@cherrystudio/embedjs-loader-pdf',
|
||||
'@cherrystudio/embedjs-loader-sitemap',
|
||||
'@cherrystudio/embedjs-libsql',
|
||||
'@cherrystudio/embedjs-loader-image',
|
||||
'p-queue',
|
||||
'webdav'
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('main')
|
||||
],
|
||||
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
@@ -37,8 +19,12 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client']
|
||||
}
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate']
|
||||
},
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
},
|
||||
optimizeDeps: {
|
||||
noDiscovery: process.env.NODE_ENV === 'development'
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
@@ -47,10 +33,14 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@shared': resolve('packages/shared')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
plugins: [
|
||||
(async () => (await import('@tailwindcss/vite')).default())(),
|
||||
react({
|
||||
plugins: [
|
||||
[
|
||||
@@ -82,24 +72,9 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
|
||||
},
|
||||
output: {
|
||||
manualChunks: (id: string) => {
|
||||
// 检测所有 worker 文件,提取 worker 名称作为 chunk 名
|
||||
if (id.includes('.worker') && id.endsWith('?worker')) {
|
||||
const workerName = id.split('/').pop()?.split('.')[0] || 'worker'
|
||||
return `workers/${workerName}`
|
||||
}
|
||||
|
||||
// All node_modules are in the vendor chunk
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor'
|
||||
}
|
||||
|
||||
// Other modules use default chunk splitting strategy
|
||||
return undefined
|
||||
}
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ export default defineConfig([
|
||||
'.yarn/**',
|
||||
'.gitignore',
|
||||
'scripts/cloudflare-worker.js',
|
||||
'src/main/integration/nutstore/sso/lib/**'
|
||||
'src/main/integration/nutstore/sso/lib/**',
|
||||
'src/renderer/src/ui/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
94
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.3.7",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -20,8 +20,9 @@
|
||||
"scripts": {
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
|
||||
"build:check": "yarn typecheck && yarn check:i18n && yarn test",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
@@ -37,23 +38,23 @@
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||
"generate:agents": "yarn workspace @cherry-studio/database agents",
|
||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"check:i18n": "node scripts/check-i18n.js",
|
||||
"test": "yarn test:renderer",
|
||||
"test:coverage": "yarn test:renderer:coverage",
|
||||
"test:node": "npx -y tsx --test src/**/*.test.ts",
|
||||
"test:renderer": "vitest run",
|
||||
"test:renderer:ui": "vitest --ui",
|
||||
"test:renderer:coverage": "vitest run --coverage",
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
"test:renderer": "vitest run --project renderer",
|
||||
"test:update": "yarn test:renderer --update",
|
||||
"test:coverage": "vitest run --coverage --silent",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "yarn playwright test",
|
||||
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -69,14 +70,12 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tanstack/react-query": "^5.27.0",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"electron-log": "^5.1.5",
|
||||
@@ -84,22 +83,19 @@
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"got-scraping": "^4.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"selection-hook": "^0.9.22",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"webdav": "^5.8.0",
|
||||
"ws": "^8.18.1",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -112,18 +108,32 @@
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@eslint-react/eslint-plugin": "^1.36.1",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "^0.13.0",
|
||||
"@google/genai": "^1.0.1",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.3",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.2.2",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
@@ -137,25 +147,28 @@
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/ws": "^8",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.12",
|
||||
"@uiw/codemirror-themes-all": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"@vitest/web-worker": "^3.1.3",
|
||||
"@vitest/browser": "^3.1.4",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"@vitest/ui": "^3.1.4",
|
||||
"@vitest/web-worker": "^3.1.4",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "^5.22.5",
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"color": "^5.0.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "35.2.2",
|
||||
"electron": "35.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^3.1.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
@@ -163,20 +176,25 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fast-diff": "^1.3.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.487.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"motion": "^12.12.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"playwright": "^1.52.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -199,30 +217,34 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.2.2",
|
||||
"shiki": "^3.4.2",
|
||||
"sonner": "^2.0.3",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^0.4.1",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.6.2",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.1"
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"node-gyp": "^9.1.0",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"shiki": "3.2.2",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch"
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
|
||||
"prettier --write",
|
||||
|
||||
@@ -11,9 +11,9 @@ export enum IpcChannel {
|
||||
App_SetLaunchToTray = 'app:set-launch-to-tray',
|
||||
App_SetTray = 'app:set-tray',
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_RestartTray = 'app:restart-tray',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_SetFeedUrl = 'app:set-feed-url',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
@@ -21,6 +21,11 @@ export enum IpcChannel {
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
|
||||
App_QuoteToMain = 'app:quote-to-main',
|
||||
|
||||
Notification_Send = 'notification:send',
|
||||
Notification_OnClick = 'notification:on-click',
|
||||
|
||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||
|
||||
// Open
|
||||
@@ -108,12 +113,13 @@ export enum IpcChannel {
|
||||
File_WriteWithId = 'file:writeWithId',
|
||||
File_SaveImage = 'file:saveImage',
|
||||
File_Base64Image = 'file:base64Image',
|
||||
File_SaveBase64Image = 'file:saveBase64Image',
|
||||
File_Download = 'file:download',
|
||||
File_Copy = 'file:copy',
|
||||
File_BinaryImage = 'file:binaryImage',
|
||||
File_Base64File = 'file:base64File',
|
||||
Fs_Read = 'fs:read',
|
||||
File_ResolveFilePath = 'file:resolveFilePath',
|
||||
|
||||
Export_Word = 'export:word',
|
||||
|
||||
Shortcuts_Update = 'shortcuts:update',
|
||||
@@ -141,7 +147,7 @@ export enum IpcChannel {
|
||||
|
||||
// events
|
||||
BackupProgress = 'backup-progress',
|
||||
ThemeChange = 'theme:change',
|
||||
ThemeUpdated = 'theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
@@ -170,5 +176,26 @@ export enum IpcChannel {
|
||||
StoreSync_Subscribe = 'store-sync:subscribe',
|
||||
StoreSync_Unsubscribe = 'store-sync:unsubscribe',
|
||||
StoreSync_OnUpdate = 'store-sync:on-update',
|
||||
StoreSync_BroadcastSync = 'store-sync:broadcast-sync'
|
||||
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
|
||||
|
||||
// Provider
|
||||
Provider_AddKey = 'provider:add-key',
|
||||
|
||||
//Selection Assistant
|
||||
Selection_TextSelected = 'selection:text-selected',
|
||||
Selection_ToolbarHide = 'selection:toolbar-hide',
|
||||
Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change',
|
||||
Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size',
|
||||
Selection_WriteToClipboard = 'selection:write-to-clipboard',
|
||||
Selection_SetEnabled = 'selection:set-enabled',
|
||||
Selection_SetTriggerMode = 'selection:set-trigger-mode',
|
||||
Selection_SetFilterMode = 'selection:set-filter-mode',
|
||||
Selection_SetFilterList = 'selection:set-filter-list',
|
||||
Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
|
||||
Selection_SetRemeberWinSize = 'selection:set-remeber-win-size',
|
||||
Selection_ActionWindowClose = 'selection:action-window-close',
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
Selection_ProcessAction = 'selection:process-action',
|
||||
Selection_UpdateActionData = 'selection:update-action-data'
|
||||
}
|
||||
|
||||
@@ -4,135 +4,368 @@ export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const thirdPartyApplicationExts = ['.draftsExport']
|
||||
export const bookExts = ['.epub']
|
||||
export const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
'.md', // Markdown 文件
|
||||
'.mdx', // Markdown 文件
|
||||
'.html', // HTML 文件
|
||||
'.htm', // HTML 文件的另一种扩展名
|
||||
'.xml', // XML 文件
|
||||
'.json', // JSON 文件
|
||||
'.yaml', // YAML 文件
|
||||
'.yml', // YAML 文件的另一种扩展名
|
||||
'.csv', // 逗号分隔值文件
|
||||
'.tsv', // 制表符分隔值文件
|
||||
'.ini', // 配置文件
|
||||
'.log', // 日志文件
|
||||
'.rtf', // 富文本格式文件
|
||||
'.org', // org-mode 文件
|
||||
'.wiki', // VimWiki 文件
|
||||
'.tex', // LaTeX 文件
|
||||
'.bib', // BibTeX 文件
|
||||
'.srt', // 字幕文件
|
||||
'.xhtml', // XHTML 文件
|
||||
'.nfo', // 信息文件(主要用于场景发布)
|
||||
'.conf', // 配置文件
|
||||
'.config', // 配置文件
|
||||
'.env', // 环境变量文件
|
||||
'.rst', // reStructuredText 文件
|
||||
'.php', // PHP 脚本文件,包含嵌入的 HTML
|
||||
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
|
||||
'.ts', // TypeScript 文件
|
||||
'.jsp', // JavaServer Pages 文件
|
||||
'.aspx', // ASP.NET 文件
|
||||
'.bat', // Windows 批处理文件
|
||||
'.sh', // Unix/Linux Shell 脚本文件
|
||||
'.py', // Python 脚本文件
|
||||
'.ipynb', // Jupyter 笔记本格式
|
||||
'.rb', // Ruby 脚本文件
|
||||
'.pl', // Perl 脚本文件
|
||||
'.sql', // SQL 脚本文件
|
||||
'.css', // Cascading Style Sheets 文件
|
||||
'.less', // Less CSS 预处理器文件
|
||||
'.scss', // Sass CSS 预处理器文件
|
||||
'.sass', // Sass 文件
|
||||
'.styl', // Stylus CSS 预处理器文件
|
||||
'.coffee', // CoffeeScript 文件
|
||||
'.ino', // Arduino 代码文件
|
||||
'.asm', // Assembly 语言文件
|
||||
'.go', // Go 语言文件
|
||||
'.scala', // Scala 语言文件
|
||||
'.swift', // Swift 语言文件
|
||||
'.kt', // Kotlin 语言文件
|
||||
'.rs', // Rust 语言文件
|
||||
'.lua', // Lua 语言文件
|
||||
'.groovy', // Groovy 语言文件
|
||||
'.dart', // Dart 语言文件
|
||||
'.hs', // Haskell 语言文件
|
||||
'.clj', // Clojure 语言文件
|
||||
'.cljs', // ClojureScript 语言文件
|
||||
'.elm', // Elm 语言文件
|
||||
'.erl', // Erlang 语言文件
|
||||
'.ex', // Elixir 语言文件
|
||||
'.exs', // Elixir 脚本文件
|
||||
'.pug', // Pug (formerly Jade) 模板文件
|
||||
'.haml', // Haml 模板文件
|
||||
'.slim', // Slim 模板文件
|
||||
'.tpl', // 模板文件(通用)
|
||||
'.ejs', // Embedded JavaScript 模板文件
|
||||
'.hbs', // Handlebars 模板文件
|
||||
'.mustache', // Mustache 模板文件
|
||||
'.jade', // Jade 模板文件 (已重命名为 Pug)
|
||||
'.twig', // Twig 模板文件
|
||||
'.blade', // Blade 模板文件 (Laravel)
|
||||
'.vue', // Vue.js 单文件组件
|
||||
'.jsx', // React JSX 文件
|
||||
'.tsx', // React TSX 文件
|
||||
'.graphql', // GraphQL 查询语言文件
|
||||
'.gql', // GraphQL 查询语言文件
|
||||
'.proto', // Protocol Buffers 文件
|
||||
'.thrift', // Thrift 文件
|
||||
'.toml', // TOML 配置文件
|
||||
'.edn', // Clojure 数据表示文件
|
||||
'.cake', // CakePHP 配置文件
|
||||
'.ctp', // CakePHP 视图文件
|
||||
'.cfm', // ColdFusion 标记语言文件
|
||||
'.cfc', // ColdFusion 组件文件
|
||||
'.m', // Objective-C 或 MATLAB 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
'.kts', // Kotlin Script 文件
|
||||
'.java', // Java 代码文件
|
||||
'.cs', // C# 代码文件
|
||||
'.cpp', // C++ 代码文件
|
||||
'.c', // C++ 代码文件
|
||||
'.h', // C++ 头文件
|
||||
'.hpp', // C++ 头文件
|
||||
'.cc', // C++ 源文件
|
||||
'.cxx', // C++ 源文件
|
||||
'.cppm', // C++20 模块接口文件
|
||||
'.ipp', // 模板实现文件
|
||||
'.ixx', // C++20 模块实现文件
|
||||
'.f90', // Fortran 90 源文件
|
||||
'.f', // Fortran 固定格式源代码文件
|
||||
'.f03', // Fortran 2003+ 源代码文件
|
||||
'.ahk', // AutoHotKey 语言文件
|
||||
'.tcl', // Tcl 脚本
|
||||
'.do', // Questa 或 Modelsim Tcl 脚本
|
||||
'.v', // Verilog 源文件
|
||||
'.sv', // SystemVerilog 源文件
|
||||
'.svh', // SystemVerilog 头文件
|
||||
'.vhd', // VHDL 源文件
|
||||
'.vhdl', // VHDL 源文件
|
||||
'.lef', // Library Exchange Format
|
||||
'.def', // Design Exchange Format
|
||||
'.edif', // Electronic Design Interchange Format
|
||||
'.sdf', // Standard Delay Format
|
||||
'.sdc', // Synopsys Design Constraints
|
||||
'.xdc', // Xilinx Design Constraints
|
||||
'.rpt', // 报告文件
|
||||
'.lisp', // Lisp 脚本
|
||||
'.il', // Cadence SKILL 脚本
|
||||
'.ils', // Cadence SKILL++ 脚本
|
||||
'.sp', // SPICE netlist 文件
|
||||
'.spi', // SPICE netlist 文件
|
||||
'.cir', // SPICE netlist 文件
|
||||
'.net', // SPICE netlist 文件
|
||||
'.scs', // Spectre netlist 文件
|
||||
'.asc', // LTspice netlist schematic 文件
|
||||
'.tf' // Technology File
|
||||
]
|
||||
const textExtsByCategory = new Map([
|
||||
[
|
||||
'language',
|
||||
[
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx', // JavaScript/TypeScript
|
||||
'.py', // Python
|
||||
'.java', // Java
|
||||
'.cs', // C#
|
||||
'.cpp',
|
||||
'.c',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.cc',
|
||||
'.cxx',
|
||||
'.cppm',
|
||||
'.ipp',
|
||||
'.ixx', // C/C++
|
||||
'.php', // PHP
|
||||
'.rb', // Ruby
|
||||
'.pl', // Perl
|
||||
'.go', // Go
|
||||
'.rs', // Rust
|
||||
'.swift', // Swift
|
||||
'.kt',
|
||||
'.kts', // Kotlin
|
||||
'.scala', // Scala
|
||||
'.lua', // Lua
|
||||
'.groovy', // Groovy
|
||||
'.dart', // Dart
|
||||
'.hs', // Haskell
|
||||
'.clj',
|
||||
'.cljs', // Clojure
|
||||
'.elm', // Elm
|
||||
'.erl', // Erlang
|
||||
'.ex',
|
||||
'.exs', // Elixir
|
||||
'.ml',
|
||||
'.mli', // OCaml
|
||||
'.fs', // F#
|
||||
'.r',
|
||||
'.R', // R
|
||||
'.sol', // Solidity
|
||||
'.awk', // AWK
|
||||
'.cob', // COBOL
|
||||
'.asm',
|
||||
'.s', // Assembly
|
||||
'.lisp',
|
||||
'.lsp', // Lisp
|
||||
'.coffee', // CoffeeScript
|
||||
'.ino', // Arduino
|
||||
'.jl', // Julia
|
||||
'.nim', // Nim
|
||||
'.zig', // Zig
|
||||
'.d', // D语言
|
||||
'.pas', // Pascal
|
||||
'.vb', // Visual Basic
|
||||
'.rkt', // Racket
|
||||
'.scm', // Scheme
|
||||
'.hx', // Haxe
|
||||
'.as', // ActionScript
|
||||
'.pde', // Processing
|
||||
'.f90',
|
||||
'.f',
|
||||
'.f03',
|
||||
'.for',
|
||||
'.f95', // Fortran
|
||||
'.adb',
|
||||
'.ads', // Ada
|
||||
'.pro', // Prolog
|
||||
'.m',
|
||||
'.mm', // Objective-C/MATLAB
|
||||
'.rpy', // Ren'Py
|
||||
'.ets', // OpenHarmony,
|
||||
'.uniswap', // DeFi
|
||||
'.vy', // Vyper
|
||||
'.shader',
|
||||
'.glsl',
|
||||
'.frag',
|
||||
'.vert',
|
||||
'.gd' // Godot
|
||||
]
|
||||
],
|
||||
[
|
||||
'script',
|
||||
[
|
||||
'.sh', // Shell
|
||||
'.bat',
|
||||
'.cmd', // Windows批处理
|
||||
'.ps1', // PowerShell
|
||||
'.tcl',
|
||||
'.do', // Tcl
|
||||
'.ahk', // AutoHotkey
|
||||
'.zsh', // Zsh
|
||||
'.fish', // Fish shell
|
||||
'.csh', // C shell
|
||||
'.vbs', // VBScript
|
||||
'.applescript', // AppleScript
|
||||
'.au3', // AutoIt
|
||||
'.bash',
|
||||
'.nu'
|
||||
]
|
||||
],
|
||||
[
|
||||
'style',
|
||||
[
|
||||
'.css', // CSS
|
||||
'.less', // Less
|
||||
'.scss',
|
||||
'.sass', // Sass
|
||||
'.styl', // Stylus
|
||||
'.pcss', // PostCSS
|
||||
'.postcss' // PostCSS
|
||||
]
|
||||
],
|
||||
[
|
||||
'template',
|
||||
[
|
||||
'.vue', // Vue.js
|
||||
'.pug',
|
||||
'.jade', // Pug/Jade
|
||||
'.haml', // Haml
|
||||
'.slim', // Slim
|
||||
'.tpl', // 通用模板
|
||||
'.ejs', // EJS
|
||||
'.hbs', // Handlebars
|
||||
'.mustache', // Mustache
|
||||
'.twig', // Twig
|
||||
'.blade', // Blade (Laravel)
|
||||
'.liquid', // Liquid
|
||||
'.jinja',
|
||||
'.jinja2',
|
||||
'.j2', // Jinja
|
||||
'.erb', // ERB
|
||||
'.vm', // Velocity
|
||||
'.ftl', // FreeMarker
|
||||
'.svelte', // Svelte
|
||||
'.astro' // Astro
|
||||
]
|
||||
],
|
||||
[
|
||||
'config',
|
||||
[
|
||||
'.ini', // INI配置
|
||||
'.conf',
|
||||
'.config', // 通用配置
|
||||
'.env', // 环境变量
|
||||
'.toml', // TOML
|
||||
'.cfg', // 通用配置
|
||||
'.properties', // Java属性
|
||||
'.desktop', // Linux桌面文件
|
||||
'.service', // systemd服务
|
||||
'.rc',
|
||||
'.bashrc',
|
||||
'.zshrc', // Shell配置
|
||||
'.fishrc', // Fish shell配置
|
||||
'.vimrc', // Vim配置
|
||||
'.htaccess', // Apache配置
|
||||
'.robots', // robots.txt
|
||||
'.editorconfig', // EditorConfig
|
||||
'.eslintrc', // ESLint
|
||||
'.prettierrc', // Prettier
|
||||
'.babelrc', // Babel
|
||||
'.npmrc', // npm
|
||||
'.dockerignore', // Docker ignore
|
||||
'.npmignore',
|
||||
'.yarnrc',
|
||||
'.prettierignore',
|
||||
'.eslintignore',
|
||||
'.browserslistrc',
|
||||
'.json5',
|
||||
'.tfvars'
|
||||
]
|
||||
],
|
||||
[
|
||||
'document',
|
||||
[
|
||||
'.txt',
|
||||
'.text', // 纯文本
|
||||
'.md',
|
||||
'.mdx', // Markdown
|
||||
'.html',
|
||||
'.htm',
|
||||
'.xhtml', // HTML
|
||||
'.xml', // XML
|
||||
'.org', // Org-mode
|
||||
'.wiki', // Wiki
|
||||
'.tex',
|
||||
'.bib', // LaTeX
|
||||
'.rst', // reStructuredText
|
||||
'.rtf', // 富文本
|
||||
'.nfo', // 信息文件
|
||||
'.adoc',
|
||||
'.asciidoc', // AsciiDoc
|
||||
'.pod', // Perl文档
|
||||
'.1',
|
||||
'.2',
|
||||
'.3',
|
||||
'.4',
|
||||
'.5',
|
||||
'.6',
|
||||
'.7',
|
||||
'.8',
|
||||
'.9', // man页面
|
||||
'.man', // man页面
|
||||
'.texi',
|
||||
'.texinfo', // Texinfo
|
||||
'.readme',
|
||||
'.me', // README
|
||||
'.changelog', // 变更日志
|
||||
'.license', // 许可证
|
||||
'.authors', // 作者文件
|
||||
'.po',
|
||||
'.pot'
|
||||
]
|
||||
],
|
||||
[
|
||||
'data',
|
||||
[
|
||||
'.json', // JSON
|
||||
'.jsonc', // JSON with comments
|
||||
'.yaml',
|
||||
'.yml', // YAML
|
||||
'.csv',
|
||||
'.tsv', // 分隔值文件
|
||||
'.edn', // Clojure数据
|
||||
'.jsonl',
|
||||
'.ndjson', // 换行分隔JSON
|
||||
'.geojson', // GeoJSON
|
||||
'.gpx', // GPS Exchange
|
||||
'.kml', // Keyhole Markup
|
||||
'.rss',
|
||||
'.atom', // Feed格式
|
||||
'.vcf', // vCard
|
||||
'.ics', // iCalendar
|
||||
'.ldif', // LDAP数据交换
|
||||
'.pbtxt',
|
||||
'.map'
|
||||
]
|
||||
],
|
||||
[
|
||||
'build',
|
||||
[
|
||||
'.gradle', // Gradle
|
||||
'.make',
|
||||
'.mk', // Make
|
||||
'.cmake', // CMake
|
||||
'.sbt', // SBT
|
||||
'.rake', // Rake
|
||||
'.spec', // RPM spec
|
||||
'.pom',
|
||||
'.build', // Meson
|
||||
'.bazel' // Bazel
|
||||
]
|
||||
],
|
||||
[
|
||||
'database',
|
||||
[
|
||||
'.sql', // SQL
|
||||
'.ddl',
|
||||
'.dml', // DDL/DML
|
||||
'.plsql', // PL/SQL
|
||||
'.psql', // PostgreSQL
|
||||
'.cypher', // Cypher
|
||||
'.sparql' // SPARQL
|
||||
]
|
||||
],
|
||||
[
|
||||
'web',
|
||||
[
|
||||
'.graphql',
|
||||
'.gql', // GraphQL
|
||||
'.proto', // Protocol Buffers
|
||||
'.thrift', // Thrift
|
||||
'.wsdl', // WSDL
|
||||
'.raml', // RAML
|
||||
'.swagger',
|
||||
'.openapi' // API文档
|
||||
]
|
||||
],
|
||||
[
|
||||
'version',
|
||||
[
|
||||
'.gitignore', // Git ignore
|
||||
'.gitattributes', // Git attributes
|
||||
'.gitconfig', // Git config
|
||||
'.hgignore', // Mercurial ignore
|
||||
'.bzrignore', // Bazaar ignore
|
||||
'.svnignore', // SVN ignore
|
||||
'.githistory' // Git history
|
||||
]
|
||||
],
|
||||
[
|
||||
'subtitle',
|
||||
[
|
||||
'.srt',
|
||||
'.sub',
|
||||
'.ass' // 字幕格式
|
||||
]
|
||||
],
|
||||
[
|
||||
'log',
|
||||
[
|
||||
'.log',
|
||||
'.rpt' // 日志和报告 (移除了.out,因为通常是二进制可执行文件)
|
||||
]
|
||||
],
|
||||
[
|
||||
'eda',
|
||||
[
|
||||
'.v',
|
||||
'.sv',
|
||||
'.svh', // Verilog/SystemVerilog
|
||||
'.vhd',
|
||||
'.vhdl', // VHDL
|
||||
'.lef',
|
||||
'.def', // LEF/DEF
|
||||
'.edif', // EDIF
|
||||
'.sdf', // SDF
|
||||
'.sdc',
|
||||
'.xdc', // 约束文件
|
||||
'.sp',
|
||||
'.spi',
|
||||
'.cir',
|
||||
'.net', // SPICE
|
||||
'.scs', // Spectre
|
||||
'.asc', // LTspice
|
||||
'.tf', // Technology File
|
||||
'.il',
|
||||
'.ils' // SKILL
|
||||
]
|
||||
],
|
||||
[
|
||||
'game',
|
||||
[
|
||||
'.mtl', // Material Template Library
|
||||
'.x3d', // X3D文件
|
||||
'.gltf', // glTF JSON
|
||||
'.prefab', // Unity预制体 (YAML格式)
|
||||
'.meta' // Unity元数据文件 (YAML格式)
|
||||
]
|
||||
],
|
||||
[
|
||||
'other',
|
||||
[
|
||||
'.mcfunction', // Minecraft函数
|
||||
'.jsp', // JSP
|
||||
'.aspx', // ASP.NET
|
||||
'.ipynb', // Jupyter Notebook
|
||||
'.cake',
|
||||
'.ctp', // CakePHP
|
||||
'.cfm',
|
||||
'.cfc' // ColdFusion
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
export const textExts = Array.from(textExtsByCategory.values()).flat()
|
||||
|
||||
export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]
|
||||
|
||||
@@ -170,3 +403,8 @@ export const KB = 1024
|
||||
export const MB = 1024 * KB
|
||||
export const GB = 1024 * MB
|
||||
export const defaultLanguage = 'en-US'
|
||||
|
||||
export enum FeedUrl {
|
||||
PRODUCTION = 'https://releases.cherry-ai.com',
|
||||
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||
}
|
||||
|
||||
42
playwright.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
// Look for test files, relative to this configuration file.
|
||||
testDir: './tests/e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
]
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
})
|
||||
@@ -15,6 +15,8 @@ const BUN_PACKAGES = {
|
||||
'darwin-x64': 'bun-darwin-x64.zip',
|
||||
'win32-x64': 'bun-windows-x64.zip',
|
||||
'win32-x64-baseline': 'bun-windows-x64-baseline.zip',
|
||||
'win32-arm64': 'bun-windows-x64.zip',
|
||||
'win32-arm64-baseline': 'bun-windows-x64-baseline.zip',
|
||||
'linux-x64': 'bun-linux-x64.zip',
|
||||
'linux-x64-baseline': 'bun-linux-x64-baseline.zip',
|
||||
'linux-arm64': 'bun-linux-aarch64.zip',
|
||||
|
||||
19
scripts/win-sign.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
exports.default = async function (configuration) {
|
||||
if (process.env.WIN_SIGN) {
|
||||
const { path } = configuration
|
||||
if (configuration.path) {
|
||||
try {
|
||||
console.log('Start code signing...')
|
||||
console.log('Signing file:', path)
|
||||
const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"`
|
||||
execSync(signCommand, { stdio: 'inherit' })
|
||||
console.log('Code signing completed')
|
||||
} catch (error) {
|
||||
console.error('Code signing failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,13 @@ if (isDev) {
|
||||
export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
}
|
||||
|
||||
export const titleBarOverlayLight = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000'
|
||||
}
|
||||
|
||||
58
src/main/configs/SelectionConfig.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
interface IFilterList {
|
||||
WINDOWS: string[]
|
||||
MAC?: string[]
|
||||
}
|
||||
|
||||
interface IFinetunedList {
|
||||
EXCLUDE_CLIPBOARD_CURSOR_DETECT: IFilterList
|
||||
INCLUDE_CLIPBOARD_DELAY_READ: IFilterList
|
||||
}
|
||||
|
||||
/*************************************************************************
|
||||
* 注意:请不要修改此配置,除非你非常清楚其含义、影响和行为的目的
|
||||
* Note: Do not modify this configuration unless you fully understand its meaning, implications, and intended behavior.
|
||||
* -----------------------------------------------------------------------
|
||||
* A predefined application filter list to include commonly used software
|
||||
* that does not require text selection but may conflict with it, and disable them in advance.
|
||||
* Only available in the selected mode.
|
||||
*
|
||||
* Specification: must be all lowercase, need to accurately find the actual running program name
|
||||
*************************************************************************/
|
||||
export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
|
||||
WINDOWS: [
|
||||
'explorer.exe',
|
||||
// Screenshot
|
||||
'snipaste.exe',
|
||||
'pixpin.exe',
|
||||
'sharex.exe',
|
||||
// Office
|
||||
'excel.exe',
|
||||
'powerpnt.exe',
|
||||
// Image Editor
|
||||
'photoshop.exe',
|
||||
'illustrator.exe',
|
||||
// Video Editor
|
||||
'adobe premiere pro.exe',
|
||||
'afterfx.exe',
|
||||
// Audio Editor
|
||||
'adobe audition.exe',
|
||||
// 3D Editor
|
||||
'blender.exe',
|
||||
'3dsmax.exe',
|
||||
'maya.exe',
|
||||
// CAD
|
||||
'acad.exe',
|
||||
'sldworks.exe',
|
||||
// Remote Desktop
|
||||
'mstsc.exe'
|
||||
]
|
||||
}
|
||||
|
||||
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
|
||||
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
|
||||
},
|
||||
INCLUDE_CLIPBOARD_DELAY_READ: {
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,14 @@ export default class EmbeddingsFactory {
|
||||
azureOpenAIApiVersion: apiVersion,
|
||||
azureOpenAIApiDeploymentName: model,
|
||||
azureOpenAIApiInstanceName: getInstanceName(baseURL),
|
||||
// dimensions,
|
||||
dimensions,
|
||||
batchSize
|
||||
})
|
||||
}
|
||||
return new OpenAiEmbeddings({
|
||||
model,
|
||||
apiKey,
|
||||
// dimensions,
|
||||
dimensions,
|
||||
batchSize,
|
||||
configuration: { baseURL }
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { isDev } from './constant'
|
||||
import { isDev, isWin } from './constant'
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
registerProtocolClient,
|
||||
setupAppImageDeepLink
|
||||
} from './services/ProtocolClient'
|
||||
import selectionService, { initSelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
@@ -23,6 +24,16 @@ import { setUserDataDir } from './utils/file'
|
||||
|
||||
Logger.initialize()
|
||||
|
||||
/**
|
||||
* Disable chromium's window animations
|
||||
* main purpose for this is to avoid the transparent window flashing when it is shown
|
||||
* (especially on Windows for SelectionAssistant Toolbar)
|
||||
* Know Issue: https://github.com/electron/electron/issues/12130#issuecomment-627198990
|
||||
*/
|
||||
if (isWin) {
|
||||
app.commandLine.appendSwitch('wm-window-animations-disabled')
|
||||
}
|
||||
|
||||
// in production mode, handle uncaught exception and unhandled rejection globally
|
||||
if (!isDev) {
|
||||
// handle uncaught exception
|
||||
@@ -84,6 +95,9 @@ if (!app.requestSingleInstanceLock()) {
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
|
||||
//start selection assistant service
|
||||
initSelectionService()
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
@@ -110,12 +124,17 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.isQuitting = true
|
||||
|
||||
// quit selection service
|
||||
if (selectionService) {
|
||||
selectionService.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
// event.preventDefault()
|
||||
try {
|
||||
await mcpService().cleanup()
|
||||
await mcpService.cleanup()
|
||||
} catch (error) {
|
||||
Logger.error('Error cleaning up MCP service:', error)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
@@ -17,22 +17,24 @@ import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileService from './services/FileService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import { GeminiService } from './services/GeminiService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import { getMcpInstance } from './services/MCPService'
|
||||
import mcpService from './services/MCPService'
|
||||
import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
@@ -41,6 +43,7 @@ const obsidianVaultService = new ObsidianVaultService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
const notificationService = new NotificationService(mainWindow)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Info, () => ({
|
||||
version: app.getVersion(),
|
||||
@@ -110,10 +113,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
|
||||
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
|
||||
appUpdater.setFeedUrl(feedUrl)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
|
||||
configManager.set(key, value)
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
|
||||
@@ -122,34 +127,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// theme
|
||||
ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => {
|
||||
const updateTitleBarOverlay = () => {
|
||||
if (!mainWindow?.setTitleBarOverlay) return
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
mainWindow.setTitleBarOverlay(isDark ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
}
|
||||
|
||||
const broadcastThemeChange = () => {
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
const effectiveTheme = isDark ? ThemeMode.dark : ThemeMode.light
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.webContents.send(IpcChannel.ThemeChange, effectiveTheme))
|
||||
}
|
||||
|
||||
const notifyThemeChange = () => {
|
||||
updateTitleBarOverlay()
|
||||
broadcastThemeChange()
|
||||
}
|
||||
|
||||
if (theme === ThemeMode.auto) {
|
||||
nativeTheme.themeSource = 'system'
|
||||
nativeTheme.on('updated', notifyThemeChange)
|
||||
} else {
|
||||
nativeTheme.themeSource = theme
|
||||
nativeTheme.off('updated', notifyThemeChange)
|
||||
}
|
||||
|
||||
updateTitleBarOverlay()
|
||||
configManager.setTheme(theme)
|
||||
notifyThemeChange()
|
||||
themeService.setTheme(theme)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => {
|
||||
@@ -197,7 +175,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// check for update
|
||||
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
|
||||
await appUpdater.checkForUpdates()
|
||||
return await appUpdater.checkForUpdates()
|
||||
})
|
||||
|
||||
// notification
|
||||
ipcMain.handle(IpcChannel.Notification_Send, async (_, notification: Notification) => {
|
||||
await notificationService.sendNotification(notification)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Notification_OnClick, (_, notification: Notification) => {
|
||||
mainWindow.webContents.send('notification-click', notification)
|
||||
})
|
||||
|
||||
// zip
|
||||
@@ -238,11 +224,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
|
||||
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
||||
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
|
||||
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image)
|
||||
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
|
||||
ipcMain.handle(IpcChannel.File_ResolveFilePath, (_, name) => fileManager.resolveFilePath(name))
|
||||
|
||||
// fs
|
||||
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
|
||||
@@ -287,13 +273,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
// gemini
|
||||
ipcMain.handle(IpcChannel.Gemini_UploadFile, GeminiService.uploadFile)
|
||||
ipcMain.handle(IpcChannel.Gemini_Base64File, GeminiService.base64File)
|
||||
ipcMain.handle(IpcChannel.Gemini_RetrieveFile, GeminiService.retrieveFile)
|
||||
ipcMain.handle(IpcChannel.Gemini_ListFiles, GeminiService.listFiles)
|
||||
ipcMain.handle(IpcChannel.Gemini_DeleteFile, GeminiService.deleteFile)
|
||||
|
||||
// mini window
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
|
||||
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
|
||||
@@ -310,19 +289,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
)
|
||||
|
||||
// Register MCP handlers
|
||||
ipcMain.handle(IpcChannel.Mcp_RemoveServer, (event, server) => getMcpInstance().removeServer(event, server))
|
||||
ipcMain.handle(IpcChannel.Mcp_RestartServer, (event, server) => getMcpInstance().restartServer(event, server))
|
||||
ipcMain.handle(IpcChannel.Mcp_StopServer, (event, server) => getMcpInstance().stopServer(event, server))
|
||||
ipcMain.handle(IpcChannel.Mcp_ListTools, (event, server) => getMcpInstance().listTools(event, server))
|
||||
ipcMain.handle(IpcChannel.Mcp_CallTool, (event, params) => getMcpInstance().callTool(event, params))
|
||||
ipcMain.handle(IpcChannel.Mcp_ListPrompts, (event, server) => getMcpInstance().listPrompts(event, server))
|
||||
ipcMain.handle(IpcChannel.Mcp_GetPrompt, (event, params) => getMcpInstance().getPrompt(event, params))
|
||||
ipcMain.handle(IpcChannel.Mcp_ListResources, (event, server) => getMcpInstance().listResources(event, server))
|
||||
ipcMain.handle(IpcChannel.Mcp_GetResource, (event, params) => getMcpInstance().getResource(event, params))
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, () => getMcpInstance().getInstallInfo())
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, (event, params) =>
|
||||
getMcpInstance().checkMcpConnectivity(event, params)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.Mcp_RemoveServer, mcpService.removeServer)
|
||||
ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer)
|
||||
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
|
||||
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
|
||||
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
|
||||
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
|
||||
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||
@@ -371,4 +348,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// store sync
|
||||
storeSyncService.registerIpcHandler()
|
||||
|
||||
// selection assistant
|
||||
SelectionService.registerIpcHandler()
|
||||
|
||||
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
@@ -19,6 +21,7 @@ export default class AppUpdater {
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
autoUpdater.setFeedURL(configManager.getFeedUrl())
|
||||
|
||||
// 检测下载错误
|
||||
autoUpdater.on('error', (error) => {
|
||||
@@ -61,6 +64,11 @@ export default class AppUpdater {
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
}
|
||||
|
||||
public setFeedUrl(feedUrl: FeedUrl) {
|
||||
autoUpdater.setFeedURL(feedUrl)
|
||||
configManager.setFeedUrl(feedUrl)
|
||||
}
|
||||
|
||||
public async checkForUpdates() {
|
||||
if (isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env) {
|
||||
return {
|
||||
@@ -94,15 +102,22 @@ export default class AppUpdater {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
}
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { update: updateLocale } = locale.translation
|
||||
|
||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
||||
if (detail === '') {
|
||||
detail = updateLocale.noReleaseNotes
|
||||
}
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: '安装更新',
|
||||
title: updateLocale.title,
|
||||
icon,
|
||||
message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
|
||||
detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
|
||||
buttons: ['稍后安装', '立即安装'],
|
||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
||||
detail,
|
||||
buttons: [updateLocale.later, updateLocale.install],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
@@ -118,7 +133,7 @@ export default class AppUpdater {
|
||||
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return '暂无更新说明'
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
|
||||
@@ -7,7 +7,7 @@ import Logger from 'electron-log'
|
||||
import * as fs from 'fs-extra'
|
||||
import StreamZip from 'node-stream-zip'
|
||||
import * as path from 'path'
|
||||
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
@@ -255,19 +255,26 @@ class BackupManager {
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
const dataExists = await fs.pathExists(sourcePath)
|
||||
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
|
||||
|
||||
await this.setWritableRecursive(destPath)
|
||||
await fs.remove(destPath)
|
||||
if (dataExists && dataFiles.length > 0) {
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
await this.setWritableRecursive(destPath)
|
||||
await fs.remove(destPath)
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
} else {
|
||||
Logger.log('[backup] skipBackupFile is true, skip restoring Data directory')
|
||||
}
|
||||
|
||||
Logger.log('[backup] step 4: clean up temp directory')
|
||||
// 清理临时目录
|
||||
@@ -288,10 +295,12 @@ class BackupManager {
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
|
||||
const contentLength = (await fs.stat(backupedFilePath)).size
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
try {
|
||||
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
overwrite: true
|
||||
overwrite: true,
|
||||
contentLength
|
||||
})
|
||||
// 上传成功后删除本地备份文件
|
||||
await fs.remove(backupedFilePath)
|
||||
@@ -333,12 +342,8 @@ class BackupManager {
|
||||
|
||||
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
|
||||
try {
|
||||
const client = createClient(config.webdavHost, {
|
||||
username: config.webdavUser,
|
||||
password: config.webdavPass
|
||||
})
|
||||
|
||||
const response = await client.getDirectoryContents(config.webdavPath)
|
||||
const client = new WebDav(config)
|
||||
const response = await client.getDirectoryContents()
|
||||
const files = Array.isArray(response) ? response : response.data
|
||||
|
||||
return files
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { defaultLanguage, FeedUrl, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
|
||||
import { locales } from '../utils/locales'
|
||||
|
||||
enum ConfigKeys {
|
||||
export enum ConfigKeys {
|
||||
Language = 'language',
|
||||
Theme = 'theme',
|
||||
LaunchToTray = 'launchToTray',
|
||||
@@ -16,7 +16,14 @@ enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
EnableDataCollection = 'enableDataCollection'
|
||||
FeedUrl = 'feedUrl',
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
|
||||
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
|
||||
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -32,12 +39,12 @@ export class ConfigManager {
|
||||
return this.get(ConfigKeys.Language, locale) as LanguageVarious
|
||||
}
|
||||
|
||||
setLanguage(theme: LanguageVarious) {
|
||||
this.set(ConfigKeys.Language, theme)
|
||||
setLanguage(lang: LanguageVarious) {
|
||||
this.setAndNotify(ConfigKeys.Language, lang)
|
||||
}
|
||||
|
||||
getTheme(): ThemeMode {
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.auto)
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.system)
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
@@ -57,8 +64,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setTray(value: boolean) {
|
||||
this.set(ConfigKeys.Tray, value)
|
||||
this.notifySubscribers(ConfigKeys.Tray, value)
|
||||
this.setAndNotify(ConfigKeys.Tray, value)
|
||||
}
|
||||
|
||||
getTrayOnClose(): boolean {
|
||||
@@ -74,8 +80,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setZoomFactor(factor: number) {
|
||||
this.set(ConfigKeys.ZoomFactor, factor)
|
||||
this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
|
||||
this.setAndNotify(ConfigKeys.ZoomFactor, factor)
|
||||
}
|
||||
|
||||
subscribe<T>(key: string, callback: (newValue: T) => void) {
|
||||
@@ -107,11 +112,10 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setShortcuts(shortcuts: Shortcut[]) {
|
||||
this.set(
|
||||
this.setAndNotify(
|
||||
ConfigKeys.Shortcuts,
|
||||
shortcuts.filter((shortcut) => shortcut.system)
|
||||
)
|
||||
this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
|
||||
}
|
||||
|
||||
getClickTrayToShowQuickAssistant(): boolean {
|
||||
@@ -127,7 +131,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setEnableQuickAssistant(value: boolean) {
|
||||
this.set(ConfigKeys.EnableQuickAssistant, value)
|
||||
this.setAndNotify(ConfigKeys.EnableQuickAssistant, value)
|
||||
}
|
||||
|
||||
getAutoUpdate(): boolean {
|
||||
@@ -138,6 +142,14 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.AutoUpdate, value)
|
||||
}
|
||||
|
||||
getFeedUrl(): string {
|
||||
return this.get<string>(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION)
|
||||
}
|
||||
|
||||
setFeedUrl(value: FeedUrl) {
|
||||
this.set(ConfigKeys.FeedUrl, value)
|
||||
}
|
||||
|
||||
getEnableDataCollection(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.EnableDataCollection, true)
|
||||
}
|
||||
@@ -146,8 +158,64 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.EnableDataCollection, value)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
// Selection Assistant: is enabled the selection assistant
|
||||
getSelectionAssistantEnabled(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, false)
|
||||
}
|
||||
|
||||
setSelectionAssistantEnabled(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: trigger mode (selected, ctrlkey)
|
||||
getSelectionAssistantTriggerMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantTriggerMode, 'selected')
|
||||
}
|
||||
|
||||
setSelectionAssistantTriggerMode(value: string) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: if action window position follow toolbar
|
||||
getSelectionAssistantFollowToolbar(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantFollowToolbar, true)
|
||||
}
|
||||
|
||||
setSelectionAssistantFollowToolbar(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantRemeberWinSize(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantRemeberWinSize, false)
|
||||
}
|
||||
|
||||
setSelectionAssistantRemeberWinSize(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantFilterMode, 'default')
|
||||
}
|
||||
|
||||
setSelectionAssistantFilterMode(value: string) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterList(): string[] {
|
||||
return this.get<string[]>(ConfigKeys.SelectionAssistantFilterList, [])
|
||||
}
|
||||
|
||||
setSelectionAssistantFilterList(value: string[]) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value)
|
||||
}
|
||||
|
||||
setAndNotify(key: string, value: unknown) {
|
||||
this.set(key, value, true)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown, isNotify: boolean = false) {
|
||||
this.store.set(key, value)
|
||||
isNotify && this.notifySubscribers(key, value)
|
||||
}
|
||||
|
||||
get<T>(key: string, defaultValue?: T) {
|
||||
|
||||
@@ -47,6 +47,8 @@ export class ExportService {
|
||||
let linkText = ''
|
||||
let linkUrl = ''
|
||||
let insideLink = false
|
||||
let boldStack = 0 // 跟踪嵌套的粗体标记
|
||||
let italicStack = 0 // 跟踪嵌套的斜体标记
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]
|
||||
@@ -82,17 +84,37 @@ export class ExportService {
|
||||
insideLink = false
|
||||
}
|
||||
break
|
||||
case 'strong_open':
|
||||
boldStack++
|
||||
break
|
||||
case 'strong_close':
|
||||
boldStack--
|
||||
break
|
||||
case 'em_open':
|
||||
italicStack++
|
||||
break
|
||||
case 'em_close':
|
||||
italicStack--
|
||||
break
|
||||
case 'text':
|
||||
runs.push(new TextRun({ text: token.content, bold: isHeaderRow }))
|
||||
break
|
||||
case 'strong':
|
||||
runs.push(new TextRun({ text: token.content, bold: true }))
|
||||
break
|
||||
case 'em':
|
||||
runs.push(new TextRun({ text: token.content, italics: true }))
|
||||
runs.push(
|
||||
new TextRun({
|
||||
text: token.content,
|
||||
bold: isHeaderRow || boldStack > 0,
|
||||
italics: italicStack > 0
|
||||
})
|
||||
)
|
||||
break
|
||||
case 'code_inline':
|
||||
runs.push(new TextRun({ text: token.content, font: 'Consolas', size: 20 }))
|
||||
runs.push(
|
||||
new TextRun({
|
||||
text: token.content,
|
||||
font: 'Consolas',
|
||||
size: 20,
|
||||
bold: isHeaderRow || boldStack > 0,
|
||||
italics: italicStack > 0
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
export default class FileService {
|
||||
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
|
||||
return fs.readFileSync(path, 'utf8')
|
||||
public static async readFile(_: Electron.IpcMainInvokeEvent, pathOrUrl: string, encoding?: BufferEncoding) {
|
||||
const path = pathOrUrl.startsWith('file://') ? new URL(pathOrUrl) : pathOrUrl
|
||||
if (encoding) return fs.readFile(path, { encoding })
|
||||
return fs.readFile(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ class FileStorage {
|
||||
this.initStorageDir()
|
||||
}
|
||||
|
||||
public resolveFilePath = (name: string): string => {
|
||||
return path.join(this.storageDir, name)
|
||||
}
|
||||
|
||||
private initStorageDir = (): void => {
|
||||
try {
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
@@ -272,6 +268,51 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
|
||||
try {
|
||||
if (!base64Data) {
|
||||
throw new Error('Base64 data is required')
|
||||
}
|
||||
|
||||
// 移除 base64 头部信息(如果存在)
|
||||
const base64String = base64Data.replace(/^data:.*;base64,/, '')
|
||||
const buffer = Buffer.from(base64String, 'base64')
|
||||
const uuid = uuidv4()
|
||||
const ext = '.png'
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
logger.info('[FileStorage] Saving base64 image:', {
|
||||
storageDir: this.storageDir,
|
||||
destPath,
|
||||
bufferSize: buffer.length
|
||||
})
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
id: uuid,
|
||||
origin_name: uuid + ext,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: new Date().toISOString(),
|
||||
size: buffer.length,
|
||||
ext: ext.slice(1),
|
||||
type: getFileType(ext),
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Failed to save base64 image:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
const buffer = await fs.promises.readFile(filePath)
|
||||
@@ -332,7 +373,7 @@ class FileStorage {
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<string | null> => {
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
@@ -340,14 +381,18 @@ class FileStorage {
|
||||
...options
|
||||
})
|
||||
|
||||
if (result.canceled) {
|
||||
return Promise.reject(new Error('User canceled the save dialog'))
|
||||
}
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||
}
|
||||
|
||||
return result.filePath
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
return null
|
||||
return Promise.reject('An error occurred saving the file: ' + err?.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +431,11 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise<FileType> => {
|
||||
public downloadFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
url: string,
|
||||
isUseContentType?: boolean
|
||||
): Promise<FileType> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
@@ -411,7 +460,7 @@ class FileStorage {
|
||||
}
|
||||
|
||||
// 如果文件名没有后缀,根据Content-Type添加后缀
|
||||
if (!filename.includes('.')) {
|
||||
if (isUseContentType || !filename.includes('.')) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
const ext = this.getExtensionFromMimeType(contentType)
|
||||
filename += ext
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { File, FileState, GoogleGenAI, Pager } from '@google/genai'
|
||||
import { FileType } from '@types'
|
||||
import fs from 'fs'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
|
||||
export class GeminiService {
|
||||
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
|
||||
private static readonly CACHE_DURATION = 3000
|
||||
|
||||
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
return await sdk.files.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
displayName: file.origin_name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async base64File(_: Electron.IpcMainInvokeEvent, file: FileType) {
|
||||
return {
|
||||
data: Buffer.from(fs.readFileSync(file.path)).toString('base64'),
|
||||
mimeType: 'application/pdf'
|
||||
}
|
||||
}
|
||||
|
||||
static async retrieveFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File | undefined> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
|
||||
if (cachedResponse) {
|
||||
return GeminiService.processResponse(cachedResponse, file)
|
||||
}
|
||||
|
||||
const response = await sdk.files.list()
|
||||
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, response, GeminiService.CACHE_DURATION)
|
||||
|
||||
return GeminiService.processResponse(response, file)
|
||||
}
|
||||
|
||||
private static async processResponse(response: Pager<File>, file: FileType) {
|
||||
for await (const f of response) {
|
||||
if (f.state === FileState.ACTIVE) {
|
||||
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string): Promise<File[]> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const files: File[] = []
|
||||
for await (const f of await sdk.files.list()) {
|
||||
files.push(f)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
static async deleteFile(_: Electron.IpcMainInvokeEvent, fileId: string, apiKey: string) {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
await sdk.files.delete({ name: fileId })
|
||||
}
|
||||
}
|
||||
@@ -69,18 +69,10 @@ function withCache<T extends unknown[], R>(
|
||||
}
|
||||
|
||||
class McpService {
|
||||
private static instance: McpService | null = null
|
||||
private clients: Map<string, Client> = new Map()
|
||||
private pendingClients: Map<string, Promise<Client>> = new Map()
|
||||
|
||||
public static getInstance(): McpService {
|
||||
if (!McpService.instance) {
|
||||
McpService.instance = new McpService()
|
||||
}
|
||||
return McpService.instance
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
constructor() {
|
||||
this.initClient = this.initClient.bind(this)
|
||||
this.listTools = this.listTools.bind(this)
|
||||
this.callTool = this.callTool.bind(this)
|
||||
@@ -99,7 +91,7 @@ class McpService {
|
||||
return JSON.stringify({
|
||||
baseUrl: server.baseUrl,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
args: Array.isArray(server.args) ? server.args : [],
|
||||
registryUrl: server.registryUrl,
|
||||
env: server.env,
|
||||
id: server.id
|
||||
@@ -251,6 +243,12 @@ class McpService {
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
const loginShellEnv = await this.getLoginShellEnv()
|
||||
|
||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||
if (cmd.includes('bun')) {
|
||||
this.removeProxyEnv(loginShellEnv)
|
||||
}
|
||||
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
@@ -569,12 +567,11 @@ class McpService {
|
||||
try {
|
||||
const result = await client.listResources()
|
||||
const resources = result.resources || []
|
||||
const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({
|
||||
return (Array.isArray(resources) ? resources : []).map((resource: any) => ({
|
||||
...resource,
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}))
|
||||
return serverResources
|
||||
} catch (error: any) {
|
||||
// -32601 is the code for the method not found
|
||||
if (error?.code !== -32601) {
|
||||
@@ -659,15 +656,14 @@ class McpService {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let mcpInstance: ReturnType<typeof McpService.getInstance> | null = null
|
||||
|
||||
export const getMcpInstance = () => {
|
||||
if (!mcpInstance) {
|
||||
mcpInstance = McpService.getInstance()
|
||||
private removeProxyEnv(env: Record<string, string>) {
|
||||
delete env.HTTPS_PROXY
|
||||
delete env.HTTP_PROXY
|
||||
delete env.grpc_proxy
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
return mcpInstance
|
||||
}
|
||||
|
||||
export default McpService.getInstance
|
||||
export default new McpService()
|
||||
|
||||
31
src/main/services/NotificationService.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
|
||||
class NotificationService {
|
||||
private window: BrowserWindow
|
||||
|
||||
constructor(window: BrowserWindow) {
|
||||
// Initialize the service
|
||||
this.window = window
|
||||
}
|
||||
|
||||
public async sendNotification(notification: Notification) {
|
||||
// 使用 Electron Notification API
|
||||
const electronNotification = new ElectronNotification({
|
||||
title: notification.title,
|
||||
body: notification.message,
|
||||
icon: icon
|
||||
})
|
||||
|
||||
electronNotification.on('click', () => {
|
||||
this.window.show()
|
||||
this.window.webContents.send('notification-click', notification)
|
||||
})
|
||||
|
||||
electronNotification.show()
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationService
|
||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { handleProvidersProtocolUrl } from './urlschema/handle-providers'
|
||||
import { handleMcpProtocolUrl } from './urlschema/mcp-install'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -34,6 +35,9 @@ export function handleProtocolUrl(url: string) {
|
||||
case 'mcp':
|
||||
handleMcpProtocolUrl(urlObj)
|
||||
return
|
||||
case 'providers':
|
||||
handleProvidersProtocolUrl(urlObj)
|
||||
return
|
||||
}
|
||||
|
||||
// You can send the data to your renderer process
|
||||
|
||||
1250
src/main/services/SelectionService.ts
Normal file
48
src/main/services/ThemeService.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ThemeService {
|
||||
private theme: ThemeMode = ThemeMode.system
|
||||
constructor() {
|
||||
this.theme = configManager.getTheme()
|
||||
|
||||
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
|
||||
nativeTheme.themeSource = this.theme
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
configManager.setTheme(ThemeMode.system)
|
||||
nativeTheme.themeSource = ThemeMode.system
|
||||
}
|
||||
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
|
||||
}
|
||||
|
||||
themeUpdatadHandler() {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
|
||||
try {
|
||||
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
} catch (error) {
|
||||
// don't throw error if setTitleBarOverlay failed
|
||||
// Because it may be called with some windows have some title bar
|
||||
}
|
||||
}
|
||||
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
|
||||
})
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
if (theme === this.theme) {
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
nativeTheme.themeSource = theme
|
||||
configManager.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const themeService = new ThemeService()
|
||||
@@ -5,16 +5,17 @@ import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray }
|
||||
import icon from '../../../build/tray_icon.png?asset'
|
||||
import iconDark from '../../../build/tray_icon_dark.png?asset'
|
||||
import iconLight from '../../../build/tray_icon_light.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
private static instance: TrayService
|
||||
private tray: Tray | null = null
|
||||
private contextMenu: Menu | null = null
|
||||
|
||||
constructor() {
|
||||
this.watchConfigChanges()
|
||||
this.updateTray()
|
||||
this.watchTrayChanges()
|
||||
TrayService.instance = this
|
||||
}
|
||||
|
||||
@@ -43,6 +44,30 @@ export class TrayService {
|
||||
|
||||
this.tray = tray
|
||||
|
||||
this.updateContextMenu()
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(this.contextMenu)
|
||||
}
|
||||
|
||||
this.tray.setToolTip('Cherry Studio')
|
||||
|
||||
this.tray.on('right-click', () => {
|
||||
if (this.contextMenu) {
|
||||
this.tray?.popUpContextMenu(this.contextMenu)
|
||||
}
|
||||
})
|
||||
|
||||
this.tray.on('click', () => {
|
||||
if (configManager.getEnableQuickAssistant() && configManager.getClickTrayToShowQuickAssistant()) {
|
||||
windowService.showMiniWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private updateContextMenu() {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale } = locale.translation
|
||||
|
||||
@@ -64,25 +89,7 @@ export class TrayService {
|
||||
}
|
||||
].filter(Boolean) as MenuItemConstructorOptions[]
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate(template)
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(contextMenu)
|
||||
}
|
||||
|
||||
this.tray.setToolTip('Cherry Studio')
|
||||
|
||||
this.tray.on('right-click', () => {
|
||||
this.tray?.popUpContextMenu(contextMenu)
|
||||
})
|
||||
|
||||
this.tray.on('click', () => {
|
||||
if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
|
||||
windowService.showMiniWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
this.contextMenu = Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
private updateTray() {
|
||||
@@ -94,13 +101,6 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
public restartTray() {
|
||||
if (configManager.getTray()) {
|
||||
this.destroyTray()
|
||||
this.createTray()
|
||||
}
|
||||
}
|
||||
|
||||
private destroyTray() {
|
||||
if (this.tray) {
|
||||
this.tray.destroy()
|
||||
@@ -108,8 +108,16 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
private watchTrayChanges() {
|
||||
configManager.subscribe<boolean>('tray', () => this.updateTray())
|
||||
private watchConfigChanges() {
|
||||
configManager.subscribe(ConfigKeys.Tray, () => this.updateTray())
|
||||
|
||||
configManager.subscribe(ConfigKeys.Language, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
}
|
||||
|
||||
private quit() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import https from 'https'
|
||||
import path from 'path'
|
||||
import Stream from 'stream'
|
||||
import {
|
||||
BufferLike,
|
||||
@@ -14,13 +16,14 @@ export default class WebDav {
|
||||
private webdavPath: string
|
||||
|
||||
constructor(params: WebDavConfig) {
|
||||
this.webdavPath = params.webdavPath
|
||||
this.webdavPath = params.webdavPath || '/'
|
||||
|
||||
this.instance = createClient(params.webdavHost, {
|
||||
username: params.webdavUser,
|
||||
password: params.webdavPass,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity
|
||||
maxContentLength: Infinity,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false })
|
||||
})
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
@@ -49,7 +52,7 @@ export default class WebDav {
|
||||
throw error
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
const remoteFilePath = path.posix.join(this.webdavPath, filename)
|
||||
|
||||
try {
|
||||
return await this.instance.putFileContents(remoteFilePath, data, options)
|
||||
@@ -64,7 +67,7 @@ export default class WebDav {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
const remoteFilePath = path.posix.join(this.webdavPath, filename)
|
||||
|
||||
try {
|
||||
return await this.instance.getFileContents(remoteFilePath, options)
|
||||
@@ -74,6 +77,19 @@ export default class WebDav {
|
||||
}
|
||||
}
|
||||
|
||||
public getDirectoryContents = async () => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.instance.getDirectoryContents(this.webdavPath)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error getting directory contents on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public checkConnection = async () => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
@@ -105,7 +121,7 @@ export default class WebDav {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
const remoteFilePath = path.posix.join(this.webdavPath, filename)
|
||||
|
||||
try {
|
||||
return await this.instance.deleteFile(remoteFilePath)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// just import the themeService to ensure the theme is initialized
|
||||
import './ThemeService'
|
||||
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { getFilesDir } from '@main/utils/file'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { app, BrowserWindow, nativeTheme, shell } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
@@ -45,13 +47,6 @@ export class WindowService {
|
||||
maximize: false
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
if (theme === ThemeMode.auto) {
|
||||
nativeTheme.themeSource = 'system'
|
||||
} else {
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
|
||||
this.mainWindow = new BrowserWindow({
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
@@ -61,14 +56,14 @@ export class WindowService {
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
transparent: false,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -549,6 +544,25 @@ export class WindowService {
|
||||
public setPinMiniWindow(isPinned) {
|
||||
this.isPinnedMiniWindow = isPinned
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用文本到主窗口
|
||||
* @param text 原始文本(未格式化)
|
||||
*/
|
||||
public quoteToMainWindow(text: string): void {
|
||||
try {
|
||||
this.showMainWindow()
|
||||
|
||||
const mainWindow = this.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
setTimeout(() => {
|
||||
mainWindow.webContents.send(IpcChannel.App_QuoteToMain, text)
|
||||
}, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to quote to main window:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const windowService = WindowService.getInstance()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth'
|
||||
import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import Logger from 'electron-log'
|
||||
import open from 'open'
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
Logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`)
|
||||
}
|
||||
|
||||
const env = {}
|
||||
const env: Record<string, string> = {}
|
||||
const lines = output.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
@@ -110,6 +110,8 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
Logger.warn('Raw output from shell:\n', output)
|
||||
}
|
||||
|
||||
env.PATH = env.Path || env.PATH || ''
|
||||
|
||||
resolve(env)
|
||||
})
|
||||
})
|
||||
|
||||
37
src/main/services/urlschema/handle-providers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
|
||||
export function handleProvidersProtocolUrl(url: URL) {
|
||||
const params = new URLSearchParams(url.search)
|
||||
switch (url.pathname) {
|
||||
case '/api-keys': {
|
||||
// jsonConfig example:
|
||||
// {
|
||||
// "id": "tokenflux",
|
||||
// "baseUrl": "https://tokenflux.ai/v1",
|
||||
// "apiKey": "sk-xxxx"
|
||||
// }
|
||||
// cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))}
|
||||
const data = params.get('data')
|
||||
if (data) {
|
||||
const stringify = Buffer.from(data, 'base64').toString('utf8')
|
||||
Logger.info('get api keys from urlschema: ', stringify)
|
||||
const jsonConfig = JSON.parse(stringify)
|
||||
Logger.info('get api keys from urlschema: ', jsonConfig)
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig)
|
||||
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`)
|
||||
}
|
||||
} else {
|
||||
Logger.error('No data found in URL')
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown MCP protocol URL: ${url}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/mcp-servers')")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
71
src/main/utils/__tests__/aes.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { decrypt, encrypt } from '../aes'
|
||||
|
||||
const key = '12345678901234567890123456789012' // 32字节
|
||||
const iv = '1234567890abcdef1234567890abcdef' // 32字节hex,实际应16字节hex
|
||||
|
||||
function getIv16() {
|
||||
// 取前16字节作为 hex
|
||||
return iv.slice(0, 32)
|
||||
}
|
||||
|
||||
describe('aes utils', () => {
|
||||
it('should encrypt and decrypt normal string', () => {
|
||||
const text = 'hello world'
|
||||
const { iv: outIv, encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(typeof encryptedData).toBe('string')
|
||||
expect(outIv).toBe(getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should support unicode and special chars', () => {
|
||||
const text = '你好,世界!🌟🚀'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const text = ''
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should encrypt and decrypt long string', () => {
|
||||
const text = 'a'.repeat(100_000)
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
const decrypted = decrypt(encryptedData, getIv16(), key)
|
||||
expect(decrypted).toBe(text)
|
||||
})
|
||||
|
||||
it('should throw error for wrong key', () => {
|
||||
const text = 'test'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for wrong iv', () => {
|
||||
const text = 'test'
|
||||
const { encryptedData } = encrypt(text, key, getIv16())
|
||||
expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid key/iv length', () => {
|
||||
expect(() => encrypt('test', 'shortkey', getIv16())).toThrow()
|
||||
expect(() => encrypt('test', key, 'shortiv')).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid encrypted data', () => {
|
||||
expect(() => decrypt('nothexdata', getIv16(), key)).toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for non-string input', () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
expect(() => encrypt(null, key, getIv16())).toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
expect(() => decrypt(null, getIv16(), key)).toThrow()
|
||||
})
|
||||
})
|
||||
243
src/main/utils/__tests__/file.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileTypes } from '@types'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
vi.mock('node:os')
|
||||
vi.mock('node:path')
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'mock-uuid'
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((key) => {
|
||||
if (key === 'temp') return '/mock/temp'
|
||||
if (key === 'userData') return '/mock/userData'
|
||||
return '/mock/unknown'
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
describe('file', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock path.extname
|
||||
vi.mocked(path.extname).mockImplementation((file) => {
|
||||
const parts = file.split('.')
|
||||
return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''
|
||||
})
|
||||
|
||||
// Mock path.basename
|
||||
vi.mocked(path.basename).mockImplementation((file) => {
|
||||
const parts = file.split('/')
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
|
||||
// Mock path.join
|
||||
vi.mocked(path.join).mockImplementation((...args) => args.join('/'))
|
||||
|
||||
// Mock os.homedir
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('getFileType', () => {
|
||||
it('should return IMAGE for image extensions', () => {
|
||||
expect(getFileType('.jpg')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.png')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.gif')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.webp')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.bmp')).toBe(FileTypes.IMAGE)
|
||||
})
|
||||
|
||||
it('should return VIDEO for video extensions', () => {
|
||||
expect(getFileType('.mp4')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.avi')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.mov')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.mkv')).toBe(FileTypes.VIDEO)
|
||||
expect(getFileType('.flv')).toBe(FileTypes.VIDEO)
|
||||
})
|
||||
|
||||
it('should return AUDIO for audio extensions', () => {
|
||||
expect(getFileType('.mp3')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.wav')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.ogg')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.flac')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.aac')).toBe(FileTypes.AUDIO)
|
||||
})
|
||||
|
||||
it('should return TEXT for text extensions', () => {
|
||||
expect(getFileType('.txt')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.md')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.html')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.json')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.js')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.ts')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.css')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.java')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.py')).toBe(FileTypes.TEXT)
|
||||
})
|
||||
|
||||
it('should return DOCUMENT for document extensions', () => {
|
||||
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should return OTHER for unknown extensions', () => {
|
||||
expect(getFileType('.unknown')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('...')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.123')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
|
||||
it('should handle case-insensitive extensions', () => {
|
||||
expect(getFileType('.JPG')).toBe(FileTypes.IMAGE)
|
||||
expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT)
|
||||
expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO)
|
||||
expect(getFileType('.HtMl')).toBe(FileTypes.TEXT)
|
||||
expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should handle extensions without leading dot', () => {
|
||||
expect(getFileType('jpg')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('pdf')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('mp3')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
|
||||
it('should handle extreme cases', () => {
|
||||
expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.文件')).toBe(FileTypes.OTHER)
|
||||
expect(getFileType('.файл')).toBe(FileTypes.OTHER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllFiles', () => {
|
||||
it('should return all valid files recursively', () => {
|
||||
// Mock file system
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => {
|
||||
if (dirPath === '/test') {
|
||||
return ['file1.txt', 'file2.pdf', 'subdir']
|
||||
} else if (dirPath === '/test/subdir') {
|
||||
return ['file3.md', 'file4.docx']
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
vi.mocked(fs.statSync).mockImplementation((filePath) => {
|
||||
const isDir = String(filePath).endsWith('subdir')
|
||||
return {
|
||||
isDirectory: () => isDir,
|
||||
size: 1024
|
||||
} as fs.Stats
|
||||
})
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].id).toBe('mock-uuid')
|
||||
expect(result[0].name).toBe('file1.txt')
|
||||
expect(result[0].type).toBe(FileTypes.TEXT)
|
||||
expect(result[1].name).toBe('file2.pdf')
|
||||
expect(result[1].type).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should skip hidden files', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt'])
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => false,
|
||||
size: 1024
|
||||
} as fs.Stats)
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('visible.txt')
|
||||
})
|
||||
|
||||
it('should skip unsupported file types', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf'])
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => false,
|
||||
size: 1024
|
||||
} as fs.Stats)
|
||||
|
||||
const result = getAllFiles('/test')
|
||||
|
||||
// Should only include document.pdf as the others are excluded types
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('document.pdf')
|
||||
expect(result[0].type).toBe(FileTypes.DOCUMENT)
|
||||
})
|
||||
|
||||
it('should return empty array for empty directory', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockReturnValue([])
|
||||
|
||||
const result = getAllFiles('/empty')
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle file system errors', () => {
|
||||
// @ts-ignore - override type for testing
|
||||
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
|
||||
throw new Error('Directory not found')
|
||||
})
|
||||
|
||||
// Since the function doesn't have error handling, we expect it to propagate
|
||||
expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTempDir', () => {
|
||||
it('should return correct temp directory path', () => {
|
||||
const tempDir = getTempDir()
|
||||
expect(tempDir).toBe('/mock/temp/CherryStudio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilesDir', () => {
|
||||
it('should return correct files directory path', () => {
|
||||
const filesDir = getFilesDir()
|
||||
expect(filesDir).toBe('/mock/userData/Data/Files')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfigDir', () => {
|
||||
it('should return correct config directory path', () => {
|
||||
const configDir = getConfigDir()
|
||||
expect(configDir).toBe('/mock/home/.cherrystudio/config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAppConfigDir', () => {
|
||||
it('should return correct app config directory path', () => {
|
||||
const appConfigDir = getAppConfigDir('test-app')
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app')
|
||||
})
|
||||
|
||||
it('should handle empty app name', () => {
|
||||
const appConfigDir = getAppConfigDir('')
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
|
||||
})
|
||||
})
|
||||
})
|
||||
61
src/main/utils/__tests__/zip.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compress, decompress } from '../zip'
|
||||
|
||||
const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] })
|
||||
|
||||
// 辅助函数:生成大字符串
|
||||
function makeLargeString(size: number) {
|
||||
return 'a'.repeat(size)
|
||||
}
|
||||
|
||||
describe('zip', () => {
|
||||
describe('compress & decompress', () => {
|
||||
it('should compress and decompress a normal JSON string', async () => {
|
||||
const compressed = await compress(jsonStr)
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe(jsonStr)
|
||||
})
|
||||
|
||||
it('should handle empty string', async () => {
|
||||
const compressed = await compress('')
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe('')
|
||||
})
|
||||
|
||||
it('should handle large string', async () => {
|
||||
const largeStr = makeLargeString(100_000)
|
||||
const compressed = await compress(largeStr)
|
||||
expect(compressed).toBeInstanceOf(Buffer)
|
||||
expect(compressed.length).toBeLessThan(largeStr.length)
|
||||
const decompressed = await decompress(compressed)
|
||||
expect(decompressed).toBe(largeStr)
|
||||
})
|
||||
|
||||
it('should throw error when decompressing invalid buffer', async () => {
|
||||
const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8')
|
||||
await expect(decompress(invalidBuffer)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error when compress input is not string', async () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(null)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(undefined)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(compress(123)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error when decompress input is not buffer', async () => {
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress(null)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress(undefined)).rejects.toThrow()
|
||||
// @ts-expect-error purposely pass wrong type to test error branch
|
||||
await expect(decompress('string')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip)
|
||||
|
||||
/**
|
||||
* 压缩字符串
|
||||
* @param {string} str 要压缩的 JSON 字符串
|
||||
* @returns {Promise<Buffer>} 压缩后的 Buffer
|
||||
* @param str
|
||||
*/
|
||||
export async function compress(str) {
|
||||
export async function compress(str: string): Promise<Buffer> {
|
||||
try {
|
||||
const buffer = Buffer.from(str, 'utf-8')
|
||||
return await gzipPromise(buffer)
|
||||
@@ -27,7 +27,7 @@ export async function compress(str) {
|
||||
* @param {Buffer} compressedBuffer - 压缩的 Buffer
|
||||
* @returns {Promise<string>} 解压缩后的 JSON 字符串
|
||||
*/
|
||||
export async function decompress(compressedBuffer) {
|
||||
export async function decompress(compressedBuffer: Buffer): Promise<string> {
|
||||
try {
|
||||
const buffer = await gunzipPromise(compressedBuffer)
|
||||
return buffer.toString('utf-8')
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
|
||||
@@ -17,14 +21,17 @@ const api = {
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
|
||||
setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl),
|
||||
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
notification: {
|
||||
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
|
||||
},
|
||||
system: {
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
|
||||
@@ -55,7 +62,6 @@ const api = {
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
resolveFilePath: (name: string) => ipcRenderer.invoke(IpcChannel.File_ResolveFilePath, name),
|
||||
upload: (file: FileType) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
||||
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||
@@ -71,14 +77,16 @@ const api = {
|
||||
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
|
||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||
download: (url: string, isUseContentType?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
},
|
||||
fs: {
|
||||
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
||||
@@ -113,14 +121,16 @@ const api = {
|
||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
||||
},
|
||||
gemini: {
|
||||
uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, apiKey),
|
||||
uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
|
||||
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
|
||||
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
|
||||
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
|
||||
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
|
||||
},
|
||||
miniWindow: {
|
||||
@@ -200,7 +210,26 @@ const api = {
|
||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||
unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
|
||||
onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action)
|
||||
}
|
||||
},
|
||||
selection: {
|
||||
hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide),
|
||||
writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text),
|
||||
determineToolbarSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height),
|
||||
setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled),
|
||||
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
|
||||
setFollowToolbar: (isFollowToolbar: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
|
||||
setRemeberWinSize: (isRemeberWinSize: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
|
||||
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
|
||||
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text)
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('electron-log/renderer', () => {
|
||||
return {
|
||||
default: {
|
||||
info: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
debug: console.debug,
|
||||
verbose: console.log,
|
||||
silly: console.log,
|
||||
log: console.log,
|
||||
transports: {
|
||||
console: {
|
||||
level: 'info'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
on: vi.fn(), // Mocking ipcRenderer.on
|
||||
send: vi.fn() // Mocking ipcRenderer.send
|
||||
}
|
||||
},
|
||||
api: {
|
||||
file: {
|
||||
read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
|
||||
writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
|
||||
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
|
||||
// You can add other axios methods like put, delete etc. as needed
|
||||
}
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
...global.window, // Copy other global properties
|
||||
addEventListener: vi.fn(), // Mock addEventListener
|
||||
removeEventListener: vi.fn() // You can also mock removeEventListener if needed
|
||||
})
|
||||
41
src/renderer/selectionAction.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Assistant</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
43
src/renderer/selectionToolbar.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,18 +5,19 @@ import { Provider } from 'react-redux'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import MainSidebar from './components/app/MainSidebar'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import DiscoverPage from './pages/discover'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import McpServersPage from './pages/mcp-servers'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
@@ -27,26 +28,30 @@ function App(): React.ReactElement {
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<MainSidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* <Route path="/agents" element={<AgentsPage />} /> */}
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
{/* <Route path="/apps" element={<AppsPage />} /> */}
|
||||
<Route path="/mcp-servers/*" element={<McpServersPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/discover/*" element={<DiscoverPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
|
||||
1
src/renderer/src/assets/images/apps/google.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>
|
||||
|
After Width: | Height: | Size: 688 B |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.3 KiB |
BIN
src/renderer/src/assets/images/models/tokenflux.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/renderer/src/assets/images/models/tokenflux_dark.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/renderer/src/assets/images/providers/DMXAPI-to-img.webp
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
src/renderer/src/assets/images/providers/burncloud.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/renderer/src/assets/images/providers/dmxapi-logo.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 81 KiB |
BIN
src/renderer/src/assets/images/providers/tokenflux.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
outline: none;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.ant-segmented-group {
|
||||
@@ -25,7 +25,6 @@
|
||||
}
|
||||
|
||||
.minapp-drawer {
|
||||
max-width: calc(100vw - var(--sidebar-width));
|
||||
.ant-drawer-content-wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -33,7 +32,7 @@
|
||||
position: absolute;
|
||||
-webkit-app-region: drag;
|
||||
min-height: calc(var(--navbar-height) + 0.5px);
|
||||
width: calc(100vw - var(--sidebar-width));
|
||||
width: 100%;
|
||||
margin-top: -0.5px;
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -206,8 +205,14 @@
|
||||
|
||||
.ant-collapse {
|
||||
border: 1px solid var(--color-border);
|
||||
.ant-color-picker & {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-text-secondary: rgba(235, 235, 245, 0.7);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border: #383838;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
@@ -43,6 +44,9 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: rgba(255, 255, 255, 0.1);
|
||||
--color-list-item-hover: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
@@ -52,7 +56,7 @@
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--navbar-height: 42px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
@@ -60,13 +64,15 @@
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--chat-background: #111111;
|
||||
--chat-background-user: #28b561;
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: #232325;
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
--list-item-border-radius: 8px;
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
@@ -97,6 +103,7 @@
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-text-secondary: rgba(0, 0, 0, 0.75);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000019;
|
||||
@@ -114,6 +121,9 @@
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--color-list-item: rgba(255, 255, 255, 0.9);
|
||||
--color-list-item-hover: rgba(255, 255, 255, 0.5);
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--color-highlight: initial;
|
||||
@@ -123,8 +133,10 @@
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: #f3f3f3;
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-text);
|
||||
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#content-container {
|
||||
background-color: var(--color-background);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-container {
|
||||
.context-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
// margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -119,39 +119,33 @@ ul {
|
||||
#messages {
|
||||
background-color: var(--chat-background);
|
||||
}
|
||||
#inputbar {
|
||||
margin: -5px 15px 15px 15px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
.system-prompt {
|
||||
background-color: var(--chat-background-assistant);
|
||||
}
|
||||
.message-content-container {
|
||||
margin: 5px 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 15px 0 15px;
|
||||
}
|
||||
.message-thought-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.message-user {
|
||||
color: var(--chat-text-user);
|
||||
.markdown,
|
||||
.anticon,
|
||||
.iconfont,
|
||||
.lucide,
|
||||
.message-tokens {
|
||||
color: var(--chat-text-user) !important;
|
||||
.message-content-container {
|
||||
margin: 5px 0;
|
||||
border-radius: 8px 0 8px 8px;
|
||||
padding: 10px 15px 0 15px;
|
||||
}
|
||||
.message-action-button:hover {
|
||||
background-color: var(--color-white-soft);
|
||||
}
|
||||
.group-grid-container.horizontal,
|
||||
.group-grid-container.grid {
|
||||
.message-content-container-assistant {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.group-message-wrapper {
|
||||
background-color: var(--color-background);
|
||||
.message-content-container {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-background-mute);
|
||||
}
|
||||
}
|
||||
.group-menu-bar {
|
||||
@@ -170,6 +164,7 @@ span.highlight {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
span.highlight.selected {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
@@ -299,15 +299,21 @@ emoji-picker {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
border-radius: 5px;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
font-family: var(--code-font-family);
|
||||
padding: 1px;
|
||||
border-radius: 5px;
|
||||
|
||||
.cm-gutters {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
:root {
|
||||
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(255, 255, 255, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(0, 0, 0, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 全局初始化滚动条样式 */
|
||||
@@ -18,7 +14,8 @@ body[theme-mode='light'] {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
::-webkit-scrollbar-track,
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -30,7 +27,7 @@ body[theme-mode='light'] {
|
||||
}
|
||||
}
|
||||
|
||||
pre::-webkit-scrollbar-thumb {
|
||||
pre:not(.shiki)::-webkit-scrollbar-thumb {
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
&:hover {
|
||||
|
||||
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@use './font.scss';
|
||||
|
||||
html {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
|
||||
--color-selection-toolbar-hover-bg: #222222;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-error: #f44336;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
|
||||
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
|
||||
|
||||
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
|
||||
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
146
src/renderer/src/assets/styles/tailwind.css
Normal file
@@ -0,0 +1,146 @@
|
||||
@import 'tailwindcss' source('../../../src');
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* 如需自定义:
|
||||
1. 清晰地组织自定义 CSS 到相应的层中。
|
||||
2. 基础样式(如全局重置、链接样式)放入 base 层;
|
||||
3. 可复用的组件样式(如果仍使用 @apply 或原生 CSS 嵌套创建)放入 components 层;
|
||||
4. 新的自定义工具类放入 utilities 层。
|
||||
*/
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { uuid } from '@renderer/utils'
|
||||
@@ -12,6 +12,7 @@ import styled from 'styled-components'
|
||||
interface CodePreviewProps {
|
||||
children: string
|
||||
language: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,7 @@ interface CodePreviewProps {
|
||||
* - 通过 shiki tokenizer 处理流式响应
|
||||
* - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过
|
||||
*/
|
||||
const CodePreview = ({ children, language }: CodePreviewProps) => {
|
||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
@@ -35,7 +36,7 @@ const CodePreview = ({ children, language }: CodePreviewProps) => {
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { registerTool, removeTool } = useCodeToolbar()
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
// 展开/折叠工具
|
||||
useEffect(() => {
|
||||
@@ -133,26 +134,31 @@ const CodePreview = ({ children, language }: CodePreviewProps) => {
|
||||
return () => cleanupTokenizers(callerId)
|
||||
}, [callerId, cleanupTokenizers])
|
||||
|
||||
// 处理第二次开始的代码高亮
|
||||
// 触发代码高亮
|
||||
// - 进入视口后触发第一次高亮
|
||||
// - 内容变化后触发之后的高亮
|
||||
useEffect(() => {
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
}
|
||||
}, [highlightCode])
|
||||
|
||||
// 视口检测逻辑,只处理第一次代码高亮
|
||||
useEffect(() => {
|
||||
const codeElement = codeContentRef.current
|
||||
if (!codeElement || prevCodeLengthRef.current > 0) return
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
observer.disconnect()
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const codeElement = codeContentRef.current
|
||||
if (!codeElement) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].intersectionRatio > 0 && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '50px 0px 50px 0px'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
observer.observe(codeElement)
|
||||
|
||||
@@ -162,21 +168,24 @@ const CodePreview = ({ children, language }: CodePreviewProps) => {
|
||||
}
|
||||
}, [highlightCode])
|
||||
|
||||
const hasHighlightedCode = useMemo(() => {
|
||||
return tokenLines.length > 0
|
||||
}, [tokenLines.length])
|
||||
|
||||
return (
|
||||
<ContentContainer
|
||||
ref={codeContentRef}
|
||||
$isShowLineNumbers={codeShowLineNumbers}
|
||||
$isUnwrapped={isUnwrapped}
|
||||
$isCodeWrappable={codeWrappable}
|
||||
$lineNumbers={codeShowLineNumbers}
|
||||
$wrap={codeWrappable && !isUnwrapped}
|
||||
$fadeIn={hasHighlightedCode}
|
||||
style={{
|
||||
fontSize: fontSize - 1,
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
|
||||
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible'
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
|
||||
}}>
|
||||
{tokenLines.length > 0 ? (
|
||||
{hasHighlightedCode ? (
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
|
||||
) : (
|
||||
<div style={{ opacity: 0.1 }}>{children}</div>
|
||||
<CodePlaceholder>{children}</CodePlaceholder>
|
||||
)}
|
||||
</ContentContainer>
|
||||
)
|
||||
@@ -223,15 +232,15 @@ const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[
|
||||
)
|
||||
|
||||
const ContentContainer = styled.div<{
|
||||
$isShowLineNumbers: boolean
|
||||
$isUnwrapped: boolean
|
||||
$isCodeWrappable: boolean
|
||||
$lineNumbers: boolean
|
||||
$wrap: boolean
|
||||
$fadeIn: boolean
|
||||
}>`
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: 5px;
|
||||
margin-top: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
.shiki {
|
||||
padding: 1em;
|
||||
@@ -239,18 +248,22 @@ const ContentContainer = styled.div<{
|
||||
code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.line {
|
||||
display: block;
|
||||
min-height: 1.3rem;
|
||||
padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')};
|
||||
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
|
||||
|
||||
* {
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$isShowLineNumbers &&
|
||||
props.$lineNumbers &&
|
||||
`
|
||||
code {
|
||||
counter-reset: step;
|
||||
@@ -269,15 +282,25 @@ const ContentContainer = styled.div<{
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$isCodeWrappable &&
|
||||
!props.$isUnwrapped &&
|
||||
`
|
||||
code .line * {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`}
|
||||
@keyframes contentFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')};
|
||||
`
|
||||
|
||||
const CodePlaceholder = styled.div`
|
||||
display: block;
|
||||
opacity: 0.1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: hidden;
|
||||
min-height: 1.3rem;
|
||||
`
|
||||
|
||||
CodePreview.displayName = 'CodePreview'
|
||||
|
||||
@@ -13,7 +13,6 @@ interface Props {
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
/**
|
||||
@@ -23,6 +22,7 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
openMinapp({
|
||||
id: 'artifacts-preview',
|
||||
name: title,
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
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 {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const MermaidPreview: React.FC<Props> = ({ children }) => {
|
||||
const { mermaid, isLoading, error: mermaidError } = useMermaid()
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
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, {
|
||||
@@ -25,60 +32,75 @@ const MermaidPreview: React.FC<Props> = ({ children }) => {
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
const render = useCallback(async () => {
|
||||
try {
|
||||
if (!children) return
|
||||
// 实际的渲染函数
|
||||
const renderMermaid = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content || !mermaidRef.current) return
|
||||
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(children)
|
||||
try {
|
||||
setIsRendering(true)
|
||||
|
||||
if (!mermaidRef.current) return
|
||||
const { svg } = await mermaid.render(diagramId, children, mermaidRef.current)
|
||||
// 验证语法,提前抛出异常
|
||||
await mermaid.parse(content)
|
||||
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
const { svg } = await mermaid.render(diagramId, content, mermaidRef.current)
|
||||
|
||||
// 没有语法错误时清除错误记录和定时器
|
||||
setError(null)
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
// 延迟显示错误
|
||||
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = setTimeout(() => {
|
||||
// 避免不可见时产生 undefined 和 NaN
|
||||
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
|
||||
mermaidRef.current.innerHTML = fixedSvg
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message)
|
||||
}, 500)
|
||||
}
|
||||
}, [children, diagramId, mermaid])
|
||||
|
||||
// 渲染Mermaid图表
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
|
||||
startTransition(render)
|
||||
|
||||
// 清理定时器
|
||||
return () => {
|
||||
if (errorTimeoutRef.current) {
|
||||
clearTimeout(errorTimeoutRef.current)
|
||||
errorTimeoutRef.current = null
|
||||
} finally {
|
||||
setIsRendering(false)
|
||||
}
|
||||
},
|
||||
[diagramId, mermaid]
|
||||
)
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderMermaid(content))
|
||||
}, 300),
|
||||
[renderMermaid]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (isLoadingMermaid) return
|
||||
|
||||
if (children) {
|
||||
setIsRendering(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsRendering(false)
|
||||
}
|
||||
}, [isLoading, render])
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, isLoadingMermaid, debouncedRender])
|
||||
|
||||
const isLoading = isLoadingMermaid || isRendering
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { Spin } from 'antd'
|
||||
import pako from 'pako'
|
||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
||||
@@ -134,9 +134,10 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
|
||||
|
||||
interface PlantUMLProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -165,6 +166,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload: customDownload
|
||||
|
||||
@@ -1,38 +1,64 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const SvgPreview: React.FC<Props> = ({ children }) => {
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = svgContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' })
|
||||
|
||||
// 添加基础样式
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host {
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
|
||||
// 清空并重新添加内容
|
||||
shadowRoot.innerHTML = ''
|
||||
shadowRoot.appendChild(style)
|
||||
|
||||
const svgContainer = document.createElement('div')
|
||||
svgContainer.innerHTML = children
|
||||
shadowRoot.appendChild(svgContainer)
|
||||
}, [children])
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
||||
imgSelector: '.svg-preview svg',
|
||||
imgSelector: 'svg',
|
||||
prefix: 'svg-image'
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
)
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
}
|
||||
|
||||
const SvgPreviewContainer = styled.div`
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
`
|
||||
|
||||
export default memo(SvgPreview)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { CodeToolbar, CodeToolContext, TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
|
||||
import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
@@ -9,7 +9,7 @@ import dayjs from 'dayjs'
|
||||
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled, { css } from 'styled-components'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CodePreview from './CodePreview'
|
||||
import HtmlArtifacts from './HtmlArtifacts'
|
||||
@@ -49,6 +49,9 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [output, setOutput] = useState('')
|
||||
|
||||
const [tools, setTools] = useState<CodeTool[]>([])
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
const isExecutable = useMemo(() => {
|
||||
return codeExecution.enabled && language === 'python'
|
||||
}, [codeExecution.enabled, language])
|
||||
@@ -59,33 +62,17 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
}, [hasSpecialView, viewMode])
|
||||
|
||||
const { updateContext, registerTool, removeTool } = useCodeToolbar()
|
||||
const handleCopySource = useCallback(() => {
|
||||
navigator.clipboard.writeText(children)
|
||||
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
||||
}, [children, t])
|
||||
|
||||
useEffect(() => {
|
||||
updateContext({
|
||||
code: children,
|
||||
language
|
||||
})
|
||||
}, [children, language, updateContext])
|
||||
|
||||
const handleCopySource = useCallback(
|
||||
(ctx?: CodeToolContext) => {
|
||||
if (!ctx) return
|
||||
navigator.clipboard.writeText(ctx.code)
|
||||
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const handleDownloadSource = useCallback((ctx?: CodeToolContext) => {
|
||||
if (!ctx) return
|
||||
|
||||
const { code, language } = ctx
|
||||
const handleDownloadSource = useCallback(() => {
|
||||
let fileName = ''
|
||||
|
||||
// 尝试提取标题
|
||||
if (language === 'html' && code.includes('</html>')) {
|
||||
const title = extractTitle(code)
|
||||
if (language === 'html' && children.includes('</html>')) {
|
||||
const title = extractTitle(children)
|
||||
if (title) {
|
||||
fileName = `${title}.html`
|
||||
}
|
||||
@@ -96,31 +83,26 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
|
||||
}
|
||||
|
||||
window.api.file.save(fileName, code)
|
||||
}, [])
|
||||
window.api.file.save(fileName, children)
|
||||
}, [children, language])
|
||||
|
||||
const handleRunScript = useCallback(
|
||||
(ctx?: CodeToolContext) => {
|
||||
if (!ctx) return
|
||||
const handleRunScript = useCallback(() => {
|
||||
setIsRunning(true)
|
||||
setOutput('')
|
||||
|
||||
setIsRunning(true)
|
||||
setOutput('')
|
||||
|
||||
pyodideService
|
||||
.runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000)
|
||||
.then((formattedOutput) => {
|
||||
setOutput(formattedOutput)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error)
|
||||
setOutput(`Unexpected error: ${error.message || 'Unknown error'}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRunning(false)
|
||||
})
|
||||
},
|
||||
[codeExecution.timeoutMinutes]
|
||||
)
|
||||
pyodideService
|
||||
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
|
||||
.then((formattedOutput) => {
|
||||
setOutput(formattedOutput)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error)
|
||||
setOutput(`Unexpected error: ${error.message || 'Unknown error'}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRunning(false)
|
||||
})
|
||||
}, [children, codeExecution.timeoutMinutes])
|
||||
|
||||
useEffect(() => {
|
||||
// 复制按钮
|
||||
@@ -191,7 +173,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
...TOOL_SPECS.run,
|
||||
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
|
||||
tooltip: t('code_block.run'),
|
||||
onClick: (ctx) => !isRunning && handleRunScript(ctx)
|
||||
onClick: () => !isRunning && handleRunScript()
|
||||
})
|
||||
|
||||
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
|
||||
@@ -199,22 +181,33 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
|
||||
// 源代码视图组件
|
||||
const sourceView = useMemo(() => {
|
||||
const SourceView = codeEditor.enabled ? CodeEditor : CodePreview
|
||||
return (
|
||||
<SourceView language={language} onSave={onSave}>
|
||||
{children}
|
||||
</SourceView>
|
||||
)
|
||||
}, [children, codeEditor.enabled, language, onSave])
|
||||
if (codeEditor.enabled) {
|
||||
return (
|
||||
<CodeEditor
|
||||
value={children}
|
||||
language={language}
|
||||
onSave={onSave}
|
||||
options={{ stream: true }}
|
||||
setTools={setTools}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<CodePreview language={language} setTools={setTools}>
|
||||
{children}
|
||||
</CodePreview>
|
||||
)
|
||||
}
|
||||
}, [children, codeEditor.enabled, language, onSave, setTools])
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidPreview>{children}</MermaidPreview>
|
||||
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
|
||||
} else if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUmlPreview>{children}</PlantUmlPreview>
|
||||
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
|
||||
} else if (language === 'svg') {
|
||||
return <SvgPreview>{children}</SvgPreview>
|
||||
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
@@ -247,7 +240,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
<CodeToolbar />
|
||||
<CodeToolbar tools={tools} />
|
||||
{renderContent}
|
||||
{renderArtifacts}
|
||||
{isExecutable && output && <StatusBar>{output}</StatusBar>}
|
||||
@@ -257,8 +250,11 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')};
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
@@ -272,52 +268,27 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$isInSpecialView &&
|
||||
css`
|
||||
.code-toolbar {
|
||||
margin-top: 20px;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
!props.$isInSpecialView &&
|
||||
css`
|
||||
.code-toolbar {
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
|
||||
${(props) =>
|
||||
props.$isInSpecialView &&
|
||||
css`
|
||||
height: 16px;
|
||||
`}
|
||||
margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')};
|
||||
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
|
||||
`
|
||||
|
||||
const SplitViewWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
65
src/renderer/src/components/CodeEditor/hook.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { Extension } from '@uiw/react-codemirror'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
let linterPromise: Promise<any> | null = null
|
||||
function importLintPackage() {
|
||||
if (!linterPromise) {
|
||||
linterPromise = import('@codemirror/lint').then((mod) => mod.linter)
|
||||
}
|
||||
return linterPromise
|
||||
}
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
json: async () => {
|
||||
const [linter, jsonParseLinter] = await Promise.all([
|
||||
importLintPackage(),
|
||||
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
|
||||
])
|
||||
return linter(jsonParseLinter())
|
||||
}
|
||||
}
|
||||
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
// 加载语言
|
||||
useEffect(() => {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
import('@uiw/codemirror-extensions-langs')
|
||||
.then(({ loadLanguage }) => {
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
if (extension) {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`Failed to load language: ${normalizedLang}`, error)
|
||||
})
|
||||
}, [language, languageMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lint) return
|
||||
|
||||
const loader = linterLoaders[language]
|
||||
if (loader) {
|
||||
loader()
|
||||
.then((extension) => {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load linter for ${language}`, error)
|
||||
})
|
||||
}
|
||||
}, [language, lint])
|
||||
|
||||
return extensions
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror'
|
||||
@@ -14,17 +14,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useLanguageExtensions } from './hook'
|
||||
|
||||
// 标记非用户编辑的变更
|
||||
const External = Annotation.define<boolean>()
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
value: string
|
||||
placeholder?: string | HTMLElement
|
||||
language: string
|
||||
onSave?: (newContent: string) => void
|
||||
onChange?: (newContent: string) => void
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
minHeight?: string
|
||||
maxHeight?: string
|
||||
/** 用于覆写编辑器的某些设置 */
|
||||
options?: {
|
||||
stream?: boolean // 用于流式响应场景,默认 false
|
||||
lint?: boolean
|
||||
collapsible?: boolean
|
||||
wrappable?: boolean
|
||||
keymap?: boolean
|
||||
@@ -36,11 +43,23 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* 源代码编辑器,基于 CodeMirror
|
||||
* 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。
|
||||
*
|
||||
* 目前必须和 CodeToolbar 配合使用。
|
||||
*/
|
||||
const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, extensions, style }: Props) => {
|
||||
const CodeEditor = ({
|
||||
value,
|
||||
placeholder,
|
||||
language,
|
||||
onSave,
|
||||
onChange,
|
||||
setTools,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
options,
|
||||
extensions,
|
||||
style
|
||||
}: Props) => {
|
||||
const {
|
||||
fontSize,
|
||||
codeShowLineNumbers: _lineNumbers,
|
||||
@@ -61,37 +80,17 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
||||
}
|
||||
}, [codeEditor, _lineNumbers, options])
|
||||
|
||||
const { activeCmTheme, languageMap } = useCodeStyle()
|
||||
const { activeCmTheme } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
|
||||
const initialContent = useRef(children?.trimEnd() ?? '')
|
||||
const [langExtension, setLangExtension] = useState<Extension[]>([])
|
||||
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
||||
const [editorReady, setEditorReady] = useState(false)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { registerTool, removeTool } = useCodeToolbar()
|
||||
const langExtensions = useLanguageExtensions(language, options?.lint)
|
||||
|
||||
// 加载语言
|
||||
useEffect(() => {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
import('@uiw/codemirror-extensions-langs')
|
||||
.then(({ loadLanguage }) => {
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
if (extension) {
|
||||
setLangExtension([extension])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`Failed to load language: ${normalizedLang}`, error)
|
||||
})
|
||||
}, [language, languageMap])
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
// 展开/折叠工具
|
||||
useEffect(() => {
|
||||
@@ -144,7 +143,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
||||
useEffect(() => {
|
||||
if (!editorViewRef.current) return
|
||||
|
||||
const newContent = children?.trimEnd() ?? ''
|
||||
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
|
||||
const currentDoc = editorViewRef.current.state.doc.toString()
|
||||
|
||||
const changes = prepareCodeChanges(currentDoc, newContent)
|
||||
@@ -155,7 +154,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
||||
annotations: [External.of(true)]
|
||||
})
|
||||
}
|
||||
}, [children])
|
||||
}, [options?.stream, value])
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(!collapsible)
|
||||
@@ -182,17 +181,19 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
||||
const customExtensions = useMemo(() => {
|
||||
return [
|
||||
...(extensions ?? []),
|
||||
...langExtension,
|
||||
...langExtensions,
|
||||
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
|
||||
...(enableKeymap ? [saveKeymap] : [])
|
||||
]
|
||||
}, [extensions, langExtension, isUnwrapped, enableKeymap, saveKeymap])
|
||||
}, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap])
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
// 维持一个稳定值,避免触发 CodeMirror 重置
|
||||
value={initialContent.current}
|
||||
placeholder={placeholder}
|
||||
width="100%"
|
||||
minHeight={minHeight}
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
editable={true}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
@@ -223,13 +224,10 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
overflow: collapsible && !isExpanded ? 'auto' : 'visible',
|
||||
position: 'relative',
|
||||
border: '0.5px solid transparent',
|
||||
borderRadius: '5px',
|
||||
marginTop: 0,
|
||||
...style
|
||||
marginTop: 0
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
||||
copy: {
|
||||
id: 'copy',
|
||||
type: 'core',
|
||||
order: 10
|
||||
order: 11
|
||||
},
|
||||
download: {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 11
|
||||
order: 10
|
||||
},
|
||||
edit: {
|
||||
id: 'edit',
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import React, { createContext, use, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CodeTool, CodeToolContext } from './types'
|
||||
|
||||
// 定义上下文默认值
|
||||
const defaultContext: CodeToolContext = {
|
||||
code: '',
|
||||
language: ''
|
||||
}
|
||||
|
||||
export interface CodeToolbarContextType {
|
||||
tools: CodeTool[]
|
||||
context: CodeToolContext
|
||||
registerTool: (tool: CodeTool) => void
|
||||
removeTool: (id: string) => void
|
||||
updateContext: (newContext: Partial<CodeToolContext>) => void
|
||||
}
|
||||
|
||||
const defaultCodeToolbarContext: CodeToolbarContextType = {
|
||||
tools: [],
|
||||
context: defaultContext,
|
||||
registerTool: () => {},
|
||||
removeTool: () => {},
|
||||
updateContext: () => {}
|
||||
}
|
||||
|
||||
const CodeToolbarContext = createContext<CodeToolbarContextType>(defaultCodeToolbarContext)
|
||||
|
||||
export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [tools, setTools] = useState<CodeTool[]>([])
|
||||
const [context, setContext] = useState<CodeToolContext>(defaultContext)
|
||||
|
||||
// 注册工具,如果已存在同ID工具则替换
|
||||
const registerTool = useCallback((tool: CodeTool) => {
|
||||
setTools((prev) => {
|
||||
const filtered = prev.filter((t) => t.id !== tool.id)
|
||||
return [...filtered, tool].sort((a, b) => b.order - a.order)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 移除工具
|
||||
const removeTool = useCallback((id: string) => {
|
||||
setTools((prev) => prev.filter((tool) => tool.id !== id))
|
||||
}, [])
|
||||
|
||||
// 更新上下文
|
||||
const updateContext = useCallback((newContext: Partial<CodeToolContext>) => {
|
||||
setContext((prev) => ({ ...prev, ...newContext }))
|
||||
}, [])
|
||||
|
||||
const value: CodeToolbarContextType = useMemo(
|
||||
() => ({
|
||||
tools,
|
||||
context,
|
||||
registerTool,
|
||||
removeTool,
|
||||
updateContext
|
||||
}),
|
||||
[tools, context, registerTool, removeTool, updateContext]
|
||||
)
|
||||
|
||||
return <CodeToolbarContext value={value}>{children}</CodeToolbarContext>
|
||||
}
|
||||
|
||||
export const useCodeToolbar = () => {
|
||||
const context = use(CodeToolbarContext)
|
||||
if (!context) {
|
||||
throw new Error('useCodeToolbar must be used within a CodeToolbarProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
26
src/renderer/src/components/CodeToolbar/hook.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { CodeTool } from './types'
|
||||
|
||||
export const useCodeTool = (setTools?: (value: React.SetStateAction<CodeTool[]>) => void) => {
|
||||
// 注册工具,如果已存在同ID工具则替换
|
||||
const registerTool = useCallback(
|
||||
(tool: CodeTool) => {
|
||||
setTools?.((prev) => {
|
||||
const filtered = prev.filter((t) => t.id !== tool.id)
|
||||
return [...filtered, tool].sort((a, b) => b.order - a.order)
|
||||
})
|
||||
},
|
||||
[setTools]
|
||||
)
|
||||
|
||||
// 移除工具
|
||||
const removeTool = useCallback(
|
||||
(id: string) => {
|
||||
setTools?.((prev) => prev.filter((tool) => tool.id !== id))
|
||||
},
|
||||
[setTools]
|
||||
)
|
||||
|
||||
return { registerTool, removeTool }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './constants'
|
||||
export * from './context'
|
||||
export * from './hook'
|
||||
export * from './toolbar'
|
||||
export * from './types'
|
||||
export * from './usePreviewTools'
|
||||
|
||||
@@ -5,7 +5,6 @@ import React, { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useCodeToolbar } from './context'
|
||||
import { CodeTool } from './types'
|
||||
|
||||
interface CodeToolButtonProps {
|
||||
@@ -13,22 +12,19 @@ interface CodeToolButtonProps {
|
||||
}
|
||||
|
||||
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
|
||||
const { context } = useCodeToolbar()
|
||||
|
||||
return (
|
||||
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper>
|
||||
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => tool.onClick()}>{tool.icon}</ToolWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
export const CodeToolbar: React.FC = memo(() => {
|
||||
const { tools, context } = useCodeToolbar()
|
||||
export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) => {
|
||||
const [showQuickTools, setShowQuickTools] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 根据条件显示工具
|
||||
const visibleTools = tools.filter((tool) => !tool.visible || tool.visible(context))
|
||||
const visibleTools = tools.filter((tool) => !tool.visible || tool.visible())
|
||||
|
||||
// 按类型分组
|
||||
const coreTools = visibleTools.filter((tool) => tool.type === 'core')
|
||||
|
||||
@@ -20,16 +20,6 @@ export interface CodeToolSpec {
|
||||
export interface CodeTool extends CodeToolSpec {
|
||||
icon: React.ReactNode
|
||||
tooltip: string
|
||||
visible?: (ctx?: CodeToolContext) => boolean
|
||||
onClick: (ctx?: CodeToolContext) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具上下文接口
|
||||
* @param code 代码内容
|
||||
* @param language 语言类型
|
||||
*/
|
||||
export interface CodeToolContext {
|
||||
code: string
|
||||
language: string
|
||||
visible?: () => boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
|
||||
import { TOOL_SPECS } from './constants'
|
||||
import { useCodeToolbar } from './context'
|
||||
import { useCodeTool } from './hook'
|
||||
import { CodeTool } from './types'
|
||||
|
||||
// 预编译正则表达式用于查询位置
|
||||
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
|
||||
@@ -31,6 +32,14 @@ export const usePreviewToolHandlers = (
|
||||
// 创建选择器函数
|
||||
const getImgElement = useCallback(() => {
|
||||
if (!containerRef.current) return null
|
||||
|
||||
// 优先尝试从 Shadow DOM 中查找
|
||||
const shadowRoot = containerRef.current.shadowRoot
|
||||
if (shadowRoot) {
|
||||
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
||||
}
|
||||
|
||||
// 降级到常规 DOM 查找
|
||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||
}, [containerRef, imgSelector])
|
||||
|
||||
@@ -272,6 +281,7 @@ export const usePreviewToolHandlers = (
|
||||
}
|
||||
|
||||
export interface PreviewToolsOptions {
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
handleZoom?: (delta: number) => void
|
||||
handleCopyImage?: () => Promise<void>
|
||||
handleDownload?: (format: 'svg' | 'png') => void
|
||||
@@ -280,19 +290,9 @@ export interface PreviewToolsOptions {
|
||||
/**
|
||||
* 提供预览组件通用工具栏功能的自定义Hook
|
||||
*/
|
||||
export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
|
||||
export const usePreviewTools = ({ setTools, handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useCodeToolbar()
|
||||
|
||||
const toolIds = useCallback(() => {
|
||||
return {
|
||||
zoomIn: 'preview-zoom-in',
|
||||
zoomOut: 'preview-zoom-out',
|
||||
copyImage: 'preview-copy-image',
|
||||
downloadSvg: 'preview-download-svg',
|
||||
downloadPng: 'preview-download-png'
|
||||
}
|
||||
}, [])
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
useEffect(() => {
|
||||
// 根据提供的功能有选择性地注册工具
|
||||
@@ -356,5 +356,5 @@ export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }:
|
||||
removeTool(TOOL_SPECS['download-png'].id)
|
||||
}
|
||||
}
|
||||
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t, toolIds])
|
||||
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t])
|
||||
}
|
||||
|
||||
@@ -399,6 +399,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const implementation = {
|
||||
disable() {
|
||||
setEnableContentSearch(false)
|
||||
@@ -526,8 +527,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
implementation.search()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch]) // Add enableContentSearch dependency
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency
|
||||
|
||||
const prevButtonOnClick = () => {
|
||||
implementation.searchPrev()
|
||||
@@ -558,7 +558,13 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<SearchBarContainer>
|
||||
<InputWrapper>
|
||||
<Input ref={searchInputRef} onInput={userInputHandler} onKeyDown={keyDownHandler} />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
onInput={userInputHandler}
|
||||
onKeyDown={keyDownHandler}
|
||||
placeholder={t('chat.assistant.search.placeholder')}
|
||||
style={{ lineHeight: '20px' }}
|
||||
/>
|
||||
<ToolBar>
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
@@ -624,17 +630,19 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const SearchBarContainer = styled.div`
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
margin: 5px 20px;
|
||||
margin-bottom: 0;
|
||||
padding: 6px 15px 8px;
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
margin-bottom: 5px;
|
||||
padding: 5px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-opacity);
|
||||
background-color: var(--color-background);
|
||||
flex: 1 1 auto; /* Take up input's previous space */
|
||||
`
|
||||
|
||||
@@ -682,18 +690,18 @@ const SearchResults = styled.div`
|
||||
width: 80px;
|
||||
margin: 0 2px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-1);
|
||||
font-size: 14px;
|
||||
font-family: Ubuntu;
|
||||
`
|
||||
|
||||
const SearchResultsPlaceholder = styled.span`
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-1);
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const NoResults = styled.span`
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const SearchResultCount = styled.span`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Dropdown } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: React.ReactNode
|
||||
@@ -11,7 +11,6 @@ interface ContextMenuProps {
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
||||
const { t } = useTranslation()
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
@@ -19,12 +18,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
@@ -44,7 +37,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
}, [])
|
||||
|
||||
// 获取右键菜单项
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
@@ -65,27 +58,29 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedQuoteText) {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
if (selectedText) {
|
||||
window.api?.quoteToMainWindow(selectedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu} style={{ width: '100%' }}>
|
||||
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container">
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
menu={{ items: getContextMenuItems(t, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
</Dropdown>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</ContextContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextContainer = styled.div``
|
||||
|
||||
export default ContextMenu
|
||||
|
||||
83
src/renderer/src/components/CopyButton.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Tooltip } from 'antd'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CopyButtonProps {
|
||||
tooltip?: string
|
||||
textToCopy: string
|
||||
label?: string
|
||||
color?: string
|
||||
hoverColor?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ButtonContainerProps {
|
||||
$color: string
|
||||
$hoverColor: string
|
||||
}
|
||||
|
||||
const CopyButton: FC<CopyButtonProps> = ({
|
||||
tooltip,
|
||||
textToCopy,
|
||||
label,
|
||||
color = 'var(--color-text-2)',
|
||||
hoverColor = 'var(--color-primary)',
|
||||
size = 14
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard
|
||||
.writeText(textToCopy)
|
||||
.then(() => {
|
||||
window.message?.success(t('message.copy.success'))
|
||||
})
|
||||
.catch(() => {
|
||||
window.message?.error(t('message.copy.failed'))
|
||||
})
|
||||
}
|
||||
|
||||
const button = (
|
||||
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
|
||||
<Copy size={size} className="copy-icon" />
|
||||
{label && <RightText size={size}>{label}</RightText>}
|
||||
</ButtonContainer>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return <Tooltip title={tooltip}>{button}</Tooltip>
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
const ButtonContainer = styled.div<ButtonContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$color};
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
|
||||
.copy-icon {
|
||||
color: ${(props) => props.$hoverColor};
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RightText = styled.span<{ size: number }>`
|
||||
font-size: ${(props) => props.size}px;
|
||||
`
|
||||
|
||||
export default CopyButton
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Collapse } from 'antd'
|
||||
import { merge } from 'lodash'
|
||||
import { FC, memo } from 'react'
|
||||
import { FC, memo, useMemo, useState } from 'react'
|
||||
|
||||
interface CustomCollapseProps {
|
||||
label: React.ReactNode
|
||||
@@ -28,28 +28,46 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
style,
|
||||
styles
|
||||
}) => {
|
||||
const [activeKeys, setActiveKeys] = useState(activeKey || defaultActiveKey)
|
||||
|
||||
const defaultCollapseStyle = {
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: '0.5px solid var(--color-border)'
|
||||
}
|
||||
|
||||
const defaultCollpaseHeaderStyle = {
|
||||
padding: '3px 16px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--color-background-soft)'
|
||||
}
|
||||
|
||||
const getHeaderStyle = () => {
|
||||
return activeKeys && activeKeys.length > 0
|
||||
? {
|
||||
...defaultCollpaseHeaderStyle,
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
}
|
||||
: {
|
||||
...defaultCollpaseHeaderStyle,
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCollapseItemStyles = {
|
||||
header: {
|
||||
padding: '3px 16px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--color-background-soft)',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
},
|
||||
header: getHeaderStyle(),
|
||||
body: {
|
||||
borderTop: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const collapseStyle = merge({}, defaultCollapseStyle, style)
|
||||
const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles)
|
||||
const collapseItemStyles = useMemo(() => {
|
||||
return merge({}, defaultCollapseItemStyles, styles)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeKeys])
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
@@ -59,6 +77,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
activeKey={activeKey}
|
||||
destroyInactivePanel={destroyInactivePanel}
|
||||
collapsible={collapsible}
|
||||
onChange={setActiveKeys}
|
||||
items={[
|
||||
{
|
||||
styles: collapseItemStyles,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||