Compare commits
461 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc1f4b640 | ||
|
|
ccb216e76a | ||
|
|
60931b85ff | ||
|
|
dc1dbc7bb6 | ||
|
|
5d2efbd62b | ||
|
|
5337017648 | ||
|
|
c409256ae9 | ||
|
|
4ac608052c | ||
|
|
5e6aaabb23 | ||
|
|
8812daeeee | ||
|
|
13e3a8478c | ||
|
|
8687985ccb | ||
|
|
7d54f9b4fa | ||
|
|
6b7ba35183 | ||
|
|
5b42a6d054 | ||
|
|
153e7a9299 | ||
|
|
77e0c5172e | ||
|
|
c50ac440c8 | ||
|
|
34ebab0af8 | ||
|
|
b85765915e | ||
|
|
960f50e4e4 | ||
|
|
65e19d187c | ||
|
|
aa4f94f8a4 | ||
|
|
aa3812eddc | ||
|
|
6b9e58171b | ||
|
|
2f64653b1e | ||
|
|
03dd3038e0 | ||
|
|
f1f7e8e11b | ||
|
|
fbd189c5e1 | ||
|
|
87c3716f75 | ||
|
|
37477587b6 | ||
|
|
d558572d97 | ||
|
|
7506d04c55 | ||
|
|
35fd5aef22 | ||
|
|
8f11d2b1c9 | ||
|
|
9aa2a4727d | ||
|
|
ca6027dd83 | ||
|
|
c2462fd51c | ||
|
|
0739758469 | ||
|
|
b2554333a9 | ||
|
|
6ced973b35 | ||
|
|
ccbeefc546 | ||
|
|
7fdc2db522 | ||
|
|
978f1342e4 | ||
|
|
ff935a656e | ||
|
|
15539a5609 | ||
|
|
88cd4f2144 | ||
|
|
daf2e035b2 | ||
|
|
7ceb4920ec | ||
|
|
0074d5c8b4 | ||
|
|
96737ed695 | ||
|
|
356da1ea67 | ||
|
|
debf996146 | ||
|
|
8d73d1e844 | ||
|
|
b0d777293b | ||
|
|
1a9fbbc0a2 | ||
|
|
ab99a7b96d | ||
|
|
7d561dbfb7 | ||
|
|
6af07c278d | ||
|
|
9c18b851cc | ||
|
|
b1ebe13b5f | ||
|
|
9b258734c4 | ||
|
|
25eb97902b | ||
|
|
2fae6e4a3e | ||
|
|
f312c5fc40 | ||
|
|
afa96549a3 | ||
|
|
6beee78ce8 | ||
|
|
a230ee2c69 | ||
|
|
28a27447a5 | ||
|
|
408976e5dc | ||
|
|
7153996c35 | ||
|
|
73f6a743cd | ||
|
|
3b250d7d78 | ||
|
|
272efaf76e | ||
|
|
44c64a571a | ||
|
|
f817d9136b | ||
|
|
c0f192c6f2 | ||
|
|
b5a109401c | ||
|
|
aeff59946c | ||
|
|
21ad4cfecc | ||
|
|
4df39179bb | ||
|
|
423fdb6992 | ||
|
|
f66adcd217 | ||
|
|
465bf4006c | ||
|
|
14c9cb6001 | ||
|
|
e35d928bcd | ||
|
|
1981f2e648 | ||
|
|
e5c1791135 | ||
|
|
ae1960f5c6 | ||
|
|
51ca9cb289 | ||
|
|
7d2df1a8c5 | ||
|
|
2757535cf0 | ||
|
|
243065221d | ||
|
|
2a674c169e | ||
|
|
100dbc8101 | ||
|
|
67d7ccbf10 | ||
|
|
a71782abb6 | ||
|
|
73973ecb7f | ||
|
|
368de84440 | ||
|
|
a170dbd6f0 | ||
|
|
9b84176a42 | ||
|
|
0f36610e23 | ||
|
|
1e273834b8 | ||
|
|
3b569131a5 | ||
|
|
115f111071 | ||
|
|
a4d1bcffd9 | ||
|
|
f5d37a4e53 | ||
|
|
a9d4a0885c | ||
|
|
6596497c97 | ||
|
|
12d8f57dab | ||
|
|
7f2f3ad88a | ||
|
|
cd3c053f81 | ||
|
|
7dacd58821 | ||
|
|
744a6ac7cb | ||
|
|
2e9041c891 | ||
|
|
3717ff25bf | ||
|
|
494d52ac85 | ||
|
|
22d2ff1518 | ||
|
|
06ae4328ea | ||
|
|
8de1197557 | ||
|
|
09e86b35a5 | ||
|
|
76ea170a01 | ||
|
|
4cd962b42f | ||
|
|
1cae86f93d | ||
|
|
1171100417 | ||
|
|
dcf57651fe | ||
|
|
603b867a5f | ||
|
|
e765bf9828 | ||
|
|
33d5da7325 | ||
|
|
383e8255a0 | ||
|
|
4cb5c128bb | ||
|
|
949a13b021 | ||
|
|
e6c9cb60dc | ||
|
|
ad625b23a7 | ||
|
|
2e34b79f26 | ||
|
|
9288e7b292 | ||
|
|
ec703852f8 | ||
|
|
fca9fb0c84 | ||
|
|
64c8831530 | ||
|
|
75e396ecf0 | ||
|
|
697c3b1838 | ||
|
|
efa0f4cbdb | ||
|
|
c3414e9b6d | ||
|
|
6ba6108f43 | ||
|
|
c3d007b52c | ||
|
|
e1494d408f | ||
|
|
cd625430b2 | ||
|
|
aefb08965d | ||
|
|
d2dd70000b | ||
|
|
f0a96bb34c | ||
|
|
0ec61e1c47 | ||
|
|
335ce4963b | ||
|
|
63ef0d2df1 | ||
|
|
c0c0e8ae33 | ||
|
|
771b078df9 | ||
|
|
64e70ea918 | ||
|
|
2d46a4494e | ||
|
|
9b7e2282fe | ||
|
|
535b7d0a92 | ||
|
|
223496192d | ||
|
|
db779446f0 | ||
|
|
8ef3ef2a8f | ||
|
|
30da183578 | ||
|
|
49c09f381c | ||
|
|
c8c58ddcfb | ||
|
|
5bffb86d4f | ||
|
|
84fa5b065b | ||
|
|
7ecb35dfa7 | ||
|
|
2d7d403b15 | ||
|
|
7342a0afef | ||
|
|
1b8a3885f7 | ||
|
|
c33c0b20f2 | ||
|
|
4f75f29361 | ||
|
|
fe00eed7b9 | ||
|
|
30fa9277ff | ||
|
|
11a446e106 | ||
|
|
d2ca6f1041 | ||
|
|
0f3dc87d08 | ||
|
|
7d3cae1f5b | ||
|
|
ceae1fa3d0 | ||
|
|
12a2c8c86d | ||
|
|
29d6c4be18 | ||
|
|
738e51c078 | ||
|
|
db050c002a | ||
|
|
398f995cd1 | ||
|
|
348fc365fa | ||
|
|
7b6d38e349 | ||
|
|
6a35c0e3d8 | ||
|
|
9a63169a73 | ||
|
|
a9aa5a8da0 | ||
|
|
a2d568175b | ||
|
|
0b9717780d | ||
|
|
b371fed814 | ||
|
|
3311f8cdef | ||
|
|
422baa848b | ||
|
|
739aa21475 | ||
|
|
23ef4ab952 | ||
|
|
b77f845cb0 | ||
|
|
0573b274ed | ||
|
|
60433bb1ab | ||
|
|
1caf53fbda | ||
|
|
446e011c6a | ||
|
|
c319b54a26 | ||
|
|
e9ca1d54a0 | ||
|
|
1ff8fe0c2e | ||
|
|
902341bc1d | ||
|
|
17fff46024 | ||
|
|
d258c1cfe2 | ||
|
|
79cabadfb8 | ||
|
|
61ceca2363 | ||
|
|
8a8deda002 | ||
|
|
6536ec227a | ||
|
|
af1fd90118 | ||
|
|
68fa2bad15 | ||
|
|
bac3bad8db | ||
|
|
e11633310c | ||
|
|
612b39a878 | ||
|
|
8491141edc | ||
|
|
088628f89f | ||
|
|
a6b4e48640 | ||
|
|
ba0e2c5848 | ||
|
|
d986087857 | ||
|
|
73c93c5581 | ||
|
|
ceec4a9f97 | ||
|
|
cf08467552 | ||
|
|
34c85e8f0c | ||
|
|
1db3faa2a8 | ||
|
|
35efada37e | ||
|
|
ceca3408ff | ||
|
|
f2def559d4 | ||
|
|
cd97be0f10 | ||
|
|
b87394ed88 | ||
|
|
a4d8e71916 | ||
|
|
39cf227e42 | ||
|
|
5d01d12d2a | ||
|
|
d2cad31db4 | ||
|
|
d7f4e4584a | ||
|
|
eda870f181 | ||
|
|
3f093a91be | ||
|
|
aa864f3876 | ||
|
|
77a8b23d76 | ||
|
|
45dd76e281 | ||
|
|
568d4814e3 | ||
|
|
9468f3b511 | ||
|
|
04af940144 | ||
|
|
e33d9ac0ae | ||
|
|
cd835b7c36 | ||
|
|
dd4239da87 | ||
|
|
41c3895da4 | ||
|
|
2e9c7d0830 | ||
|
|
8ea73e14c9 | ||
|
|
3791556b13 | ||
|
|
e0dab5cf5b | ||
|
|
1785e7df0a | ||
|
|
6cb1846b23 | ||
|
|
21243579b3 | ||
|
|
0d2ad2e4c3 | ||
|
|
071a3950cd | ||
|
|
dc6066b74c | ||
|
|
ce55d8d0e7 | ||
|
|
d4ae321cd2 | ||
|
|
89dd35c98d | ||
|
|
b8c70a3061 | ||
|
|
968a749aaa | ||
|
|
e2fc593624 | ||
|
|
0e1674ce6c | ||
|
|
18566989be | ||
|
|
31fa10f185 | ||
|
|
f6aa0dc55a | ||
|
|
ca2a9ed84a | ||
|
|
79f6d598ab | ||
|
|
fb564733e4 | ||
|
|
63e5972dd2 | ||
|
|
b80270709f | ||
|
|
d7b459dcee | ||
|
|
76b9e1a65e | ||
|
|
b148c5adf5 | ||
|
|
2313f66ad9 | ||
|
|
02edd983d1 | ||
|
|
3e049baaa4 | ||
|
|
7401d85825 | ||
|
|
241dcddfed | ||
|
|
cd0ea8154d | ||
|
|
6d6788eeb2 | ||
|
|
9ac35ae3d8 | ||
|
|
72e847258d | ||
|
|
0cc460a4a3 | ||
|
|
98307d5d85 | ||
|
|
f73749ac63 | ||
|
|
c5deba270f | ||
|
|
bf5617393b | ||
|
|
057efbf98c | ||
|
|
2143a6614e | ||
|
|
6f9eb2ae75 | ||
|
|
73c2945961 | ||
|
|
18beffcc29 | ||
|
|
2b17319855 | ||
|
|
d77c1ce2b4 | ||
|
|
b43f5c9ead | ||
|
|
a8651ec558 | ||
|
|
d76a173706 | ||
|
|
7ec3cb05f2 | ||
|
|
a83d514169 | ||
|
|
1f8551135f | ||
|
|
1444739cc6 | ||
|
|
c7cbecad68 | ||
|
|
ab1c597e1c | ||
|
|
ac21c90b6f | ||
|
|
9ec0836d26 | ||
|
|
ee966010e1 | ||
|
|
7c99621558 | ||
|
|
cfb3eb7d90 | ||
|
|
64ad2fc9f4 | ||
|
|
7f0909c796 | ||
|
|
27631d9cff | ||
|
|
596cf8e3f2 | ||
|
|
6e2ab66b81 | ||
|
|
2cbb4c8831 | ||
|
|
5347f63aa8 | ||
|
|
077a66c675 | ||
|
|
6e7b6d8387 | ||
|
|
bdf6df1936 | ||
|
|
a2dd440f77 | ||
|
|
b47d6c95e7 | ||
|
|
6265d27ebc | ||
|
|
0dd60cb129 | ||
|
|
04dae10d89 | ||
|
|
71ef0f319f | ||
|
|
58817ae82f | ||
|
|
7e477cb9c7 | ||
|
|
1063610c01 | ||
|
|
927670d3a3 | ||
|
|
2fea7659b1 | ||
|
|
43b9298329 | ||
|
|
fe2e3bfc36 | ||
|
|
bae80fda8d | ||
|
|
ab709b9c61 | ||
|
|
2c28e3bb76 | ||
|
|
d98020e12c | ||
|
|
25addc390f | ||
|
|
88d04a1a6e | ||
|
|
1f582c672d | ||
|
|
c913b2a6d0 | ||
|
|
267c60f24d | ||
|
|
a8ccaf6847 | ||
|
|
a3a005b946 | ||
|
|
2220a6016e | ||
|
|
3197390f1a | ||
|
|
5f04d1adb1 | ||
|
|
76b6593545 | ||
|
|
04ce641bf7 | ||
|
|
31e912aac3 | ||
|
|
832ec99d92 | ||
|
|
ef9fda6d0c | ||
|
|
624230411a | ||
|
|
14808649f8 | ||
|
|
3cc8cfb43b | ||
|
|
4055111ade | ||
|
|
dc98b27e3e | ||
|
|
90fec317e5 | ||
|
|
303a0e20a0 | ||
|
|
d69252a7da | ||
|
|
99f05383cb | ||
|
|
5ba6c9f882 | ||
|
|
27f64409d6 | ||
|
|
7237729ff6 | ||
|
|
d29cd3c657 | ||
|
|
8c87f59822 | ||
|
|
5780141df4 | ||
|
|
f5799ef47b | ||
|
|
6f502049f4 | ||
|
|
c68ad4febb | ||
|
|
2ebcec9f59 | ||
|
|
7bc74a5b86 | ||
|
|
75152421d9 | ||
|
|
3326074076 | ||
|
|
362d82bdcc | ||
|
|
fcce241c82 | ||
|
|
693b06c126 | ||
|
|
c310c71576 | ||
|
|
bea95fc52f | ||
|
|
969cf8ea21 | ||
|
|
5b357f14e5 | ||
|
|
de5db4f805 | ||
|
|
1ccb5edda7 | ||
|
|
97b8749dd1 | ||
|
|
a6d7ecae81 | ||
|
|
938efb5aef | ||
|
|
9baf0f772e | ||
|
|
ff5de3625e | ||
|
|
f1cfdb29f8 | ||
|
|
26e48f07fd | ||
|
|
8bb5fb9811 | ||
|
|
d41667b599 | ||
|
|
85152cbcd7 | ||
|
|
b80863111f | ||
|
|
6cd88fa51d | ||
|
|
3619e8f47b | ||
|
|
5b41dd24d4 | ||
|
|
91dd2f233a | ||
|
|
7e651f9abc | ||
|
|
e44f666c5c | ||
|
|
c254b52b51 | ||
|
|
37c3a4438f | ||
|
|
302d7511dc | ||
|
|
68d57ba238 | ||
|
|
cf98675223 | ||
|
|
4cc140e4f2 | ||
|
|
2da3a3f010 | ||
|
|
fa6f7ecab0 | ||
|
|
31ab444300 | ||
|
|
85453f5a3a | ||
|
|
6d92539524 | ||
|
|
a605ae6043 | ||
|
|
6aaa6bf042 | ||
|
|
aa578194c7 | ||
|
|
220600070c | ||
|
|
32cdfbbfb0 | ||
|
|
33b83bf242 | ||
|
|
2e1b433365 | ||
|
|
2771a842fe | ||
|
|
4af3d16e61 | ||
|
|
eb47fb051b | ||
|
|
0f9655611b | ||
|
|
0c72ccac12 | ||
|
|
09f7fcd2b4 | ||
|
|
b9250df347 | ||
|
|
ca897db0d2 | ||
|
|
af75d4139c | ||
|
|
d2e35a888d | ||
|
|
fb56c3744b | ||
|
|
26942cfd1f | ||
|
|
1601fc6d81 | ||
|
|
f543a9ff80 | ||
|
|
5299a2a687 | ||
|
|
fcc627db6f | ||
|
|
1035019fc2 | ||
|
|
9d311a7261 | ||
|
|
a973c5fb89 | ||
|
|
be081ccf7a | ||
|
|
c25db02acf | ||
|
|
01f98235c6 | ||
|
|
00f3b87215 | ||
|
|
849958eeec | ||
|
|
9655153e01 | ||
|
|
74d5355e02 | ||
|
|
bb137cc799 | ||
|
|
6aee3d8088 | ||
|
|
51cedcb644 | ||
|
|
270c754c00 | ||
|
|
8f68aca24c | ||
|
|
93710c1e78 | ||
|
|
ac2a3fd38e | ||
|
|
750f1cd63d | ||
|
|
4413528d0e | ||
|
|
e8b992c289 | ||
|
|
938ff38aeb | ||
|
|
77cb534e16 | ||
|
|
58513b63a3 | ||
|
|
712e7ff104 | ||
|
|
c68d283766 |
@@ -2,4 +2,4 @@ node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
||||
|
||||
scripts/cloudflare-worker.js
|
||||
|
||||
73
.github/ISSUE_TEMPLATE/#0_bug_report.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: 🐛 错误报告
|
||||
description: 创建一个报告以帮助我们改进
|
||||
title: '[错误]: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写此错误报告!
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 平台
|
||||
description: 您正在使用哪个平台?
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 版本
|
||||
description: 您正在运行的 Cherry Studio 版本是什么?
|
||||
placeholder: 例如 v1.0.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 错误描述
|
||||
description: 清晰简洁地描述错误是什么
|
||||
placeholder: 告诉我们发生了什么...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 重现步骤
|
||||
description: 重现行为的步骤
|
||||
placeholder: |
|
||||
1. 转到 '...'
|
||||
2. 点击 '....'
|
||||
3. 向下滚动到 '....'
|
||||
4. 看到错误
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 清晰简洁地描述您期望发生的事情
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 相关日志输出
|
||||
description: 请复制并粘贴任何相关的日志输出
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 在此添加有关问题的任何其他上下文
|
||||
38
.github/ISSUE_TEMPLATE/#1_feature_request.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: 💡 功能建议
|
||||
description: 为项目提出新的想法
|
||||
title: '[功能]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间提出新的功能建议!
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 您的功能建议是否与某个问题相关?
|
||||
description: 请简明扼要地描述您遇到的问题
|
||||
placeholder: 我总是感到沮丧,因为...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 请描述您希望实现的解决方案
|
||||
description: 请简明扼要地描述您希望发生的情况
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 请描述您考虑过的其他方案
|
||||
description: 请简明扼要地描述您考虑过的任何其他解决方案或功能
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 其他补充信息
|
||||
description: 在此添加任何其他与功能建议相关的上下文或截图
|
||||
44
.github/ISSUE_TEMPLATE/#2_question.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: ❓ 提问
|
||||
description: 提出一个问题或寻求帮助
|
||||
title: '[问题]: '
|
||||
labels: ['question']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您的提问!请尽可能详细地描述您的问题,这样我们才能更好地帮助您。
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: 您的问题
|
||||
description: 请详细描述您的问题
|
||||
placeholder: 请尽可能清楚地说明您的问题...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: 相关背景
|
||||
description: 请提供一些背景信息,帮助我们更好地理解您的问题
|
||||
placeholder: 例如:使用场景、已尝试的解决方案等
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 任何其他相关的信息、截图或代码示例
|
||||
render: shell
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
description: 这个问题对您来说有多紧急?
|
||||
options:
|
||||
- 低 (有空再看)
|
||||
- 中 (希望尽快得到答复)
|
||||
- 高 (阻碍工作进行)
|
||||
validations:
|
||||
required: true
|
||||
73
.github/ISSUE_TEMPLATE/0_bug_report.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Create a report to help us improve
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: What platform are you using?
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Cherry Studio are you running?
|
||||
placeholder: e.g. v1.0.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Tell us what happened...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Log Output
|
||||
description: Please copy and paste any relevant log output
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here
|
||||
38
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: 💡 Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a new feature!
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context or screenshots about the feature request here
|
||||
44
.github/ISSUE_TEMPLATE/2_question.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: ❓ Question
|
||||
description: Ask a question or seek help
|
||||
title: '[Question]: '
|
||||
labels: ['question']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for asking a question! Please provide as much detail as possible so we can better assist you.
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Your Question
|
||||
description: Please describe your question in detail
|
||||
placeholder: Please explain your question as clearly as possible...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: Please provide some background information to help us better understand your question
|
||||
placeholder: "For example: use case, solutions you've tried, etc."
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any other relevant information, screenshots, or code examples
|
||||
render: shell
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How urgent is this question for you?
|
||||
options:
|
||||
- Low (Can wait)
|
||||
- Medium (Would like a response soon)
|
||||
- High (Blocking progress)
|
||||
validations:
|
||||
required: true
|
||||
27
.github/workflows/release.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
@@ -28,18 +29,37 @@ jobs:
|
||||
- name: Install corepack
|
||||
run: corepack enable && corepack prepare yarn@4.3.1 --activate
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache yarn dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
node_modules
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Build Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: yarn build:linux
|
||||
run: |
|
||||
yarn build:npm linux
|
||||
yarn build:linux
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: yarn build:mac
|
||||
run: |
|
||||
yarn build:npm mac
|
||||
yarn build:mac
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
@@ -55,7 +75,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Replace spaces in filenames
|
||||
run: node scripts/replaceSpaces.js
|
||||
run: node scripts/replace-spaces.js
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -71,5 +91,6 @@ jobs:
|
||||
dist/*.rpm
|
||||
dist/*.tar.gz
|
||||
dist/latest*.yml
|
||||
dist/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
8
.gitignore
vendored
@@ -19,12 +19,6 @@ lerna-debug.log*
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# NPM
|
||||
npm/*/*
|
||||
!npm/*/dist
|
||||
!npm/*/package.json
|
||||
!npm/*/*.js
|
||||
|
||||
# Yarn
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@@ -41,6 +35,8 @@ Thumbs.db
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
build/icons
|
||||
stats.html
|
||||
|
||||
# ENV
|
||||
.env
|
||||
|
||||
@@ -5,3 +5,4 @@ LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
CHANGELOG*.md
|
||||
agents.json
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
diff --git a/lib/check-signature.js b/lib/check-signature.js
|
||||
index 324568af71bcc4372c9f959131ecd24122848c86..677348e0a138ff608b2ac41f592d813b15ee4956 100644
|
||||
--- a/lib/check-signature.js
|
||||
+++ b/lib/check-signature.js
|
||||
@@ -41,16 +41,12 @@ const spawn_1 = require("./spawn");
|
||||
const debug_1 = __importDefault(require("debug"));
|
||||
const d = (0, debug_1.default)('electron-notarize');
|
||||
const codesignDisplay = (opts) => __awaiter(void 0, void 0, void 0, function* () {
|
||||
- const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', path.basename(opts.appPath)], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', opts.appPath]);
|
||||
return result;
|
||||
});
|
||||
const codesign = (opts) => __awaiter(void 0, void 0, void 0, function* () {
|
||||
d('attempting to check codesign of app:', opts.appPath);
|
||||
- const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', path.basename(opts.appPath)], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', opts.appPath]);
|
||||
return result;
|
||||
});
|
||||
function checkSignatures(opts) {
|
||||
diff --git a/lib/notarytool.js b/lib/notarytool.js
|
||||
index 1ab090efb2101fc8bee5553445e0349c54474421..a5ddfd922197449fc56078e4a7e9a2ee5d8d207d 100644
|
||||
--- a/lib/notarytool.js
|
||||
+++ b/lib/notarytool.js
|
||||
@@ -92,9 +92,7 @@ function notarizeAndWaitForNotaryTool(opts) {
|
||||
else {
|
||||
filePath = path.resolve(dir, `${path.parse(opts.appPath).name}.zip`);
|
||||
d('zipping application to:', filePath);
|
||||
- const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', path.basename(opts.appPath), filePath], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', opts.appPath, filePath]);
|
||||
if (zipResult.code !== 0) {
|
||||
throw new Error(`Failed to zip application, exited with code: ${zipResult.code}\n\n${zipResult.output}`);
|
||||
}
|
||||
diff --git a/lib/staple.js b/lib/staple.js
|
||||
index 47dbd85b2fc279d999b57f47fb8171e1cc674436..f8829e6ac54fcd630a730d12d75acc1591b953b6 100644
|
||||
--- a/lib/staple.js
|
||||
+++ b/lib/staple.js
|
||||
@@ -43,9 +43,7 @@ const d = (0, debug_1.default)('electron-notarize:staple');
|
||||
function stapleApp(opts) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
d('attempting to staple app:', opts.appPath);
|
||||
- const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', path.basename(opts.appPath)], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', opts.appPath]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Failed to staple your application with code: ${result.code}\n\n${result.output}`);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
diff --git a/src/libsql-db.js b/src/libsql-db.js
|
||||
index 58c42e4910bd0e53bc497ff9b9702b1f7a961266..250bc97c50a9b790e8798441d904d040f2d2af43 100644
|
||||
--- a/src/libsql-db.js
|
||||
+++ b/src/libsql-db.js
|
||||
@@ -41,9 +41,9 @@ export class LibSqlDb {
|
||||
}
|
||||
async similaritySearch(query, k) {
|
||||
const statement = `SELECT id, pageContent, uniqueLoaderId, source, metadata,
|
||||
- vector_distance_cos(vector, vector32('[${query.join(',')}]'))
|
||||
+ vector_distance_cos(vector, vector32('[${query.join(',')}]')) as distance
|
||||
FROM ${this.tableName}
|
||||
- ORDER BY vector_distance_cos(vector, vector32('[${query.join(',')}]')) ASC
|
||||
+ ORDER BY distance ASC
|
||||
LIMIT ${k};`;
|
||||
this.debug(`Executing statement - ${truncateCenterString(statement, 700)}`);
|
||||
const results = await this.client.execute(statement);
|
||||
@@ -52,7 +52,7 @@ export class LibSqlDb {
|
||||
return {
|
||||
metadata,
|
||||
pageContent: result.pageContent.toString(),
|
||||
- score: 1,
|
||||
+ score: 1 - result.distance,
|
||||
};
|
||||
});
|
||||
}
|
||||
17
.yarn/patches/@llm-tools-embedjs-npm-0.1.25-ec5645cf36.patch
Normal file
@@ -0,0 +1,17 @@
|
||||
diff --git a/src/core/rag-embedding.js b/src/core/rag-embedding.js
|
||||
index 50c3c4064af17bc4c7c46554d8f2419b3afceb0e..632c9b2e04d2e0e3bb09ef1cd8f29d2560e6afc1 100644
|
||||
--- a/src/core/rag-embedding.js
|
||||
+++ b/src/core/rag-embedding.js
|
||||
@@ -1,10 +1,8 @@
|
||||
export class RAGEmbedding {
|
||||
static singleton;
|
||||
static async init(embeddingModel) {
|
||||
- if (!this.singleton) {
|
||||
- await embeddingModel.init();
|
||||
- this.singleton = new RAGEmbedding(embeddingModel);
|
||||
- }
|
||||
+ await embeddingModel.init();
|
||||
+ this.singleton = new RAGEmbedding(embeddingModel);
|
||||
}
|
||||
static getInstance() {
|
||||
return RAGEmbedding.singleton;
|
||||
@@ -0,0 +1,54 @@
|
||||
diff --git a/src/util/strings.cjs b/src/util/strings.cjs
|
||||
index 9933cc6e3866c476b47342a29ddb206eb90fa4a5..2965c4f2808bf94af9ef3e2ec889e5552e30e6ae 100644
|
||||
--- a/src/util/strings.cjs
|
||||
+++ b/src/util/strings.cjs
|
||||
@@ -38,13 +38,16 @@ function toTitleCase(str) {
|
||||
});
|
||||
}
|
||||
function isValidURL(url) {
|
||||
- try {
|
||||
- new URL(url);
|
||||
- return true;
|
||||
- }
|
||||
- catch {
|
||||
- return false;
|
||||
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://')) {
|
||||
+ try {
|
||||
+ new URL(url);
|
||||
+ return true;
|
||||
+ }
|
||||
+ catch {
|
||||
+ return false;
|
||||
+ }
|
||||
}
|
||||
+ return false;
|
||||
}
|
||||
function isValidJson(str) {
|
||||
try {
|
||||
diff --git a/src/util/strings.js b/src/util/strings.js
|
||||
index f5c1655512099b880fc5022e95d5e0c4d1d073f2..1a64bd662a22efd2effd9d2846ffcf0b93391963 100644
|
||||
--- a/src/util/strings.js
|
||||
+++ b/src/util/strings.js
|
||||
@@ -29,13 +29,16 @@ export function toTitleCase(str) {
|
||||
});
|
||||
}
|
||||
export function isValidURL(url) {
|
||||
- try {
|
||||
- new URL(url);
|
||||
- return true;
|
||||
- }
|
||||
- catch {
|
||||
- return false;
|
||||
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://')) {
|
||||
+ try {
|
||||
+ new URL(url);
|
||||
+ return true;
|
||||
+ }
|
||||
+ catch {
|
||||
+ return false;
|
||||
+ }
|
||||
}
|
||||
+ return false;
|
||||
}
|
||||
export function isValidJson(str) {
|
||||
try {
|
||||
26
.yarn/patches/openai-npm-4.76.2-8ff1374617.patch
Normal file
@@ -0,0 +1,26 @@
|
||||
diff --git a/core.js b/core.js
|
||||
index 30c91e66bf595a66c09eb3dbcbda7d58154865f5..b511ff24ea1891904c60174c6ed26ecdd4d5ac51 100644
|
||||
--- a/core.js
|
||||
+++ b/core.js
|
||||
@@ -156,7 +156,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 ac267bcfcff44b1f7c9bea5513bba94726a31795..dd5bd9f29609d3f0eea4bd5b225f302893df14ad 100644
|
||||
--- a/core.mjs
|
||||
+++ b/core.mjs
|
||||
@@ -149,7 +149,7 @@ export class APIClient {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': this.getUserAgent(),
|
||||
- ...getPlatformHeaders(),
|
||||
+ // ...getPlatformHeaders(),
|
||||
...this.authHeaders(opts),
|
||||
};
|
||||
}
|
||||
29
.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch
Normal file
@@ -0,0 +1,29 @@
|
||||
diff --git a/lib/pdf-parse.js b/lib/pdf-parse.js
|
||||
index 96bfbc705dcb4fb73cb077a75f02c115371b3477..6d02d2bb426063c3a31cb740c3d86841de162a22 100644
|
||||
--- a/lib/pdf-parse.js
|
||||
+++ b/lib/pdf-parse.js
|
||||
@@ -21,12 +21,12 @@ function render_page(pageData) {
|
||||
for (let item of textContent.items) {
|
||||
if (lastY == item.transform[5] || !lastY){
|
||||
text += item.str;
|
||||
- }
|
||||
+ }
|
||||
else{
|
||||
text += '\n' + item.str;
|
||||
- }
|
||||
+ }
|
||||
lastY = item.transform[5];
|
||||
- }
|
||||
+ }
|
||||
//let strings = textContent.items.map(item => item.str);
|
||||
//let text = strings.join("\n");
|
||||
//text = text.replace(/[ ]+/ig," ");
|
||||
@@ -60,7 +60,7 @@ async function PDF(dataBuffer, options) {
|
||||
if (typeof options.version != 'string') options.version = DEFAULT_OPTIONS.version;
|
||||
if (options.version == 'default') options.version = DEFAULT_OPTIONS.version;
|
||||
|
||||
- PDFJS = PDFJS ? PDFJS : require(`./pdf.js/${options.version}/build/pdf.js`);
|
||||
+ PDFJS = PDFJS ? PDFJS : require(`./pdf.js/v1.10.100/build/pdf.js`);
|
||||
|
||||
ret.version = PDFJS.version;
|
||||
|
||||
29
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
45
CONTRIBUTING.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Cherry Studio 贡献者指南
|
||||
|
||||
欢迎来到 Cherry Studio 的贡献者社区!我们致力于将 Cherry Studio 打造成一个长期提供价值的项目,并希望邀请更多的开发者加入我们的行列。无论您是经验丰富的开发者还是刚刚起步的初学者,您的贡献都将帮助我们更好地服务用户,提升软件质量。
|
||||
|
||||
## 如何贡献
|
||||
|
||||
以下是您可以参与的几种方式:
|
||||
|
||||
1. **贡献代码**:帮助我们开发新功能或优化现有代码。请确保您的代码符合我们的编码标准,并通过所有测试。
|
||||
|
||||
2. **修复 BUG**:如果您发现了 BUG,欢迎提交修复方案。请在提交前确认问题已被解决,并附上相关测试。
|
||||
|
||||
3. **维护 Issue**:协助我们管理 GitHub 上的 issue,帮助标记、分类和解决问题。
|
||||
|
||||
4. **产品设计**:参与产品设计讨论,帮助我们改进用户体验和界面设计。
|
||||
|
||||
5. **编写文档**:帮助我们完善用户手册、API 文档和开发者指南。
|
||||
|
||||
6. **社区维护**:参与社区讨论,帮助解答用户问题,促进社区活跃。
|
||||
|
||||
7. **推广使用**:通过博客、社交媒体等渠道推广 Cherry Studio,吸引更多用户和开发者。
|
||||
|
||||
## 开始贡献
|
||||
|
||||
1. **Fork 仓库**:在 GitHub 上 fork 我们的仓库,并将其克隆到本地。
|
||||
|
||||
2. **创建分支**:为您要进行的更改创建一个新的分支。
|
||||
|
||||
3. **提交更改**:在本地进行更改并提交。请确保您的提交信息清晰明了。
|
||||
|
||||
4. **发起 Pull Request**:将您的更改推送到 GitHub,并发起 Pull Request。请描述您的更改内容和原因。
|
||||
|
||||
### 其他建议
|
||||
|
||||
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
|
||||
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。
|
||||
|
||||
## 联系我们
|
||||
|
||||
如果您有任何问题或建议,欢迎通过以下方式联系我们:
|
||||
|
||||
- 微信:kangfenmao
|
||||
- [GitHub Issues](https://github.com/kangfenmao/cherry-studio/issues)
|
||||
|
||||
感谢您的支持和贡献!我们期待与您一起将 Cherry Studio 打造成更好的产品。
|
||||
136
LICENSE
@@ -1,101 +1,79 @@
|
||||
### Cherry Studio 商业许可协议
|
||||
## Cherry Studio 用户协议
|
||||
|
||||
欢迎使用 Cherry Studio 桌面 AI 客户端工具。请仔细阅读以下协议条款,继续使用本软件即表示您同意本协议内容。
|
||||
|
||||
**许可协议**
|
||||
|
||||
本软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry Studio 时还应遵守以下附加条款:
|
||||
|
||||
**一. 商用许可**
|
||||
|
||||
1. **免费商用**:用户在不修改代码的情况下,可以免费用于商业目的。
|
||||
2. **商业授权**:如果您满足以下任意条件之一,需取得商业授权:
|
||||
1. 对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。
|
||||
2. 为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。
|
||||
3. 预装或集成到硬件设备或产品中进行捆绑销售。
|
||||
4. 政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
|
||||
|
||||
**二. 贡献者协议**
|
||||
|
||||
作为 Cherry Studio 的贡献者,您应当同意以下条款:
|
||||
|
||||
1. **许可调整**:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。
|
||||
2. **商业用途**:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。
|
||||
|
||||
**三. 其他条款**
|
||||
|
||||
1. 本协议条款的解释权归 Cherry Studio 开发者所有。
|
||||
2. 本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。
|
||||
|
||||
如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。
|
||||
|
||||
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问 http://www.apache.org/licenses/LICENSE-2.0。
|
||||
|
||||
---
|
||||
|
||||
#### 中文版
|
||||
根据 Apache 许可证 2.0 版(“许可证”)进行许可;除非符合许可证,否则您不得使用此文件。您可以在以下网址获取许可证副本:
|
||||
|
||||
**Cherry Studio 商业许可协议**
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
本协议(以下简称“协议”)由以下双方签订:
|
||||
除非适用法律要求或书面同意,软件根据许可证分发的内容以“原样”分发,不附带任何明示或暗示的保证或条件。请参阅特定语言管理权限的许可证和许可证下的限制。
|
||||
|
||||
- 许可方:王谦(kangfenmao@qq.com)
|
||||
- 被许可方:[被许可方名称]
|
||||
## Cherry Studio User Agreement
|
||||
|
||||
**1. 定义**
|
||||
Welcome to Cherry Studio, a desktop AI client tool. Please read the following agreement carefully. By continuing to use this software, you agree to the terms outlined below.
|
||||
|
||||
- “软件”指 Cherry Studio 软件,网址为 https://cherry-ai.com。
|
||||
- “商业用途”指任何以盈利为目的的使用。
|
||||
**License Agreement**
|
||||
|
||||
**2. 许可**
|
||||
This software is licensed under the **Apache License 2.0**. In addition to the terms of the Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
|
||||
|
||||
- 未经许可方明确书面许可,被许可方不得将软件用于商业用途。
|
||||
- 未经许可方事先书面同意,被许可方不得将软件全部或部分用于商业用途分发。
|
||||
- 未经许可方明确授权,被许可方不得再许可、租赁、销售、出租或以其他方式将软件转让给任何第三方用于商业用途。
|
||||
**I. Commercial Use License**
|
||||
|
||||
**3. 责任限制**
|
||||
1. **Free Commercial Use**: Users can use the software for commercial purposes without modifying the code.
|
||||
2. **Commercial License Required**: A commercial license is required if any of the following conditions are met:
|
||||
1. You modify, develop, or alter the software, including but not limited to changes to the application name, logo, code, or functionality.
|
||||
2. You provide multi-tenant services to enterprise customers with 10 or more users.
|
||||
3. You pre-install or integrate the software into hardware devices or products and bundle it for sale.
|
||||
4. You are engaging in large-scale procurement for government or educational institutions, especially involving security, data privacy, or other sensitive requirements.
|
||||
|
||||
开发者不对因使用本软件而产生的任何直接或间接损失承担责任。用户应自行承担使用本软件的风险。
|
||||
**II. Contributor Agreement**
|
||||
|
||||
**4. 许可协议生效日期**
|
||||
As a contributor to Cherry Studio, you agree to the following:
|
||||
|
||||
本许可协议自用户首次下载或使用本软件之日起生效。
|
||||
1. **License Adjustment**: The producer reserves the right to adjust the open-source license as needed, making it stricter or more lenient.
|
||||
2. **Commercial Use**: Any code you contribute may be used for commercial purposes, including but not limited to cloud business operations.
|
||||
|
||||
**5. 许可终止**
|
||||
**III. Other Terms**
|
||||
|
||||
如发现用户违反上述条款,开发者有权随时终止本许可,并要求用户停止使用本软件及删除所有相关副本。
|
||||
1. The interpretation of these terms is subject to the discretion of Cherry Studio developers.
|
||||
2. These terms may be updated, and users will be notified through the software when changes occur.
|
||||
|
||||
**6. 其他**
|
||||
For any questions or to request a commercial license, please contact the Cherry Studio development team.
|
||||
|
||||
本协议的解释、效力及争议的解决,均适用中华人民共和国法律。
|
||||
|
||||
**7. 联系信息**
|
||||
|
||||
- 许可方联系方式:
|
||||
- 手机号:18539907620
|
||||
- 邮箱:kangfenmao@qq.com
|
||||
|
||||
**许可方(签字):**
|
||||
**日期:**
|
||||
|
||||
**被许可方(签字):**
|
||||
**日期:**
|
||||
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache License 2.0. Detailed information about the Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
|
||||
|
||||
---
|
||||
|
||||
#### English Version
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
**Cherry Studio Commercial License Agreement**
|
||||
|
||||
This Agreement ("Agreement") is entered into by and between:
|
||||
|
||||
- Licensor: Wang Qian (kangfenmao)
|
||||
- Licensee: [Licensee Name]
|
||||
|
||||
**1. Definitions**
|
||||
|
||||
- "Software" refers to the Cherry Studio software, available at https://cherry-ai.com.
|
||||
- "Commercial Use" refers to any use for profit.
|
||||
|
||||
**2. License**
|
||||
|
||||
- The Licensee may not use the Software for Commercial Use without the Licensor's explicit written permission.
|
||||
- The Licensee may not distribute the Software in whole or in part for Commercial Use without the Licensor's prior written consent.
|
||||
- The Licensee may not sublicense, lease, sell, rent, or otherwise transfer the Software to any third party for Commercial Use without the Licensor's explicit authorization.
|
||||
|
||||
**3. Termination of License**
|
||||
|
||||
The developer reserves the right to terminate this license at any time if the terms are violated, and may require the user to cease using the software and delete all related copies.
|
||||
|
||||
**4. Effective Date of License Agreement**
|
||||
|
||||
This license agreement becomes effective from the date the user first downloads or uses the software.
|
||||
|
||||
**5. Termination of License**
|
||||
|
||||
The developer reserves the right to terminate this license at any time if the terms are violated, and may require the user to cease using the software and delete all related copies.
|
||||
|
||||
**6. Miscellaneous**
|
||||
|
||||
This Agreement shall be governed by and construed in accordance with the laws of the People's Republic of China.
|
||||
|
||||
**7. Contact Information**
|
||||
|
||||
- Licensor's Contact Details:
|
||||
- Phone: 18539907620
|
||||
- Email: kangfenmao@qq.com
|
||||
|
||||
**Licensor (Signature):**
|
||||
**Date:**
|
||||
|
||||
**Licensee (Signature):**
|
||||
**Date:**
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
109
README.md
@@ -1,30 +1,67 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/releases">
|
||||
<img src="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
|
||||
</a>
|
||||
</div>
|
||||
<div align="center">
|
||||
English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
|
||||

|
||||
|
||||
Cherry Studio is a desktop client that supports for multiple LLM providers, available on Windows, Mac and Linux.
|
||||
|
||||
👏 Join [Telegram Group](https://t.me/CherryStudioAI)
|
||||
|
||||
# 🌠 Screenshot
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
# 🌟 Key Features
|
||||
|
||||

|
||||
1. **Diverse LLM Provider Support**:
|
||||
|
||||
# 🌟 Features
|
||||
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
|
||||
- 🔗 AI Web Service Integration: Claude, Peplexity, Poe, and others
|
||||
- 💻 Local Model Support with Ollama
|
||||
|
||||
1. Support for Multiple LLM Providers.
|
||||
2. Allows creation of multiple Assistants.
|
||||
3. Enables creation of multiple topics.
|
||||
4. Allows using multiple models to answer questions in the same conversation.
|
||||
5. Supports drag-and-drop sorting.
|
||||
6. Code highlighting.
|
||||
7. Mermaid chart
|
||||
2. **AI Assistants & Conversations**:
|
||||
|
||||
- 📚 300+ Pre-configured AI Assistants
|
||||
- 🤖 Custom Assistant Creation
|
||||
- 💬 Multi-model Simultaneous Conversations
|
||||
|
||||
3. **Document & Data Processing**:
|
||||
|
||||
- 📄 Support for Text, Images, Office, PDF, and more
|
||||
- ☁️ WebDAV File Management and Backup
|
||||
- 📊 Mermaid Chart Visualization
|
||||
- 💻 Code Syntax Highlighting
|
||||
|
||||
4. **Practical Tools Integration**:
|
||||
|
||||
- 🔍 Global Search Functionality
|
||||
- 📝 Topic Management System
|
||||
- 🔤 AI-powered Translation
|
||||
- 🎯 Drag-and-drop Sorting
|
||||
- 🔌 Mini Program Support
|
||||
|
||||
5. **Enhanced User Experience**:
|
||||
- 🖥️ Cross-platform Support for Windows, Mac, and Linux
|
||||
- 📦 Ready to Use, No Environment Setup Required
|
||||
- 🎨 Light/Dark Themes and Transparent Window
|
||||
- 📝 Complete Markdown Rendering
|
||||
- 🤲 Easy Content Sharing
|
||||
|
||||
# 🖥️ Develop
|
||||
|
||||
## Recommended IDE Setup
|
||||
## IDE Setup
|
||||
|
||||
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## Project Setup
|
||||
|
||||
@@ -53,10 +90,52 @@ $ yarn build:mac
|
||||
$ yarn build:linux
|
||||
```
|
||||
|
||||
# ⭐️ Star History
|
||||
# 🤝 Contributing
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
We welcome contributions to Cherry Studio! Here are some ways you can contribute:
|
||||
|
||||
1. **Contribute Code**: Develop new features or optimize existing code.
|
||||
2. **Fix Bugs**: Submit fixes for any bugs you find.
|
||||
3. **Maintain Issues**: Help manage GitHub issues.
|
||||
4. **Product Design**: Participate in design discussions.
|
||||
5. **Write Documentation**: Improve user manuals and guides.
|
||||
6. **Community Engagement**: Join discussions and help users.
|
||||
7. **Promote Usage**: Spread the word about Cherry Studio.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork the Repository**: Fork and clone it to your local machine.
|
||||
2. **Create a Branch**: For your changes.
|
||||
3. **Submit Changes**: Commit and push your changes.
|
||||
4. **Open a Pull Request**: Describe your changes and reasons.
|
||||
|
||||
For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIBUTING.md).
|
||||
|
||||
Thank you for your support and contributions!
|
||||
|
||||
# 🚀 Contributors
|
||||
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 Community
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# 📣 Product Hunt
|
||||
|
||||
<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>
|
||||
|
||||
# ☕ Sponsor
|
||||
|
||||
[Buy Me a Coffee](docs/sponsor.md)
|
||||
|
||||
# 📃 License
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
# ⭐️ Star History
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
BIN
build/icon.ico
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 353 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 210 KiB |
BIN
build/logo.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
build/tray_icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
build/tray_icon_dark.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
build/tray_icon_light.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,6 +1,8 @@
|
||||
# provider: generic
|
||||
# url: http://127.0.0.1:8080
|
||||
# updaterCacheDirName: cherry-studio-updater
|
||||
provider: github
|
||||
repo: cherry-studio
|
||||
owner: kangfenmao
|
||||
# provider: github
|
||||
# repo: cherry-studio
|
||||
# owner: kangfenmao
|
||||
provider: generic
|
||||
url: https://cherrystudio.ocool.online
|
||||
|
||||
140
docs/README.ja.md
Normal file
@@ -0,0 +1,140 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/releases">
|
||||
<img src="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
|
||||
</a>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="./README.md">English</a> | <a href="./README.zh.md">中文</a> | 日本語
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
|
||||

|
||||
|
||||
Cherry Studioは、複数のLLMプロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linuxで利用可能です。
|
||||
|
||||
👏 [Telegramグループ](https://t.me/CherryStudioAI)に参加しましょう
|
||||
|
||||
# 🌠 スクリーンショット
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
# 🌟 主な機能
|
||||
|
||||
1. **多様な LLM サービス対応**:
|
||||
|
||||
- ☁️ 主要な LLM クラウドサービス対応:OpenAI、Gemini、Anthropic など
|
||||
- 🔗 AI Web サービス統合:Claude、Peplexity、Poe など
|
||||
- 💻 Ollama によるローカルモデル実行対応
|
||||
|
||||
2. **AI アシスタントと対話**:
|
||||
|
||||
- 📚 300+ の事前設定済み AI アシスタント
|
||||
- 🤖 カスタム AI アシスタントの作成
|
||||
- 💬 複数モデルでの同時対話機能
|
||||
|
||||
3. **文書とデータ処理**:
|
||||
|
||||
- 📄 テキスト、画像、Office、PDF など多様な形式対応
|
||||
- ☁️ WebDAV によるファイル管理とバックアップ
|
||||
- 📊 Mermaid による図表作成
|
||||
- 💻 コードハイライト機能
|
||||
|
||||
4. **実用的なツール統合**:
|
||||
|
||||
- 🔍 グローバル検索機能
|
||||
- 📝 トピック管理システム
|
||||
- 🔤 AI による翻訳機能
|
||||
- 🎯 ドラッグ&ドロップによる整理
|
||||
- 🔌 ミニプログラム対応
|
||||
|
||||
5. **優れたユーザー体験**:
|
||||
- 🖥️ Windows、Mac、Linux のクロスプラットフォーム対応
|
||||
- 📦 環境構築不要ですぐに使用可能
|
||||
- 🎨 ライト/ダークテーマと透明ウィンドウ対応
|
||||
- 📝 完全な Markdown レンダリング
|
||||
- 🤲 簡単な共有機能
|
||||
|
||||
# 🖥️ 開発
|
||||
|
||||
## IDEの設定
|
||||
|
||||
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## プロジェクトの設定
|
||||
|
||||
### インストール
|
||||
|
||||
```bash
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### 開発
|
||||
|
||||
```bash
|
||||
$ yarn dev
|
||||
```
|
||||
|
||||
### ビルド
|
||||
|
||||
```bash
|
||||
# Windowsの場合
|
||||
$ yarn build:win
|
||||
|
||||
# macOSの場合
|
||||
$ yarn build:mac
|
||||
|
||||
# Linuxの場合
|
||||
$ yarn build:linux
|
||||
```
|
||||
|
||||
# 🤝 貢献
|
||||
|
||||
Cherry Studioへの貢献を歓迎します!以下の方法で貢献できます:
|
||||
|
||||
1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します。
|
||||
2. **バグの修正**:見つけたバグを修正します。
|
||||
3. **問題の管理**:GitHubの問題を管理するのを手伝います。
|
||||
4. **製品デザイン**:デザインの議論に参加します。
|
||||
5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します。
|
||||
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します。
|
||||
7. **使用の促進**:Cherry Studioを広めます。
|
||||
|
||||
## 始め方
|
||||
|
||||
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします。
|
||||
2. **ブランチを作成**:変更のためのブランチを作成します。
|
||||
3. **変更を提出**:変更をコミットしてプッシュします。
|
||||
4. **プルリクエストを開く**:変更内容と理由を説明します。
|
||||
|
||||
詳細なガイドラインについては、[貢献ガイド](./CONTRIBUTING.md)をご覧ください。
|
||||
|
||||
ご支援と貢献に感謝します!
|
||||
|
||||
# 🚀 コントリビューター
|
||||
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
</a>
|
||||
|
||||
# コミュニティ
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# 📣 プロダクトハント
|
||||
|
||||
<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>
|
||||
|
||||
# スポンサー
|
||||
|
||||
[Buy Me a Coffee](sponsor.md)
|
||||
|
||||
# 📃 ライセンス
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ⭐️ スター履歴
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
141
docs/README.zh.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/releases">
|
||||
<img src="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
|
||||
</a>
|
||||
</div>
|
||||
<div align="center">
|
||||
中文 / <a href="https://github.com/kangfenmao/cherry-studio">English</a> / <a href="./README.ja.md">日本語</a>
|
||||
</div>
|
||||
|
||||
# 🍒 Cherry Studio
|
||||
|
||||

|
||||
|
||||
Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客户端,兼容 Windows、Mac 和 Linux 系统。
|
||||
|
||||
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)
|
||||
|
||||
# 🌠 界面
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
# 🌟 主要特性
|
||||
|
||||
1. **多样化 LLM 服务支持**:
|
||||
|
||||
- ☁️ 支持主流 LLM 云服务:OpenAI、Gemini、Anthropic、硅基流动等
|
||||
- 🔗 集成流行 AI Web 服务:Claude、Peplexity、Poe、腾讯元宝、知乎直答等
|
||||
- 💻 支持 Ollama 本地模型部署
|
||||
|
||||
2. **智能助手与对话**:
|
||||
|
||||
- 📚 内置 300+ 预配置 AI 助手
|
||||
- 🤖 支持自定义创建专属助手
|
||||
- 💬 多模型同时对话,获得多样化观点
|
||||
|
||||
3. **文档与数据处理**:
|
||||
|
||||
- 📄 支持文本、图片、Office、PDF 等多种格式
|
||||
- ☁️ WebDAV 文件管理与数据备份
|
||||
- 📊 Mermaid 图表可视化
|
||||
- 💻 代码高亮显示
|
||||
|
||||
4. **实用工具集成**:
|
||||
|
||||
- 🔍 全局搜索功能
|
||||
- 📝 话题管理系统
|
||||
- 🔤 AI 驱动的翻译功能
|
||||
- 🎯 拖拽排序
|
||||
- 🔌 小程序支持
|
||||
|
||||
5. **优质使用体验**:
|
||||
- 🖥️ Windows、Mac、Linux 跨平台支持
|
||||
- 📦 开箱即用,无需配置环境
|
||||
- 🎨 支持明暗主题与透明窗口
|
||||
- 📝 完整的 Markdown 渲染
|
||||
- 🤲 便捷的内容分享功能
|
||||
|
||||
# 🖥️ 开发
|
||||
|
||||
## IDE 设置
|
||||
|
||||
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## 项目设置
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
$ yarn dev
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
$ yarn build:win
|
||||
|
||||
# macOS
|
||||
$ yarn build:mac
|
||||
|
||||
# Linux
|
||||
$ yarn build:linux
|
||||
```
|
||||
|
||||
# 🤝 贡献
|
||||
|
||||
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
|
||||
|
||||
1. **贡献代码**:开发新功能或优化现有代码。
|
||||
2. **修复错误**:提交您发现的错误修复。
|
||||
3. **维护问题**:帮助管理 GitHub 问题。
|
||||
4. **产品设计**:参与设计讨论。
|
||||
5. **撰写文档**:改进用户手册和指南。
|
||||
6. **社区参与**:加入讨论并帮助用户。
|
||||
7. **推广使用**:宣传 Cherry Studio。
|
||||
|
||||
## 入门
|
||||
|
||||
1. **Fork 仓库**:Fork 并克隆到您的本地机器。
|
||||
2. **创建分支**:为您的更改创建分支。
|
||||
3. **提交更改**:提交并推送您的更改。
|
||||
4. **打开 Pull Request**:描述您的更改和原因。
|
||||
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
感谢您的支持和贡献!
|
||||
|
||||
# 🚀 贡献者
|
||||
|
||||
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
|
||||
</a>
|
||||
<br /><br />
|
||||
|
||||
# 🌐 社区
|
||||
|
||||
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
|
||||
|
||||
# 📣 产品猎人
|
||||
|
||||
<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>
|
||||
|
||||
# ☕ 赞助
|
||||
|
||||
[微信赞赏码](sponsor.md)
|
||||
|
||||
# 📃 许可证
|
||||
|
||||
[LICENSE](../LICENSE)
|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
@@ -1,80 +0,0 @@
|
||||
# 🍒 Cherry Studio
|
||||
|
||||
Cherry Studio 是一款跨平台桌面客户端,支持多个大语言模型(LLM)服务商,兼容 Windows、Mac 和 Linux 系统,并拥丰富的个性化选项与领先的功能设计。
|
||||
|
||||
# 🌠 界面
|
||||
<img width="1582" alt="Xnip2024-09-23_15-01-53" src="https://github.com/user-attachments/assets/554aa31b-87b6-49fe-877d-af313e1608b0">
|
||||
<img width="1582" alt="Xnip2024-09-23_15-02-27" src="https://github.com/user-attachments/assets/f43fb4c8-194a-4f46-8575-6db2bd136cb9">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-12-19" src="https://github.com/user-attachments/assets/82ce3cc1-5a0b-49aa-9fe4-0376d34be1f8">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-11-44" src="https://github.com/user-attachments/assets/55e420c8-fc0f-40a0-868e-d75bebeb5af3">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-11-50" src="https://github.com/user-attachments/assets/7413384e-a7c7-4525-96ea-ccd395d7e51a">
|
||||
<img width="1582" alt="Xnip2024-09-23_16-12-59" src="https://github.com/user-attachments/assets/894b5e97-569f-4471-813c-c48d19455215">
|
||||
|
||||
|
||||
# 🌟 特性
|
||||
## 😌 轻松上手
|
||||
🍏Windows,Mac,Linux跨平台支持
|
||||
|
||||
📦开箱即用,无需 Python 与 Docker
|
||||
|
||||
🤝简洁、友好的界面与交互设计
|
||||
|
||||
## 🛠️多样化的 LLM 服务模式支持
|
||||
☁️ 全面覆盖 LLM 云服务,支持自定义 api key 与模型管理:OpenAI,Gemini,Anthropic,硅基流动...
|
||||
|
||||
🔗汇聚流行的 AI Web 服务,并计划通过功能增强提升体验:Claude,Peplexity,Poe,腾讯元宝,知乎直答...
|
||||
|
||||
💻支持 Ollama 运行本地模型
|
||||
## 📲个性化的功能体验
|
||||
📄完整的 Markdown 与 Mermaid 渲染支持
|
||||
|
||||
🤖使用与创建智能体提升工作效率
|
||||
|
||||
🔤持续迭代的翻译功能
|
||||
|
||||
🤲生成结果支持 Markdown 与图片分享
|
||||
|
||||
📎文件与图片上传,RAG 与多模态对话
|
||||
|
||||
🎨透明窗口与明暗主题支持
|
||||
|
||||
# 🖥️ 开发指南
|
||||
|
||||
## 推荐的开发环境
|
||||
|
||||
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## 项目设置
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### 启动开发环境
|
||||
|
||||
```bash
|
||||
$ yarn dev
|
||||
```
|
||||
|
||||
### 构建版本
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ yarn build:win
|
||||
|
||||
# For macOS
|
||||
$ yarn build:mac
|
||||
|
||||
# For Linux
|
||||
$ yarn build:linux
|
||||
```
|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
|
||||
# 📃 许可证
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
5
docs/sponsor.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Sponsor
|
||||
|
||||
<div align="center">
|
||||
<img src="https://github.com/user-attachments/assets/4665f07f-5ecc-4bd8-8727-ae00f35d6d98" alt="Buy Me a Coffee" width="280"/>
|
||||
</div>
|
||||
@@ -9,11 +9,27 @@ files:
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!src'
|
||||
- '!local'
|
||||
- '!scripts'
|
||||
- '!resources'
|
||||
- '!local'
|
||||
- '!docs'
|
||||
- '!packages'
|
||||
- '!stats.html'
|
||||
- '!*.md'
|
||||
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
|
||||
- '!**/{test,tests,__tests__,coverage}/**'
|
||||
- '!**/*.{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}'
|
||||
- '!node_modules/rollup-plugin-visualizer'
|
||||
- '!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/html2canvas/dist/{html2canvas.min.js,html2canvas.esm.js}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{node,dll,metal,exp,lib}'
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
nsis:
|
||||
@@ -21,14 +37,16 @@ nsis:
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
@@ -46,26 +64,24 @@ linux:
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
# - snap
|
||||
# - deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: github
|
||||
repo: cherry-studio
|
||||
owner: kangfenmao
|
||||
provider: generic
|
||||
url: https://cherrystudio.ocool.online
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
本次更新:
|
||||
全新应用图标
|
||||
模型图标更新
|
||||
近期更新:
|
||||
支持话题导出为图片
|
||||
支持粘贴图片和文件到聊天输入框
|
||||
支持将对话移动到其他智能体了
|
||||
Azure OpenAI 嵌入模型支持
|
||||
增加嵌入模型专用服务商 JINA
|
||||
支持选择图片和文件后不输入内容即可发送
|
||||
增加 GitHub Copilot 小程序
|
||||
模型编辑支持显示模型 ID
|
||||
模型选择按钮增加服务商显示
|
||||
修复话题移动到不同的助手后搜索结果不能定位问题
|
||||
修复输出包含 sub 无法正常显示问题
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@llm-tools/embedjs',
|
||||
'@llm-tools/embedjs-openai',
|
||||
'@llm-tools/embedjs-loader-web',
|
||||
'@llm-tools/embedjs-loader-markdown',
|
||||
'@llm-tools/embedjs-loader-msoffice',
|
||||
'@llm-tools/embedjs-loader-xml',
|
||||
'@llm-tools/embedjs-loader-pdf',
|
||||
'@llm-tools/embedjs-loader-sitemap',
|
||||
'@llm-tools/embedjs-libsql'
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('main')
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
'@types': resolve('src/renderer/src/types'),
|
||||
'@main': resolve('src/main')
|
||||
'@shared': resolve('packages/shared')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client']
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16,11 +42,15 @@ export default defineConfig({
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
plugins: [react(), ...visualizerPlugin('renderer')],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('packages/shared')
|
||||
}
|
||||
},
|
||||
plugins: [react()]
|
||||
optimizeDeps: {
|
||||
exclude: ['chunk-QH6N6I7P.js', 'chunk-PB73W2YU.js']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
87
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "0.7.7",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -8,8 +8,14 @@
|
||||
"homepage": "https://github.com/kangfenmao/cherry-studio",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"local"
|
||||
]
|
||||
"local",
|
||||
"packages/*"
|
||||
],
|
||||
"installConfig": {
|
||||
"hoistingLimits": [
|
||||
"packages/database"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
@@ -19,50 +25,87 @@
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build:check": "yarn typecheck",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win --publish never",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac --publish never",
|
||||
"build:linux": "dotenv electron-vite build && electron-builder --linux --publish never",
|
||||
"build:win": "dotenv npm run build && electron-builder --win",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac",
|
||||
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
|
||||
"build:linux": "dotenv electron-vite build && electron-builder --linux",
|
||||
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
|
||||
"build:npm": "node scripts/build-npm.js",
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn release patch push"
|
||||
"publish": "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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@llm-tools/embedjs": "patch:@llm-tools/embedjs@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.25-ec5645cf36.patch",
|
||||
"@llm-tools/embedjs-libsql": "patch:@llm-tools/embedjs-libsql@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-libsql-npm-0.1.25-fad000d74c.patch",
|
||||
"@llm-tools/embedjs-loader-csv": "^0.1.25",
|
||||
"@llm-tools/embedjs-loader-markdown": "^0.1.25",
|
||||
"@llm-tools/embedjs-loader-msoffice": "^0.1.25",
|
||||
"@llm-tools/embedjs-loader-pdf": "^0.1.25",
|
||||
"@llm-tools/embedjs-loader-sitemap": "^0.1.25",
|
||||
"@llm-tools/embedjs-loader-web": "^0.1.25",
|
||||
"@llm-tools/embedjs-loader-xml": "^0.1.25",
|
||||
"@llm-tools/embedjs-openai": "^0.1.25",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"apache-arrow": "^18.1.0",
|
||||
"docx": "^9.0.2",
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.1.7",
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"html2canvas": "^1.4.1"
|
||||
"fs-extra": "^11.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"tokenx": "^0.4.1",
|
||||
"webdav": "4.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.24.3",
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^1.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@google/generative-ai": "^0.16.0",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@types/adm-zip": "^0",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/node": "^18.19.9",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"antd": "^5.18.3",
|
||||
"antd": "^5.22.5",
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.9.1",
|
||||
"electron": "31.7.6",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-vite": "^2.0.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^2.3.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"eslint": "^8.56.0",
|
||||
@@ -70,28 +113,31 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.0.0",
|
||||
"gpt-tokens": "^1.3.10",
|
||||
"i18next": "^23.11.5",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.4",
|
||||
"openai": "^4.52.1",
|
||||
"openai": "patch:openai@npm%3A4.76.2#~/.yarn/patches/openai-npm-4.76.2-8ff1374617.patch",
|
||||
"prettier": "^3.2.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
"react-spinners": "^0.14.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^6.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.77.2",
|
||||
"shiki": "^1.22.2",
|
||||
"styled-components": "^6.1.11",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"typescript": "^5.6.2",
|
||||
@@ -103,7 +149,8 @@
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@electron/notarize@npm:2.2.1": "patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch"
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"@llm-tools/embedjs-utils@npm:0.1.25": "patch:@llm-tools/embedjs-utils@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-utils-npm-0.1.25-fd8fe8a193.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.5.0"
|
||||
}
|
||||
|
||||
1
packages/artifacts/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Cherry Studio Artifacts
|
||||
19
packages/artifacts/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@cherry-studio/artifacts",
|
||||
"version": "0.1.0",
|
||||
"description": "Cherry Studio Artifacts",
|
||||
"main": "index.js",
|
||||
"homepage": "https://github.com/kangfenmao/cherry-studio/blob/main/npm/artifacts",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"artifacts"
|
||||
],
|
||||
"author": "kangfenmao",
|
||||
"license": "ISC"
|
||||
}
|
||||
108
packages/artifacts/statics/word-explanation-card.css
Normal file
@@ -0,0 +1,108 @@
|
||||
:root {
|
||||
/* 莫兰迪色系:使用柔和、低饱和度的颜色 */
|
||||
--primary-color: #b6b5a7; /* 莫兰迪灰褐色,用于背景文字 */
|
||||
--secondary-color: #9a8f8f; /* 莫兰迪灰棕色,用于标题背景 */
|
||||
--accent-color: #c5b4a0; /* 莫兰迪淡棕色,用于强调元素 */
|
||||
--background-color: #e8e3de; /* 莫兰迪米色,用于页面背景 */
|
||||
--text-color: #5b5b5b; /* 莫兰迪深灰色,用于主要文字 */
|
||||
--light-text-color: #8c8c8c; /* 莫兰迪中灰色,用于次要文字 */
|
||||
--divider-color: #d1cbc3; /* 莫兰迪浅灰色,用于分隔线 */
|
||||
}
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--background-color); /* 使用莫兰迪米色作为页面背景 */
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
color: var(--text-color); /* 使用莫兰迪深灰色作为主要文字颜色 */
|
||||
}
|
||||
.card {
|
||||
width: 300px;
|
||||
height: 500px;
|
||||
background-color: #f2ede9; /* 莫兰迪浅米色,用于卡片背景 */
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header {
|
||||
background-color: var(--secondary-color); /* 使用莫兰迪灰棕色作为标题背景 */
|
||||
color: #f2ede9; /* 浅色文字与深色背景形成对比 */
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.word {
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.word-main {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 36px;
|
||||
color: var(--text-color); /* 使用莫兰迪深灰色作为主要词汇颜色 */
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.word-main::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -5px;
|
||||
width: 50px;
|
||||
height: 3px;
|
||||
background-color: var(--accent-color); /* 使用莫兰迪淡棕色作为下划线 */
|
||||
}
|
||||
.word-sub {
|
||||
font-size: 14px;
|
||||
color: var(--light-text-color); /* 使用莫兰迪中灰色作为次要文字颜色 */
|
||||
margin: 5px 0;
|
||||
}
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--divider-color); /* 使用莫兰迪浅灰色作为分隔线 */
|
||||
margin: 20px 0;
|
||||
}
|
||||
.explanation {
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.quote {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
border-left: 3px solid var(--accent-color); /* 使用莫兰迪淡棕色作为引用边框 */
|
||||
}
|
||||
.background-text {
|
||||
position: absolute;
|
||||
font-size: 150px;
|
||||
color: rgba(182, 181, 167, 0.15); /* 使用莫兰迪灰褐色的透明版本作为背景文字 */
|
||||
z-index: 0;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: bold;
|
||||
}
|
||||
3
packages/database/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
BIN
packages/database/.yarn/install-state.gz
Normal file
3
packages/database/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cherry Studio Database
|
||||
|
||||
Cherry Studio 依赖的数据文件由这个数据库来生成,数据库文件请联系开发者获取
|
||||
0
packages/database/data/.gitkeep
Normal file
13
packages/database/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@cherry-studio/database",
|
||||
"packageManager": "yarn@4.3.1",
|
||||
"dependencies": {
|
||||
"csv-parser": "^3.0.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"scripts": {
|
||||
"agents": "node src/agents.js",
|
||||
"email": "yarn csv && node src/email.js",
|
||||
"csv": "node src/csv.js"
|
||||
}
|
||||
}
|
||||
47
packages/database/src/agents.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const sqlite3 = require('sqlite3').verbose()
|
||||
const fs = require('fs')
|
||||
|
||||
// 连接到数据库
|
||||
const db = new sqlite3.Database('./data/CherryStudio.sqlite3', (err) => {
|
||||
if (err) {
|
||||
console.error('Error connecting to the database:', err.message)
|
||||
return
|
||||
}
|
||||
console.log('Connected to the database.')
|
||||
})
|
||||
|
||||
// 查询数据并转换为JSON
|
||||
db.all('SELECT * FROM agents', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error querying the database:', err.message)
|
||||
return
|
||||
}
|
||||
|
||||
// 将 ID 类型转换为字符串
|
||||
for (const row of rows) {
|
||||
row.id = row.id.toString()
|
||||
row.group = row.group.toString().split(',')
|
||||
row.group = row.group.map((item) => item.trim().replace('\r\n', ''))
|
||||
}
|
||||
|
||||
// 将查询结果转换为JSON字符串
|
||||
const jsonData = JSON.stringify(rows, null, 2)
|
||||
|
||||
// 将JSON数据写入文件
|
||||
fs.writeFile('../../src/renderer/src/config/agents.json', jsonData, (err) => {
|
||||
if (err) {
|
||||
console.error('Error writing to file:', err.message)
|
||||
return
|
||||
}
|
||||
console.log('Data has been written to agents.json')
|
||||
})
|
||||
|
||||
// 关闭数据库连接
|
||||
db.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error closing the database:', err.message)
|
||||
return
|
||||
}
|
||||
console.log('Database connection closed.')
|
||||
})
|
||||
})
|
||||
77
packages/database/src/csv.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const fs = require('fs')
|
||||
const csv = require('csv-parser')
|
||||
const sqlite3 = require('sqlite3').verbose()
|
||||
|
||||
// 连接到 SQLite 数据库
|
||||
const db = new sqlite3.Database('./data/CherryStudio.sqlite3', (err) => {
|
||||
if (err) {
|
||||
console.error('Error opening database', err)
|
||||
return
|
||||
}
|
||||
console.log('Connected to the SQLite database.')
|
||||
})
|
||||
|
||||
// 创建一个数组来存储 CSV 数据
|
||||
const results = []
|
||||
|
||||
// 读取 CSV 文件
|
||||
fs.createReadStream('./data/data.csv')
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', () => {
|
||||
// 准备 SQL 插入语句,使用 INSERT OR IGNORE
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO emails (email, github, sent) VALUES (?, ?, ?)')
|
||||
|
||||
// 插入每一行数据
|
||||
let inserted = 0
|
||||
let skipped = 0
|
||||
let emptyEmail = 0
|
||||
|
||||
db.serialize(() => {
|
||||
// 开始一个事务以提高性能
|
||||
db.run('BEGIN TRANSACTION')
|
||||
|
||||
results.forEach((row) => {
|
||||
// 检查 email 是否为空
|
||||
if (!row.email || row.email.trim() === '') {
|
||||
emptyEmail++
|
||||
return // 跳过这一行
|
||||
}
|
||||
|
||||
stmt.run(row.email, row['user-href'], 0, function (err) {
|
||||
if (err) {
|
||||
console.error('Error inserting row', err)
|
||||
} else {
|
||||
if (this.changes === 1) {
|
||||
inserted++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 提交事务
|
||||
db.run('COMMIT', (err) => {
|
||||
if (err) {
|
||||
console.error('Error committing transaction', err)
|
||||
} else {
|
||||
console.log(
|
||||
`Insertion complete. Inserted: ${inserted}, Skipped (duplicate): ${skipped}, Skipped (empty email): ${emptyEmail}`
|
||||
)
|
||||
}
|
||||
|
||||
// 完成插入
|
||||
stmt.finalize()
|
||||
|
||||
// 关闭数据库连接
|
||||
db.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error closing database', err)
|
||||
} else {
|
||||
console.log('Database connection closed.')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
36
packages/database/src/email.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const sqlite3 = require('sqlite3').verbose()
|
||||
|
||||
// 连接到数据库
|
||||
const db = new sqlite3.Database('./data/CherryStudio.sqlite3', (err) => {
|
||||
if (err) {
|
||||
console.error('Error connecting to the database:', err.message)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// 查询数据并转换为JSON
|
||||
db.all('SELECT * FROM emails WHERE sent = 0', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error querying the database:', err.message)
|
||||
return
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(row.email)
|
||||
// Update row set sent = 1
|
||||
db.run('UPDATE emails SET sent = 1 WHERE id = ?', [row.id], (err) => {
|
||||
if (err) {
|
||||
console.error('Error updating the database:', err.message)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭数据库连接
|
||||
db.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error closing the database:', err.message)
|
||||
return
|
||||
}
|
||||
})
|
||||
})
|
||||
1643
packages/database/yarn.lock
Normal file
115
packages/shared/config/constant.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
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', // 富文本格式文件
|
||||
'.tex', // LaTeX 文件
|
||||
'.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 脚本文件
|
||||
'.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 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
'.kts', // Kotlin Script 文件
|
||||
'.java' // Java 代码文件
|
||||
]
|
||||
|
||||
export const ZOOM_SHORTCUTS = [
|
||||
{
|
||||
key: 'zoom_in',
|
||||
shortcut: ['CommandOrControl', '='],
|
||||
editable: false,
|
||||
enabled: true,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
key: 'zoom_out',
|
||||
shortcut: ['CommandOrControl', '-'],
|
||||
editable: false,
|
||||
enabled: true,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
key: 'zoom_reset',
|
||||
shortcut: ['CommandOrControl', '0'],
|
||||
editable: false,
|
||||
enabled: true,
|
||||
system: true
|
||||
}
|
||||
]
|
||||
118
resources/cherry-studio/license.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CherryStudio 许可协议-ZH/EN</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100 p-8">
|
||||
<div class="container mx-auto bg-white p-6 rounded shadow-lg">
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio 许可协议</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">许可协议</h2>
|
||||
<p class="mb-4">
|
||||
本软件采用 <strong>Apache License 2.0</strong> 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry
|
||||
Studio 时还应遵守以下附加条款:
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的。</li>
|
||||
<li>
|
||||
<strong>商业授权</strong>:如果您满足以下任意条件之一,需取得商业授权:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。</li>
|
||||
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。</li>
|
||||
<li>预装或集成到硬件设备或产品中进行捆绑销售。</li>
|
||||
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
|
||||
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
|
||||
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
|
||||
</ol>
|
||||
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。</p>
|
||||
<p>
|
||||
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0"
|
||||
class="text-blue-500 underline">http://www.apache.org/licenses/LICENSE-2.0</a>
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
|
||||
<p class="mb-4">
|
||||
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
|
||||
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without
|
||||
modifying
|
||||
the code.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial License Required</strong>: A commercial license is required if any of the
|
||||
following
|
||||
conditions are met:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>
|
||||
You modify, develop, or alter the software, including but not limited to changes to the
|
||||
application
|
||||
name, logo, code, or functionality.
|
||||
</li>
|
||||
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
|
||||
<li>
|
||||
You pre-install or integrate the software into hardware devices or products and bundle it
|
||||
for sale.
|
||||
</li>
|
||||
<li>
|
||||
You are engaging in large-scale procurement for government or educational institutions,
|
||||
especially
|
||||
involving security, data privacy, or other sensitive requirements.
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source
|
||||
license as
|
||||
needed, making it stricter or more lenient.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes,
|
||||
including but
|
||||
not limited to cloud business operations.
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
|
||||
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
|
||||
</ol>
|
||||
<p class="mb-4">
|
||||
For any questions or to request a commercial license, please contact the Cherry Studio development team.
|
||||
</p>
|
||||
<p>
|
||||
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache
|
||||
License 2.0. Detailed information about the Apache License 2.0 can be found at
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0"
|
||||
class="text-blue-500 underline">http://www.apache.org/licenses/LICENSE-2.0</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
45
scripts/after-pack.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const { Arch } = require('electron-builder')
|
||||
const { default: removeLocales } = require('./remove-locales')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
exports.default = async function (context) {
|
||||
await removeLocales(context)
|
||||
const platform = context.packager.platform.name
|
||||
const arch = context.arch
|
||||
|
||||
if (platform === 'mac') {
|
||||
const node_modules_path = path.join(
|
||||
context.appOutDir,
|
||||
'Cherry Studio.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules'
|
||||
)
|
||||
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
}
|
||||
}
|
||||
|
||||
function removeDifferentArchNodeFiles(nodeModulesPath, packageName, arch) {
|
||||
const modulePath = path.join(nodeModulesPath, packageName)
|
||||
const dirs = fs.readdirSync(modulePath)
|
||||
dirs
|
||||
.filter((dir) => !arch.includes(dir))
|
||||
.forEach((dir) => {
|
||||
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
|
||||
console.log(`Removed dir: ${dir}`, arch)
|
||||
})
|
||||
}
|
||||
40
scripts/build-npm.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { downloadNpmPackage } = require('./utils')
|
||||
|
||||
async function downloadNpm(platform) {
|
||||
if (!platform || platform === 'mac') {
|
||||
downloadNpmPackage(
|
||||
'@libsql/darwin-arm64',
|
||||
'https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz'
|
||||
)
|
||||
downloadNpmPackage('@libsql/darwin-x64', 'https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz')
|
||||
}
|
||||
|
||||
if (!platform || platform === 'linux') {
|
||||
downloadNpmPackage(
|
||||
'@libsql/linux-arm64-gnu',
|
||||
'https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz'
|
||||
)
|
||||
downloadNpmPackage(
|
||||
'@libsql/linux-arm64-musl',
|
||||
'https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz'
|
||||
)
|
||||
downloadNpmPackage(
|
||||
'@libsql/linux-x64-gnu',
|
||||
'https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz'
|
||||
)
|
||||
downloadNpmPackage(
|
||||
'@libsql/linux-x64-musl',
|
||||
'https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz'
|
||||
)
|
||||
}
|
||||
|
||||
if (!platform || platform === 'windows') {
|
||||
downloadNpmPackage(
|
||||
'@libsql/win32-x64-msvc',
|
||||
'https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const platformArg = process.argv[2]
|
||||
downloadNpm(platformArg)
|
||||
529
scripts/cloudflare-worker.js
Normal file
@@ -0,0 +1,529 @@
|
||||
// 配置信息
|
||||
const config = {
|
||||
R2_CUSTOM_DOMAIN: 'cherrystudio.ocool.online',
|
||||
R2_BUCKET_NAME: 'cherrystudio',
|
||||
// 缓存键名
|
||||
CACHE_KEY: 'cherry-studio-latest-release',
|
||||
VERSION_DB: 'versions.json',
|
||||
LOG_FILE: 'logs.json',
|
||||
MAX_LOGS: 1000 // 最多保存多少条日志
|
||||
}
|
||||
|
||||
// Worker 入口函数
|
||||
const worker = {
|
||||
// 定时器触发配置
|
||||
scheduled: {
|
||||
cron: '*/1 * * * *' // 每分钟执行一次
|
||||
},
|
||||
|
||||
// 定时器执行函数 - 只负责检查和更新
|
||||
async scheduled(event, env, ctx) {
|
||||
try {
|
||||
await initDataFiles(env)
|
||||
console.log('开始定时检查新版本...')
|
||||
// 使用新的 checkNewRelease 函数
|
||||
await checkNewRelease(env)
|
||||
} catch (error) {
|
||||
console.error('定时任务执行失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// HTTP 请求处理函数 - 只负责返回数据
|
||||
async fetch(request, env, ctx) {
|
||||
if (!env || !env.R2_BUCKET) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'R2 存储桶未正确配置'
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const filename = url.pathname.slice(1)
|
||||
|
||||
try {
|
||||
// 处理文件下载请求
|
||||
if (filename) {
|
||||
return await handleDownload(env, filename)
|
||||
}
|
||||
|
||||
// 只返回缓存的版本信息
|
||||
return await getCachedRelease(env)
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default worker
|
||||
|
||||
/**
|
||||
* 添加日志记录函数
|
||||
*/
|
||||
async function addLog(env, type, event, details = null) {
|
||||
try {
|
||||
const logFile = await env.R2_BUCKET.get(config.LOG_FILE)
|
||||
let logs = { logs: [] }
|
||||
|
||||
if (logFile) {
|
||||
logs = JSON.parse(await logFile.text())
|
||||
}
|
||||
|
||||
logs.logs.unshift({
|
||||
timestamp: new Date().toISOString(),
|
||||
type,
|
||||
event,
|
||||
details
|
||||
})
|
||||
|
||||
// 保持日志数量在限制内
|
||||
if (logs.logs.length > config.MAX_LOGS) {
|
||||
logs.logs = logs.logs.slice(0, config.MAX_LOGS)
|
||||
}
|
||||
|
||||
await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(logs, null, 2))
|
||||
} catch (error) {
|
||||
console.error('写入日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新版本信息
|
||||
*/
|
||||
async function getLatestRelease(env) {
|
||||
try {
|
||||
const cached = await env.R2_BUCKET.get(config.CACHE_KEY)
|
||||
if (!cached) {
|
||||
// 如果缓存不存在,先检查版本数据库
|
||||
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB)
|
||||
if (versionDB) {
|
||||
const versions = JSON.parse(await versionDB.text())
|
||||
if (versions.latestVersion) {
|
||||
// 从版本数据库重建缓存
|
||||
const latestVersion = versions.versions[versions.latestVersion]
|
||||
const cacheData = {
|
||||
version: latestVersion.version,
|
||||
publishedAt: latestVersion.publishedAt,
|
||||
changelog: latestVersion.changelog,
|
||||
downloads: latestVersion.files
|
||||
.filter((file) => file.uploaded)
|
||||
.map((file) => ({
|
||||
name: file.name,
|
||||
url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`,
|
||||
size: formatFileSize(file.size)
|
||||
}))
|
||||
}
|
||||
// 更新缓存
|
||||
await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData))
|
||||
return new Response(JSON.stringify(cacheData), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// 如果版本数据库也没有数据,才执行检查更新
|
||||
const data = await checkNewRelease(env)
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const data = await cached.text()
|
||||
return new Response(data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
await addLog(env, 'ERROR', '获取版本信息失败', error.message)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: '获取版本信息失败: ' + error.message,
|
||||
detail: '请稍<E8AFB7><E7A88D><EFBFBD>再试'
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 修改下载处理函数,直接接收 env
|
||||
async function handleDownload(env, filename) {
|
||||
try {
|
||||
const object = await env.R2_BUCKET.get(filename)
|
||||
|
||||
if (!object) {
|
||||
return new Response('文件未找到', { status: 404 })
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
const headers = new Headers()
|
||||
object.writeHttpMetadata(headers)
|
||||
headers.set('etag', object.httpEtag)
|
||||
headers.set('Content-Disposition', `attachment; filename="${filename}"`)
|
||||
|
||||
return new Response(object.body, {
|
||||
headers
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('下载文件时发生错误:', error)
|
||||
return new Response('获取文件失败', { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取对应的 Content-Type
|
||||
*/
|
||||
function getContentType(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase()
|
||||
const types = {
|
||||
exe: 'application/x-msdownload', // Windows 可执行文件
|
||||
dmg: 'application/x-apple-diskimage', // macOS 安装包
|
||||
zip: 'application/zip', // 压缩包
|
||||
AppImage: 'application/x-executable', // Linux 可执行文件
|
||||
blockmap: 'application/octet-stream' // 更新文件
|
||||
}
|
||||
return types[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* 将字节转换为人类可读的格式(B, KB, MB, GB)
|
||||
*/
|
||||
function formatFileSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本号比较函数
|
||||
* 用于对版本号进行排序
|
||||
*/
|
||||
function compareVersions(a, b) {
|
||||
const partsA = a.replace('v', '').split('.')
|
||||
const partsB = b.replace('v', '').split('.')
|
||||
|
||||
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
||||
const numA = parseInt(partsA[i] || 0)
|
||||
const numB = parseInt(partsB[i] || 0)
|
||||
|
||||
if (numA !== numB) {
|
||||
return numA - numB
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据文件
|
||||
*/
|
||||
async function initDataFiles(env) {
|
||||
try {
|
||||
// 检查并初始化版本数据库
|
||||
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB)
|
||||
if (!versionDB) {
|
||||
const initialVersions = {
|
||||
versions: {},
|
||||
latestVersion: null,
|
||||
lastChecked: new Date().toISOString()
|
||||
}
|
||||
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(initialVersions, null, 2))
|
||||
await addLog(env, 'INFO', 'versions.json 初始化成功')
|
||||
}
|
||||
|
||||
// 检查并初始化日志文件
|
||||
const logFile = await env.R2_BUCKET.get(config.LOG_FILE)
|
||||
if (!logFile) {
|
||||
const initialLogs = {
|
||||
logs: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'INFO',
|
||||
event: '系统初始化'
|
||||
}
|
||||
]
|
||||
}
|
||||
await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(initialLogs, null, 2))
|
||||
console.log('logs.json 初始化成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化数据文件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:只获取缓存的版本信息
|
||||
async function getCachedRelease(env) {
|
||||
try {
|
||||
const cached = await env.R2_BUCKET.get(config.CACHE_KEY)
|
||||
if (!cached) {
|
||||
// 如果缓存不存在,从版本数据库获取
|
||||
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB)
|
||||
if (versionDB) {
|
||||
const versions = JSON.parse(await versionDB.text())
|
||||
if (versions.latestVersion) {
|
||||
const latestVersion = versions.versions[versions.latestVersion]
|
||||
const cacheData = {
|
||||
version: latestVersion.version,
|
||||
publishedAt: latestVersion.publishedAt,
|
||||
changelog: latestVersion.changelog,
|
||||
downloads: latestVersion.files
|
||||
.filter((file) => file.uploaded)
|
||||
.map((file) => ({
|
||||
name: file.name,
|
||||
url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`,
|
||||
size: formatFileSize(file.size)
|
||||
}))
|
||||
}
|
||||
// 重建缓存
|
||||
await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData))
|
||||
return new Response(JSON.stringify(cacheData), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// 如果没有任何数据,返回错误
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: '没有可用的版本信息'
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 返回缓存数据
|
||||
return new Response(await cached.text(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
await addLog(env, 'ERROR', '获取缓存版本信息失败', error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:只检查新版本并更新
|
||||
async function checkNewRelease(env) {
|
||||
try {
|
||||
// 获取 GitHub 最新版本
|
||||
const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', {
|
||||
headers: { 'User-Agent': 'CloudflareWorker' }
|
||||
})
|
||||
|
||||
if (!githubResponse.ok) {
|
||||
throw new Error('GitHub API 请求失败')
|
||||
}
|
||||
|
||||
const releaseData = await githubResponse.json()
|
||||
const version = releaseData.tag_name
|
||||
|
||||
// 获取版本数据库
|
||||
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB)
|
||||
let versions = { versions: {}, latestVersion: null, lastChecked: new Date().toISOString() }
|
||||
|
||||
if (versionDB) {
|
||||
versions = JSON.parse(await versionDB.text())
|
||||
}
|
||||
|
||||
// 移除版本检查,改为记录是否有文件更新的标志
|
||||
let hasUpdates = false
|
||||
if (versions.latestVersion !== version) {
|
||||
await addLog(env, 'INFO', `发现新版本: ${version}`)
|
||||
hasUpdates = true
|
||||
} else {
|
||||
await addLog(env, 'INFO', `版本 ${version} 文件完整性检查开始`)
|
||||
}
|
||||
|
||||
// 准备新版本记录
|
||||
const versionRecord = {
|
||||
version,
|
||||
publishedAt: releaseData.published_at,
|
||||
uploadedAt: null,
|
||||
files: releaseData.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
uploaded: false
|
||||
})),
|
||||
changelog: releaseData.body
|
||||
}
|
||||
|
||||
// 检查并上传文件
|
||||
for (const asset of releaseData.assets) {
|
||||
try {
|
||||
const existingFile = await env.R2_BUCKET.get(asset.name)
|
||||
// 检查文件是否存在且大小是否一致
|
||||
if (!existingFile || existingFile.size !== asset.size) {
|
||||
hasUpdates = true
|
||||
const response = await fetch(asset.browser_download_url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const file = await response.arrayBuffer()
|
||||
await env.R2_BUCKET.put(asset.name, file, {
|
||||
httpMetadata: { contentType: getContentType(asset.name) }
|
||||
})
|
||||
|
||||
// 更新文件状态
|
||||
const fileIndex = versionRecord.files.findIndex((f) => f.name === asset.name)
|
||||
if (fileIndex !== -1) {
|
||||
versionRecord.files[fileIndex].uploaded = true
|
||||
}
|
||||
|
||||
await addLog(env, 'INFO', `文件${existingFile ? '更新' : '上传'}成功: ${asset.name}`)
|
||||
} else {
|
||||
// 文件存在且大小相同,标记为已上传
|
||||
const fileIndex = versionRecord.files.findIndex((f) => f.name === asset.name)
|
||||
if (fileIndex !== -1) {
|
||||
versionRecord.files[fileIndex].uploaded = true
|
||||
}
|
||||
await addLog(env, 'INFO', `文件完整性验证通过: ${asset.name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
await addLog(env, 'ERROR', `文件处理失败: ${asset.name}`, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在有更新或是新版本时才更新数据库和缓存
|
||||
if (hasUpdates) {
|
||||
// 更新版本记录
|
||||
versionRecord.uploadedAt = new Date().toISOString()
|
||||
versions.versions[version] = versionRecord
|
||||
versions.latestVersion = version
|
||||
|
||||
// 保存版本数据库
|
||||
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2))
|
||||
|
||||
// 更新缓存
|
||||
const cacheData = {
|
||||
version,
|
||||
publishedAt: releaseData.published_at,
|
||||
changelog: releaseData.body,
|
||||
downloads: versionRecord.files
|
||||
.filter((file) => file.uploaded)
|
||||
.map((file) => ({
|
||||
name: file.name,
|
||||
url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`,
|
||||
size: formatFileSize(file.size)
|
||||
}))
|
||||
}
|
||||
|
||||
await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData))
|
||||
await addLog(env, 'INFO', hasUpdates ? '更新完成' : '文件完整性检查完成')
|
||||
|
||||
// 清理旧版本
|
||||
const versionList = Object.keys(versions.versions).sort((a, b) => compareVersions(b, a))
|
||||
if (versionList.length > 2) {
|
||||
// 获取需要保留的两个最新版本
|
||||
const keepVersions = versionList.slice(0, 2)
|
||||
// 获取所有需要删除的版本
|
||||
const oldVersions = versionList.slice(2)
|
||||
|
||||
// 先获取 R2 桶中的所有文件列表
|
||||
const allFiles = await listAllFiles(env)
|
||||
|
||||
// 获取需要保留的文件名列表
|
||||
const keepFiles = new Set()
|
||||
for (const keepVersion of keepVersions) {
|
||||
const versionFiles = versions.versions[keepVersion].files
|
||||
versionFiles.forEach((file) => keepFiles.add(file.name))
|
||||
}
|
||||
|
||||
// 删除所有旧版本文件
|
||||
for (const oldVersion of oldVersions) {
|
||||
const oldFiles = versions.versions[oldVersion].files
|
||||
for (const file of oldFiles) {
|
||||
try {
|
||||
if (file.uploaded) {
|
||||
await env.R2_BUCKET.delete(file.name)
|
||||
await addLog(env, 'INFO', `删除旧文件: ${file.name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
await addLog(env, 'ERROR', `删除旧文件失败: ${file.name}`, error.message)
|
||||
}
|
||||
}
|
||||
delete versions.versions[oldVersion]
|
||||
}
|
||||
|
||||
// 清理可能遗留的旧文件
|
||||
for (const file of allFiles) {
|
||||
if (!keepFiles.has(file.name)) {
|
||||
try {
|
||||
await env.R2_BUCKET.delete(file.name)
|
||||
await addLog(env, 'INFO', `删除遗留文件: ${file.name}`)
|
||||
} catch (error) {
|
||||
await addLog(env, 'ERROR', `删除遗留文件失败: ${file.name}`, error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存更新后的版本数据库
|
||||
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2))
|
||||
}
|
||||
} else {
|
||||
await addLog(env, 'INFO', '所有文件完整性检查通过,无需更新')
|
||||
}
|
||||
|
||||
return hasUpdates ? cacheData : null
|
||||
} catch (error) {
|
||||
await addLog(env, 'ERROR', '检查新版本失败', error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:获取 R2 桶中的所有文件列表
|
||||
async function listAllFiles(env) {
|
||||
const files = []
|
||||
let cursor
|
||||
|
||||
do {
|
||||
const listed = await env.R2_BUCKET.list({ cursor, include: ['customMetadata'] })
|
||||
files.push(...listed.objects)
|
||||
cursor = listed.cursor
|
||||
} while (cursor)
|
||||
|
||||
return files
|
||||
}
|
||||
58
scripts/remove-locales.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
exports.default = async function (context) {
|
||||
const platform = context.packager.platform.name
|
||||
|
||||
// 根据平台确定 locales 目录位置
|
||||
let resourceDirs = []
|
||||
if (platform === 'mac') {
|
||||
// macOS 的语言文件位置
|
||||
resourceDirs = [
|
||||
path.join(context.appOutDir, 'Cherry Studio.app', 'Contents', 'Resources'),
|
||||
path.join(
|
||||
context.appOutDir,
|
||||
'Cherry Studio.app',
|
||||
'Contents',
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Resources'
|
||||
)
|
||||
]
|
||||
} else {
|
||||
// Windows 和 Linux 的语言文件位置
|
||||
resourceDirs = [path.join(context.appOutDir, 'locales')]
|
||||
}
|
||||
|
||||
// 处理每个资源目录
|
||||
for (const resourceDir of resourceDirs) {
|
||||
if (!fs.existsSync(resourceDir)) {
|
||||
console.log(`Resource directory not found: ${resourceDir}, skipping...`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取所有文件和目录
|
||||
const items = fs.readdirSync(resourceDir)
|
||||
|
||||
// 遍历并删除不需要的语言文件
|
||||
for (const item of items) {
|
||||
if (platform === 'mac') {
|
||||
// 在 macOS 上检查 .lproj 目录
|
||||
if (item.endsWith('.lproj') && !item.match(/^(en|zh|ru)/)) {
|
||||
const dirPath = path.join(resourceDir, item)
|
||||
fs.rmSync(dirPath, { recursive: true, force: true })
|
||||
console.log(`Removed locale directory: ${item} from ${resourceDir}`)
|
||||
}
|
||||
} else {
|
||||
// 其他平台处理 .pak 文件
|
||||
if (!item.match(/^(en|zh|ru)/)) {
|
||||
const filePath = path.join(resourceDir, item)
|
||||
fs.unlinkSync(filePath)
|
||||
console.log(`Removed locale file: ${item} from ${resourceDir}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Locale cleanup completed!')
|
||||
}
|
||||
58
scripts/replace-spaces.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// replaceSpaces.js
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const directory = 'dist'
|
||||
|
||||
// 处理文件名中的空格
|
||||
function replaceFileNames() {
|
||||
fs.readdir(directory, (err, files) => {
|
||||
if (err) throw err
|
||||
|
||||
files.forEach((file) => {
|
||||
const oldPath = path.join(directory, file)
|
||||
const newPath = path.join(directory, file.replace(/ /g, '-'))
|
||||
|
||||
fs.stat(oldPath, (err, stats) => {
|
||||
if (err) throw err
|
||||
|
||||
if (stats.isFile() && oldPath !== newPath) {
|
||||
fs.rename(oldPath, newPath, (err) => {
|
||||
if (err) throw err
|
||||
console.log(`Renamed: ${oldPath} -> ${newPath}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function replaceYmlContent() {
|
||||
fs.readdir(directory, (err, files) => {
|
||||
if (err) throw err
|
||||
|
||||
files.forEach((file) => {
|
||||
if (path.extname(file).toLowerCase() === '.yml') {
|
||||
const filePath = path.join(directory, file)
|
||||
|
||||
fs.readFile(filePath, 'utf8', (err, data) => {
|
||||
if (err) throw err
|
||||
|
||||
// 替换内容
|
||||
const newContent = data.replace(/Cherry Studio-/g, 'Cherry-Studio-')
|
||||
|
||||
// 写回文件
|
||||
fs.writeFile(filePath, newContent, 'utf8', (err) => {
|
||||
if (err) throw err
|
||||
console.log(`Updated content in: ${filePath}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 执行两个操作
|
||||
replaceFileNames()
|
||||
replaceYmlContent()
|
||||
@@ -1,26 +0,0 @@
|
||||
// replaceSpaces.js
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const directory = 'dist'
|
||||
|
||||
fs.readdir(directory, (err, files) => {
|
||||
if (err) throw err
|
||||
|
||||
files.forEach((file) => {
|
||||
const oldPath = path.join(directory, file)
|
||||
const newPath = path.join(directory, file.replace(/ /g, '-'))
|
||||
|
||||
fs.stat(oldPath, (err, stats) => {
|
||||
if (err) throw err
|
||||
|
||||
if (stats.isFile() && oldPath !== newPath) {
|
||||
fs.rename(oldPath, newPath, (err) => {
|
||||
if (err) throw err
|
||||
console.log(`Renamed: ${oldPath} -> ${newPath}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
39
scripts/utils.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
|
||||
function downloadNpmPackage(packageName, url) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-'))
|
||||
|
||||
const targetDir = path.join('./node_modules/', packageName)
|
||||
const filename = packageName.replace('/', '-') + '.tgz'
|
||||
|
||||
// Skip if directory already exists
|
||||
if (fs.existsSync(targetDir)) {
|
||||
console.log(`${targetDir} already exists, skipping download...`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Downloading ${packageName}...`, url)
|
||||
const { execSync } = require('child_process')
|
||||
execSync(`curl --fail -o ${filename} ${url}`)
|
||||
|
||||
console.log(`Extracting ${filename}...`)
|
||||
execSync(`tar -xvf ${filename}`)
|
||||
execSync(`rm -rf ${filename}`)
|
||||
execSync(`mv package ${targetDir}`)
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${packageName}: ${error.message}`)
|
||||
if (fs.existsSync(filename)) {
|
||||
fs.unlinkSync(filename)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadNpmPackage
|
||||
}
|
||||
@@ -1,25 +1,15 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import path from 'path'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
isDev && app.setPath('userData', app.getPath('userData') + 'Dev')
|
||||
|
||||
const getDataPath = () => {
|
||||
const dataPath = path.join(app.getPath('userData'), 'Data')
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true })
|
||||
}
|
||||
return dataPath
|
||||
if (isDev) {
|
||||
app.setPath('userData', app.getPath('userData') + 'Dev')
|
||||
}
|
||||
|
||||
export const DATA_PATH = getDataPath()
|
||||
|
||||
export const appConfig = new Store()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
color: '#00000000',
|
||||
|
||||
3
src/main/constant.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const isMac = process.platform === 'darwin'
|
||||
export const isWin = process.platform === 'win32'
|
||||
export const isLinux = process.platform === 'linux'
|
||||
9
src/main/electron.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
namespace Electron {
|
||||
interface App {
|
||||
isQuitting: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -3,50 +3,67 @@ import { app, BrowserWindow } from 'electron'
|
||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { updateUserDataPath } from './utils/upgrade'
|
||||
import { createMainWindow } from './window'
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
await updateUserDataPath()
|
||||
// Check for single instance lock
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
} else {
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
await updateUserDataPath()
|
||||
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||
|
||||
const mainWindow = windowService.createMainWindow()
|
||||
new TrayService()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
windowService.createMainWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
|
||||
registerShortcuts(mainWindow)
|
||||
|
||||
registerIpc(mainWindow, app)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
installExtension(REDUX_DEVTOOLS)
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for second instance
|
||||
app.on('second-instance', () => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0]
|
||||
if (mainWindow) {
|
||||
mainWindow.isMinimized() && mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||||
app.on('before-quit', () => {
|
||||
app.isQuitting = true
|
||||
})
|
||||
|
||||
const mainWindow = createMainWindow()
|
||||
|
||||
registerIpc(mainWindow, app)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
installExtension(REDUX_DEVTOOLS)
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app"s specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
// In this file you can include the rest of your app"s specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
}
|
||||
|
||||
160
src/main/ipc.ts
@@ -1,55 +1,125 @@
|
||||
import { FileType } from '@types'
|
||||
import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'electron'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import FileManager from './services/FileManager'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
import { createMinappWindow } from './window'
|
||||
|
||||
const fileManager = new FileManager()
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const { autoUpdater } = new AppUpdater(mainWindow)
|
||||
|
||||
// IPC
|
||||
ipcMain.handle('get-app-info', () => ({
|
||||
ipcMain.handle('app:info', () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
appPath: app.getAppPath()
|
||||
appPath: app.getAppPath(),
|
||||
filesPath: path.join(app.getPath('userData'), 'Data', 'Files'),
|
||||
appDataPath: app.getPath('userData'),
|
||||
logsPath: log.transports.file.getFile().path
|
||||
}))
|
||||
|
||||
ipcMain.handle('open-website', (_, url: string) => {
|
||||
shell.openExternal(url)
|
||||
ipcMain.handle('app:proxy', async (_, proxy: string) => {
|
||||
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
|
||||
const proxyConfig: ProxyConfig = proxy === 'system' ? { mode: 'system' } : proxy ? { proxyRules: proxy } : {}
|
||||
await Promise.all(sessions.map((session) => session.setProxy(proxyConfig)))
|
||||
})
|
||||
|
||||
ipcMain.handle('set-proxy', (_, proxy: string) => {
|
||||
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
|
||||
ipcMain.handle('app:reload', () => mainWindow.reload())
|
||||
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// language
|
||||
ipcMain.handle('app:set-language', (_, language) => {
|
||||
configManager.setLanguage(language)
|
||||
})
|
||||
|
||||
ipcMain.handle('reload', () => mainWindow.reload())
|
||||
// tray
|
||||
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
|
||||
configManager.setTray(isActive)
|
||||
})
|
||||
|
||||
// theme
|
||||
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => {
|
||||
configManager.setTheme(theme)
|
||||
mainWindow?.setTitleBarOverlay &&
|
||||
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
})
|
||||
|
||||
// clear cache
|
||||
ipcMain.handle('app:clear-cache', async () => {
|
||||
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
await session.clearCache()
|
||||
await session.clearStorageData({
|
||||
storages: ['cookies', 'filesystem', 'shadercache', 'websql', 'serviceworkers', 'cachestorage']
|
||||
})
|
||||
})
|
||||
)
|
||||
await fileManager.clearTemp()
|
||||
await fs.writeFileSync(log.transports.file.getFile().path, '')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
log.error('Failed to clear cache:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// check for update
|
||||
ipcMain.handle('app:check-for-update', async () => {
|
||||
const update = await autoUpdater.checkForUpdates()
|
||||
return {
|
||||
currentVersion: autoUpdater.currentVersion,
|
||||
updateInfo: update?.updateInfo
|
||||
}
|
||||
})
|
||||
|
||||
// zip
|
||||
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
||||
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
||||
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
ipcMain.handle('file:save', fileManager.save)
|
||||
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
||||
ipcMain.handle('file:base64Image', async (_, id) => await fileManager.base64Image(id))
|
||||
ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options))
|
||||
ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file))
|
||||
ipcMain.handle('file:clear', async () => await fileManager.clear())
|
||||
ipcMain.handle('file:read', async (_, id: string) => await fileManager.readFile(id))
|
||||
ipcMain.handle('file:delete', async (_, id: string) => await fileManager.deleteFile(id))
|
||||
ipcMain.handle('file:get', async (_, filePath: string) => await fileManager.getFile(filePath))
|
||||
ipcMain.handle('file:create', async (_, fileName: string) => await fileManager.createTempFile(fileName))
|
||||
ipcMain.handle(
|
||||
'file:write',
|
||||
async (_, filePath: string, data: Uint8Array | string) => await fileManager.writeFile(filePath, data)
|
||||
)
|
||||
// backup
|
||||
ipcMain.handle('backup:backup', backupManager.backup)
|
||||
ipcMain.handle('backup:restore', backupManager.restore)
|
||||
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
|
||||
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
|
||||
|
||||
// file
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
ipcMain.handle('file:openPath', fileManager.openPath)
|
||||
ipcMain.handle('file:save', fileManager.save)
|
||||
ipcMain.handle('file:select', fileManager.selectFile)
|
||||
ipcMain.handle('file:upload', fileManager.uploadFile)
|
||||
ipcMain.handle('file:clear', fileManager.clear)
|
||||
ipcMain.handle('file:read', fileManager.readFile)
|
||||
ipcMain.handle('file:delete', fileManager.deleteFile)
|
||||
ipcMain.handle('file:get', fileManager.getFile)
|
||||
ipcMain.handle('file:selectFolder', fileManager.selectFolder)
|
||||
ipcMain.handle('file:create', fileManager.createTempFile)
|
||||
ipcMain.handle('file:write', fileManager.writeFile)
|
||||
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
||||
ipcMain.handle('file:base64Image', fileManager.base64Image)
|
||||
ipcMain.handle('file:download', fileManager.downloadFile)
|
||||
ipcMain.handle('file:copy', fileManager.copyFile)
|
||||
|
||||
// minapp
|
||||
ipcMain.handle('minapp', (_, args) => {
|
||||
createMinappWindow({
|
||||
windowService.createMinappWindow({
|
||||
url: args.url,
|
||||
parent: mainWindow,
|
||||
windowOptions: {
|
||||
@@ -59,17 +129,29 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => {
|
||||
appConfig.set('theme', theme)
|
||||
mainWindow?.setTitleBarOverlay &&
|
||||
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
// export
|
||||
ipcMain.handle('export:word', exportService.exportToWord)
|
||||
|
||||
// open path
|
||||
ipcMain.handle('open:path', async (_, path: string) => {
|
||||
await shell.openPath(path)
|
||||
})
|
||||
|
||||
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
|
||||
ipcMain.handle('check-for-update', async () => {
|
||||
return {
|
||||
currentVersion: autoUpdater.currentVersion,
|
||||
update: await autoUpdater.checkForUpdates()
|
||||
// shortcuts
|
||||
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
|
||||
configManager.setShortcuts(shortcuts)
|
||||
// Refresh shortcuts registration
|
||||
if (mainWindow) {
|
||||
unregisterAllShortcuts()
|
||||
registerShortcuts(mainWindow)
|
||||
}
|
||||
})
|
||||
|
||||
// knowledge base
|
||||
ipcMain.handle('knowledge-base:create', KnowledgeService.create)
|
||||
ipcMain.handle('knowledge-base:reset', KnowledgeService.reset)
|
||||
ipcMain.handle('knowledge-base:delete', KnowledgeService.delete)
|
||||
ipcMain.handle('knowledge-base:add', KnowledgeService.add)
|
||||
ipcMain.handle('knowledge-base:remove', KnowledgeService.remove)
|
||||
ipcMain.handle('knowledge-base:search', KnowledgeService.search)
|
||||
}
|
||||
|
||||
BIN
src/main/resources/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
@@ -1,15 +1,18 @@
|
||||
import { BrowserWindow, dialog } from 'electron'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, UpdateInfo } from 'electron-updater'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
logger.transports.file.level = 'debug'
|
||||
logger.transports.file.level = 'info'
|
||||
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.forceDevUpdateConfig = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = true
|
||||
|
||||
// 检测下载错误
|
||||
autoUpdater.on('error', (error) => {
|
||||
@@ -18,38 +21,8 @@ export default class AppUpdater {
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
autoUpdater.logger?.info('检测到新版本,确认是否下载')
|
||||
logger.info('检测到新版本', releaseInfo)
|
||||
mainWindow.webContents.send('update-available', releaseInfo)
|
||||
const releaseNotes = releaseInfo.releaseNotes
|
||||
let releaseContent = ''
|
||||
if (releaseNotes) {
|
||||
if (typeof releaseNotes === 'string') {
|
||||
releaseContent = <string>releaseNotes
|
||||
} else if (releaseNotes instanceof Array) {
|
||||
releaseNotes.forEach((releaseNote) => {
|
||||
releaseContent += `${releaseNote}\n`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
releaseContent = '暂无更新说明'
|
||||
}
|
||||
|
||||
// 弹框确认是否下载更新(releaseContent是更新日志)
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: '应用有新的更新',
|
||||
detail: releaseContent,
|
||||
message: '发现新版本,是否现在更新?',
|
||||
buttons: ['下次再说', '更新']
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (response === 1) {
|
||||
logger.info('用户选择更新,准备下载更新')
|
||||
mainWindow.webContents.send('download-update')
|
||||
autoUpdater.downloadUpdate()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 检测到不需要更新时
|
||||
@@ -59,23 +32,53 @@ export default class AppUpdater {
|
||||
|
||||
// 更新下载进度
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
logger.info('下载进度', progress)
|
||||
mainWindow.webContents.send('download-progress', progress)
|
||||
})
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
logger.info('下载完成,准备更新')
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
mainWindow.webContents.send('update-downloaded')
|
||||
|
||||
logger.info('下载完成,询问用户是否更新', releaseInfo)
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: '安装更新',
|
||||
message: '更新下载完毕,应用将重启并进行安装'
|
||||
icon,
|
||||
message: `新版本 ${releaseInfo.version} 已准备就绪`,
|
||||
detail: this.formatReleaseNotes(releaseInfo.releaseNotes),
|
||||
buttons: ['稍后安装', '立即安装'],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
.then(() => {
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
.then(({ response }) => {
|
||||
if (response === 1) {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
} else {
|
||||
mainWindow.webContents.send('update-downloaded-cancelled')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return '暂无更新说明'
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
interface ReleaseNoteInfo {
|
||||
readonly version: string
|
||||
readonly note: string | null
|
||||
}
|
||||
|
||||
121
src/main/services/BackupManager.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
|
||||
import WebDav from './WebDav'
|
||||
|
||||
class BackupManager {
|
||||
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
|
||||
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
|
||||
|
||||
constructor() {
|
||||
this.backup = this.backup.bind(this)
|
||||
this.restore = this.restore.bind(this)
|
||||
this.backupToWebdav = this.backupToWebdav.bind(this)
|
||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||
}
|
||||
|
||||
async backup(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
data: string,
|
||||
destinationPath: string = this.backupDir
|
||||
): Promise<string> {
|
||||
try {
|
||||
await fs.ensureDir(this.tempDir)
|
||||
|
||||
// 将 data 写入临时文件
|
||||
const tempDataPath = path.join(this.tempDir, 'data.json')
|
||||
await fs.writeFile(tempDataPath, data)
|
||||
|
||||
// 复制 Data 目录到临时目录
|
||||
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
||||
const tempDataDir = path.join(this.tempDir, 'Data')
|
||||
await fs.copy(sourcePath, tempDataDir)
|
||||
|
||||
// 使用 adm-zip 创建压缩文件
|
||||
const zip = new AdmZip()
|
||||
zip.addLocalFolder(this.tempDir)
|
||||
const backupedFilePath = path.join(destinationPath, fileName)
|
||||
zip.writeZip(backupedFilePath)
|
||||
|
||||
// 清理临时目录
|
||||
await fs.remove(this.tempDir)
|
||||
|
||||
Logger.log('Backup completed successfully')
|
||||
return backupedFilePath
|
||||
} catch (error) {
|
||||
Logger.error('Backup failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
|
||||
try {
|
||||
// 创建临时目录
|
||||
await fs.ensureDir(this.tempDir)
|
||||
|
||||
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
|
||||
|
||||
// 使用 adm-zip 解压
|
||||
const zip = new AdmZip(backupPath)
|
||||
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
|
||||
|
||||
Logger.log('[backup] step 2: read data.json')
|
||||
|
||||
// 读取 data.json
|
||||
const dataPath = path.join(this.tempDir, 'data.json')
|
||||
const data = await fs.readFile(dataPath, 'utf-8')
|
||||
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
await fs.remove(destPath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
|
||||
Logger.log('[backup] step 4: clean up temp directory')
|
||||
|
||||
// 清理临时目录
|
||||
await fs.remove(this.tempDir)
|
||||
|
||||
Logger.log('[backup] step 5: Restore completed successfully')
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
Logger.error('[backup] Restore failed:', error)
|
||||
await fs.remove(this.tempDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data)
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
overwrite: true
|
||||
})
|
||||
}
|
||||
|
||||
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
const filename = 'cherry-studio.backup.zip'
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const retrievedFile = await webdavClient.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
|
||||
|
||||
return await this.restore(_, backupedFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
88
src/main/services/ConfigManager.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { 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'
|
||||
|
||||
export class ConfigManager {
|
||||
private store: Store
|
||||
private subscribers: Map<string, Array<(newValue: any) => void>> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.store = new Store()
|
||||
}
|
||||
|
||||
getLanguage(): LanguageVarious {
|
||||
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : 'en-US'
|
||||
return this.store.get('language', locale) as LanguageVarious
|
||||
}
|
||||
|
||||
setLanguage(theme: LanguageVarious) {
|
||||
this.store.set('language', theme)
|
||||
}
|
||||
|
||||
getTheme(): ThemeMode {
|
||||
return this.store.get('theme', ThemeMode.light) as ThemeMode
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
this.store.set('theme', theme)
|
||||
}
|
||||
|
||||
isTray(): boolean {
|
||||
return !!this.store.get('tray', true)
|
||||
}
|
||||
|
||||
setTray(value: boolean) {
|
||||
this.store.set('tray', value)
|
||||
this.notifySubscribers('tray', value)
|
||||
}
|
||||
|
||||
getZoomFactor(): number {
|
||||
return this.store.get('zoomFactor', 1) as number
|
||||
}
|
||||
|
||||
setZoomFactor(factor: number) {
|
||||
this.store.set('zoomFactor', factor)
|
||||
this.notifySubscribers('zoomFactor', factor)
|
||||
}
|
||||
|
||||
subscribe<T>(key: string, callback: (newValue: T) => void) {
|
||||
if (!this.subscribers.has(key)) {
|
||||
this.subscribers.set(key, [])
|
||||
}
|
||||
this.subscribers.get(key)!.push(callback)
|
||||
}
|
||||
|
||||
unsubscribe<T>(key: string, callback: (newValue: T) => void) {
|
||||
const subscribers = this.subscribers.get(key)
|
||||
if (subscribers) {
|
||||
this.subscribers.set(
|
||||
key,
|
||||
subscribers.filter((subscriber) => subscriber !== callback)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private notifySubscribers<T>(key: string, newValue: T) {
|
||||
const subscribers = this.subscribers.get(key)
|
||||
if (subscribers) {
|
||||
subscribers.forEach((subscriber) => subscriber(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
getShortcuts() {
|
||||
return this.store.get('shortcuts', ZOOM_SHORTCUTS) as Shortcut[] | []
|
||||
}
|
||||
|
||||
setShortcuts(shortcuts: Shortcut[]) {
|
||||
this.store.set(
|
||||
'shortcuts',
|
||||
shortcuts.filter((shortcut) => shortcut.system)
|
||||
)
|
||||
this.notifySubscribers('shortcuts', shortcuts)
|
||||
}
|
||||
}
|
||||
|
||||
export const configManager = new ConfigManager()
|
||||
222
src/main/services/ExportService.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
// ExportService
|
||||
|
||||
import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, ShadingType, TextRun } from 'docx'
|
||||
import { dialog } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
import FileStorage from './FileStorage'
|
||||
|
||||
export class ExportService {
|
||||
private fileManager: FileStorage
|
||||
private md: MarkdownIt
|
||||
|
||||
constructor(fileManager: FileStorage) {
|
||||
this.fileManager = fileManager
|
||||
this.md = new MarkdownIt()
|
||||
}
|
||||
|
||||
private convertMarkdownToDocxElements(markdown: string) {
|
||||
const tokens = this.md.parse(markdown, {})
|
||||
const elements: any[] = []
|
||||
let listLevel = 0
|
||||
|
||||
const processInlineTokens = (tokens: any[]): TextRun[] => {
|
||||
const runs: TextRun[] = []
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
runs.push(new TextRun(token.content))
|
||||
break
|
||||
case 'strong':
|
||||
runs.push(new TextRun({ text: token.content, bold: true }))
|
||||
break
|
||||
case 'em':
|
||||
runs.push(new TextRun({ text: token.content, italics: true }))
|
||||
break
|
||||
case 'code_inline':
|
||||
runs.push(new TextRun({ text: token.content, font: 'Consolas', size: 20 }))
|
||||
break
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]
|
||||
|
||||
switch (token.type) {
|
||||
case 'heading_open':
|
||||
// 获取标题级别 (h1 -> h6)
|
||||
const level = parseInt(token.tag.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6
|
||||
const headingText = tokens[i + 1].content
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
text: headingText,
|
||||
heading: HeadingLevel[`HEADING_${level}`],
|
||||
spacing: {
|
||||
before: 240,
|
||||
after: 120
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 2 // 跳过内容标记和闭合标记
|
||||
break
|
||||
|
||||
case 'paragraph_open':
|
||||
const inlineTokens = tokens[i + 1].children || []
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: processInlineTokens(inlineTokens),
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 120
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 2
|
||||
break
|
||||
|
||||
case 'bullet_list_open':
|
||||
listLevel++
|
||||
break
|
||||
|
||||
case 'bullet_list_close':
|
||||
listLevel--
|
||||
break
|
||||
|
||||
case 'list_item_open':
|
||||
const itemInlineTokens = tokens[i + 2].children || []
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: '•', bold: true }),
|
||||
new TextRun({ text: '\t' }),
|
||||
...processInlineTokens(itemInlineTokens)
|
||||
],
|
||||
indent: {
|
||||
left: listLevel * 720
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 3
|
||||
break
|
||||
|
||||
case 'fence': // 代码块
|
||||
const codeLines = token.content.split('\n')
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: codeLines.map(
|
||||
(line) =>
|
||||
new TextRun({
|
||||
text: line + '\n',
|
||||
font: 'Consolas',
|
||||
size: 20,
|
||||
break: 1
|
||||
})
|
||||
),
|
||||
shading: {
|
||||
type: ShadingType.SOLID,
|
||||
color: 'F5F5F5'
|
||||
},
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 120
|
||||
},
|
||||
border: {
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
|
||||
left: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
|
||||
right: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' }
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'hr':
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: '─'.repeat(50), color: '999999' })],
|
||||
alignment: AlignmentType.CENTER
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'blockquote_open':
|
||||
const quoteText = tokens[i + 2].content
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: quoteText,
|
||||
italics: true
|
||||
})
|
||||
],
|
||||
indent: {
|
||||
left: 720
|
||||
},
|
||||
border: {
|
||||
left: {
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 3,
|
||||
color: 'CCCCCC'
|
||||
}
|
||||
},
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 120
|
||||
}
|
||||
})
|
||||
)
|
||||
i += 3
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
public exportToWord = async (_: Electron.IpcMainInvokeEvent, markdown: string, fileName: string): Promise<void> => {
|
||||
try {
|
||||
const elements = this.convertMarkdownToDocxElements(markdown)
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
paragraphStyles: [
|
||||
{
|
||||
id: 'Normal',
|
||||
name: 'Normal',
|
||||
run: {
|
||||
size: 24,
|
||||
font: 'Arial'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
properties: {},
|
||||
children: elements
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const buffer = await Packer.toBuffer(doc)
|
||||
|
||||
const filePath = dialog.showSaveDialogSync({
|
||||
title: '保存文件',
|
||||
filters: [{ name: 'Word Document', extensions: ['docx'] }],
|
||||
defaultPath: fileName
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
await this.fileManager.writeFile(_, filePath, buffer)
|
||||
Logger.info('[ExportService] Document exported successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[ExportService] Export to Word failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { getFileType } from '@main/utils/file'
|
||||
import { FileType } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
OpenDialogOptions,
|
||||
OpenDialogReturnValue,
|
||||
SaveDialogOptions,
|
||||
SaveDialogReturnValue
|
||||
} from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import * as fs from 'fs'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
class FileManager {
|
||||
private storageDir: string
|
||||
|
||||
constructor() {
|
||||
this.storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
this.initStorageDir()
|
||||
}
|
||||
|
||||
private initStorageDir(): void {
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async getFileHash(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('md5')
|
||||
const stream = fs.createReadStream(filePath)
|
||||
stream.on('data', (data) => hash.update(data))
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
stream.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async findDuplicateFile(filePath: string): Promise<FileType | null> {
|
||||
const stats = fs.statSync(filePath)
|
||||
const fileSize = stats.size
|
||||
|
||||
const files = await fs.promises.readdir(this.storageDir)
|
||||
for (const file of files) {
|
||||
const storedFilePath = path.join(this.storageDir, file)
|
||||
const storedStats = fs.statSync(storedFilePath)
|
||||
|
||||
if (storedStats.size === fileSize) {
|
||||
const [originalHash, storedHash] = await Promise.all([
|
||||
this.getFileHash(filePath),
|
||||
this.getFileHash(storedFilePath)
|
||||
])
|
||||
|
||||
if (originalHash === storedHash) {
|
||||
const ext = path.extname(file)
|
||||
const id = path.basename(file, ext)
|
||||
return {
|
||||
id,
|
||||
origin_name: file,
|
||||
name: file + ext,
|
||||
path: storedFilePath,
|
||||
created_at: storedStats.birthtime,
|
||||
size: storedStats.size,
|
||||
ext,
|
||||
type: getFileType(ext),
|
||||
count: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async selectFile(options?: OpenDialogOptions): Promise<FileType[] | null> {
|
||||
const defaultOptions: OpenDialogOptions = {
|
||||
properties: ['openFile']
|
||||
}
|
||||
|
||||
const dialogOptions = { ...defaultOptions, ...options }
|
||||
|
||||
const result = await dialog.showOpenDialog(dialogOptions)
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fileMetadataPromises = result.filePaths.map(async (filePath) => {
|
||||
const stats = fs.statSync(filePath)
|
||||
const ext = path.extname(filePath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
origin_name: path.basename(filePath),
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.all(fileMetadataPromises)
|
||||
}
|
||||
|
||||
async uploadFile(file: FileType): Promise<FileType> {
|
||||
const duplicateFile = await this.findDuplicateFile(file.path)
|
||||
|
||||
if (duplicateFile) {
|
||||
return duplicateFile
|
||||
}
|
||||
|
||||
const uuid = uuidv4()
|
||||
const origin_name = path.basename(file.path)
|
||||
const ext = path.extname(origin_name)
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
await fs.promises.copyFile(file.path, destPath)
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
id: uuid,
|
||||
origin_name,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
|
||||
async getFile(filePath: string): Promise<FileType | null> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath)
|
||||
const ext = path.extname(filePath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileInfo: FileType = {
|
||||
id: uuidv4(),
|
||||
origin_name: path.basename(filePath),
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileInfo
|
||||
}
|
||||
|
||||
async deleteFile(id: string): Promise<void> {
|
||||
await fs.promises.unlink(path.join(this.storageDir, id))
|
||||
}
|
||||
|
||||
async readFile(id: string): Promise<string> {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
}
|
||||
|
||||
async createTempFile(fileName: string): Promise<string> {
|
||||
const tempDir = path.join(app.getPath('temp'), 'CherryStudio')
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
const tempFilePath = path.join(tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||||
return tempFilePath
|
||||
}
|
||||
|
||||
async writeFile(filePath: string, data: Uint8Array | string): Promise<void> {
|
||||
await fs.promises.writeFile(filePath, data)
|
||||
}
|
||||
|
||||
async base64Image(id: string): Promise<{ mime: string; base64: string; data: string }> {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
const data = await fs.promises.readFile(filePath)
|
||||
const base64 = data.toString('base64')
|
||||
const mime = `image/${path.extname(filePath).slice(1)}`
|
||||
return {
|
||||
mime,
|
||||
base64,
|
||||
data: `data:${mime};base64,${base64}`
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await fs.promises.rmdir(this.storageDir, { recursive: true })
|
||||
await this.initStorageDir()
|
||||
}
|
||||
|
||||
async open(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; content: Buffer } | null> {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, content }
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async save(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
defaultPath: fileName,
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async saveImage(_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise<void> {
|
||||
try {
|
||||
const filePath = dialog.showSaveDialogSync({
|
||||
defaultPath: `${name}.png`,
|
||||
filters: [{ name: 'PNG Image', extensions: ['png'] }]
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
const base64Data = data.replace(/^data:image\/png;base64,/, '')
|
||||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the image:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileManager
|
||||
465
src/main/services/FileStorage.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { getFileType } from '@main/utils/file'
|
||||
import { documentExts, imageExts } from '@shared/config/constant'
|
||||
import { FileType } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
OpenDialogOptions,
|
||||
OpenDialogReturnValue,
|
||||
SaveDialogOptions,
|
||||
SaveDialogReturnValue,
|
||||
shell
|
||||
} from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import * as fs from 'fs'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import officeParser from 'officeparser'
|
||||
import * as path from 'path'
|
||||
import { chdir } from 'process'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
class FileStorage {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
private tempDir = path.join(app.getPath('temp'), 'CherryStudio')
|
||||
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
}
|
||||
|
||||
private initStorageDir = (): void => {
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private getFileHash = async (filePath: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('md5')
|
||||
const stream = fs.createReadStream(filePath)
|
||||
stream.on('data', (data) => hash.update(data))
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
stream.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
findDuplicateFile = async (filePath: string): Promise<FileType | null> => {
|
||||
const stats = fs.statSync(filePath)
|
||||
const fileSize = stats.size
|
||||
|
||||
const files = await fs.promises.readdir(this.storageDir)
|
||||
for (const file of files) {
|
||||
const storedFilePath = path.join(this.storageDir, file)
|
||||
const storedStats = fs.statSync(storedFilePath)
|
||||
|
||||
if (storedStats.size === fileSize) {
|
||||
const [originalHash, storedHash] = await Promise.all([
|
||||
this.getFileHash(filePath),
|
||||
this.getFileHash(storedFilePath)
|
||||
])
|
||||
|
||||
if (originalHash === storedHash) {
|
||||
const ext = path.extname(file)
|
||||
const id = path.basename(file, ext)
|
||||
return {
|
||||
id,
|
||||
origin_name: file,
|
||||
name: file + ext,
|
||||
path: storedFilePath,
|
||||
created_at: storedStats.birthtime,
|
||||
size: storedStats.size,
|
||||
ext,
|
||||
type: getFileType(ext),
|
||||
count: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public selectFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options?: OpenDialogOptions
|
||||
): Promise<FileType[] | null> => {
|
||||
const defaultOptions: OpenDialogOptions = {
|
||||
properties: ['openFile']
|
||||
}
|
||||
|
||||
const dialogOptions = { ...defaultOptions, ...options }
|
||||
|
||||
const result = await dialog.showOpenDialog(dialogOptions)
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fileMetadataPromises = result.filePaths.map(async (filePath) => {
|
||||
const stats = fs.statSync(filePath)
|
||||
const ext = path.extname(filePath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
origin_name: path.basename(filePath),
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.all(fileMetadataPromises)
|
||||
}
|
||||
|
||||
private async compressImage(sourcePath: string, destPath: string): Promise<void> {
|
||||
try {
|
||||
const stats = fs.statSync(sourcePath)
|
||||
const fileSizeInMB = stats.size / (1024 * 1024)
|
||||
|
||||
// 如果图片大于1MB才进行压缩
|
||||
if (fileSizeInMB > 1) {
|
||||
try {
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
logger.info('[FileStorage] Image compressed successfully:', sourcePath)
|
||||
} catch (jimpError) {
|
||||
logger.error('[FileStorage] Image compression failed:', jimpError)
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
}
|
||||
} else {
|
||||
// 小图片直接复制
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Image handling failed:', error)
|
||||
// 错误情况下直接复制原文件
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
}
|
||||
}
|
||||
|
||||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileType): Promise<FileType> => {
|
||||
const duplicateFile = await this.findDuplicateFile(file.path)
|
||||
|
||||
if (duplicateFile) {
|
||||
return duplicateFile
|
||||
}
|
||||
|
||||
const uuid = uuidv4()
|
||||
const origin_name = path.basename(file.path)
|
||||
const ext = path.extname(origin_name).toLowerCase()
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
logger.info('[FileStorage] Uploading file:', file.path)
|
||||
|
||||
// 根据文件类型选择处理方式
|
||||
if (imageExts.includes(ext)) {
|
||||
await this.compressImage(file.path, destPath)
|
||||
} else {
|
||||
await fs.promises.copyFile(file.path, destPath)
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
id: uuid,
|
||||
origin_name,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
|
||||
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileType | null> => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath)
|
||||
const ext = path.extname(filePath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileInfo: FileType = {
|
||||
id: uuidv4(),
|
||||
origin_name: path.basename(filePath),
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileInfo
|
||||
}
|
||||
|
||||
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||
await fs.promises.unlink(path.join(this.storageDir, id))
|
||||
}
|
||||
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
if (documentExts.includes(path.extname(filePath))) {
|
||||
const originalCwd = process.cwd()
|
||||
try {
|
||||
chdir(this.tempDir)
|
||||
const data = await officeParser.parseOfficeAsync(filePath)
|
||||
chdir(originalCwd)
|
||||
return data
|
||||
} catch (error) {
|
||||
chdir(originalCwd)
|
||||
logger.error(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
}
|
||||
|
||||
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||
}
|
||||
const tempFilePath = path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||||
return tempFilePath
|
||||
}
|
||||
|
||||
public writeFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
filePath: string,
|
||||
data: Uint8Array | string
|
||||
): Promise<void> => {
|
||||
await fs.promises.writeFile(filePath, data)
|
||||
}
|
||||
|
||||
public base64Image = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
id: string
|
||||
): Promise<{ mime: string; base64: string; data: string }> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
const data = await fs.promises.readFile(filePath)
|
||||
const base64 = data.toString('base64')
|
||||
const mime = `image/${path.extname(filePath).slice(1)}`
|
||||
return {
|
||||
mime,
|
||||
base64,
|
||||
data: `data:${mime};base64,${base64}`
|
||||
}
|
||||
}
|
||||
|
||||
public clear = async (): Promise<void> => {
|
||||
await fs.promises.rmdir(this.storageDir, { recursive: true })
|
||||
await this.initStorageDir()
|
||||
}
|
||||
|
||||
public clearTemp = async (): Promise<void> => {
|
||||
await fs.promises.rmdir(this.tempDir, { recursive: true })
|
||||
await fs.promises.mkdir(this.tempDir, { recursive: true })
|
||||
}
|
||||
|
||||
public open = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, filePath, content }
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
||||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
}
|
||||
|
||||
public save = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
defaultPath: fileName,
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||
}
|
||||
|
||||
return result.filePath
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public saveImage = async (_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise<void> => {
|
||||
try {
|
||||
const filePath = dialog.showSaveDialogSync({
|
||||
defaultPath: `${name}.png`,
|
||||
filters: [{ name: 'PNG Image', extensions: ['png'] }]
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
const base64Data = data.replace(/^data:image\/png;base64,/, '')
|
||||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the image:', error)
|
||||
}
|
||||
}
|
||||
|
||||
public selectFolder = async (_: Electron.IpcMainInvokeEvent, options: OpenDialogOptions): Promise<string | null> => {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '选择文件夹',
|
||||
properties: ['openDirectory'],
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
return result.filePaths[0]
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred selecting the folder:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise<FileType> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
// 尝试从Content-Disposition获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let filename = 'download'
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 如果URL中有文件名,使用URL中的文件名
|
||||
const urlFilename = url.split('/').pop()
|
||||
if (urlFilename && urlFilename.includes('.')) {
|
||||
filename = urlFilename
|
||||
}
|
||||
|
||||
// 如果文件名没有后缀,根据Content-Type添加后缀
|
||||
if (!filename.includes('.')) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
const ext = this.getExtensionFromMimeType(contentType)
|
||||
filename += ext
|
||||
}
|
||||
|
||||
const uuid = uuidv4()
|
||||
const ext = path.extname(filename)
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
// 将响应内容写入文件
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
id: uuid,
|
||||
origin_name: filename,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: stats.birthtime,
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: fileType,
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Download file error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private getExtensionFromMimeType(mimeType: string | null): string {
|
||||
if (!mimeType) return '.bin'
|
||||
|
||||
const mimeToExtension: { [key: string]: string } = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'application/pdf': '.pdf',
|
||||
'text/plain': '.txt',
|
||||
'application/msword': '.doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||||
'application/zip': '.zip',
|
||||
'application/x-zip-compressed': '.zip',
|
||||
'application/octet-stream': '.bin'
|
||||
}
|
||||
|
||||
return mimeToExtension[mimeType] || '.bin'
|
||||
}
|
||||
|
||||
public copyFile = async (_: Electron.IpcMainInvokeEvent, id: string, destPath: string): Promise<void> => {
|
||||
try {
|
||||
const sourcePath = path.join(this.storageDir, id)
|
||||
|
||||
// 确保目标目录存在
|
||||
const destDir = path.dirname(destPath)
|
||||
if (!fs.existsSync(destDir)) {
|
||||
await fs.promises.mkdir(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 复制文件
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
logger.info('[FileStorage] File copied successfully:', { from: sourcePath, to: destPath })
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Copy file failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileStorage
|
||||
154
src/main/services/KnowledgeService.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { LocalPathLoader, RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs'
|
||||
import type { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import { LibSqlDb } from '@llm-tools/embedjs-libsql'
|
||||
import { MarkdownLoader } from '@llm-tools/embedjs-loader-markdown'
|
||||
import { DocxLoader, ExcelLoader, PptLoader } from '@llm-tools/embedjs-loader-msoffice'
|
||||
import { PdfLoader } from '@llm-tools/embedjs-loader-pdf'
|
||||
import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@llm-tools/embedjs-loader-web'
|
||||
import { AzureOpenAiEmbeddings, OpenAiEmbeddings } from '@llm-tools/embedjs-openai'
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
}
|
||||
|
||||
private initStorageDir = (): void => {
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private getRagApplication = async ({
|
||||
id,
|
||||
model,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
dimensions
|
||||
}: KnowledgeBaseParams): Promise<RAGApplication> => {
|
||||
return new RAGApplicationBuilder()
|
||||
.setModel('NO_MODEL')
|
||||
.setEmbeddingModel(
|
||||
apiVersion
|
||||
? new AzureOpenAiEmbeddings({
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIApiVersion: apiVersion,
|
||||
azureOpenAIApiDeploymentName: model,
|
||||
azureOpenAIApiInstanceName: getInstanceName(baseURL),
|
||||
dimensions,
|
||||
batchSize: 15
|
||||
})
|
||||
: new OpenAiEmbeddings({
|
||||
model,
|
||||
apiKey,
|
||||
configuration: { baseURL },
|
||||
dimensions,
|
||||
batchSize: 15
|
||||
})
|
||||
)
|
||||
.setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) }))
|
||||
.build()
|
||||
}
|
||||
|
||||
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
this.getRagApplication(base)
|
||||
}
|
||||
|
||||
public reset = async (_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise<void> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
await ragApplication.reset()
|
||||
}
|
||||
|
||||
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||
const dbPath = path.join(this.storageDir, id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
public add = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ base, item, forceReload = false }: { base: KnowledgeBaseParams; item: KnowledgeItem; forceReload: boolean }
|
||||
): Promise<AddLoaderReturn> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
|
||||
if (item.type === 'directory') {
|
||||
const directory = item.content as string
|
||||
return await ragApplication.addLoader(new LocalPathLoader({ path: directory }), forceReload)
|
||||
}
|
||||
|
||||
if (item.type === 'url') {
|
||||
const content = item.content as string
|
||||
if (content.startsWith('http')) {
|
||||
return await ragApplication.addLoader(new WebLoader({ urlOrContent: content }), forceReload)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'sitemap') {
|
||||
const content = item.content as string
|
||||
return await ragApplication.addLoader(new SitemapLoader({ url: content }), forceReload)
|
||||
}
|
||||
|
||||
if (item.type === 'note') {
|
||||
const content = item.content as string
|
||||
return await ragApplication.addLoader(new TextLoader({ text: content }), forceReload)
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
const file = item.content as FileType
|
||||
|
||||
if (file.ext === '.pdf') {
|
||||
return await ragApplication.addLoader(new PdfLoader({ filePathOrUrl: file.path }) as any, forceReload)
|
||||
}
|
||||
|
||||
if (file.ext === '.docx') {
|
||||
return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: file.path }) as any, forceReload)
|
||||
}
|
||||
|
||||
if (file.ext === '.pptx') {
|
||||
return await ragApplication.addLoader(new PptLoader({ filePathOrUrl: file.path }) as any, forceReload)
|
||||
}
|
||||
|
||||
if (file.ext === '.xlsx') {
|
||||
return await ragApplication.addLoader(new ExcelLoader({ filePathOrUrl: file.path }) as any, forceReload)
|
||||
}
|
||||
|
||||
if (['.md', '.mdx'].includes(file.ext)) {
|
||||
return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: file.path }) as any, forceReload)
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(file.path, 'utf-8')
|
||||
|
||||
return await ragApplication.addLoader(new TextLoader({ text: fileContent }), forceReload)
|
||||
}
|
||||
|
||||
return { entriesAdded: 0, uniqueId: '', loaderType: '' }
|
||||
}
|
||||
|
||||
public remove = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }
|
||||
): Promise<void> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
await ragApplication.deleteLoader(uniqueId)
|
||||
}
|
||||
|
||||
public search = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ search, base }: { search: string; base: KnowledgeBaseParams }
|
||||
): Promise<ExtractChunkData[]> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
return await ragApplication.search(search)
|
||||
}
|
||||
}
|
||||
|
||||
export default new KnowledgeService()
|
||||
131
src/main/services/ShortcutService.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Shortcut } from '@types'
|
||||
import { BrowserWindow, globalShortcut } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
let showAppAccelerator: string | null = null
|
||||
|
||||
function getShortcutHandler(shortcut: Shortcut) {
|
||||
switch (shortcut.key) {
|
||||
case 'zoom_in':
|
||||
return (window: BrowserWindow) => handleZoom(0.1)(window)
|
||||
case 'zoom_out':
|
||||
return (window: BrowserWindow) => handleZoom(-0.1)(window)
|
||||
case 'zoom_reset':
|
||||
return (window: BrowserWindow) => {
|
||||
window.webContents.setZoomFactor(1)
|
||||
configManager.setZoomFactor(1)
|
||||
}
|
||||
case 'show_app':
|
||||
return (window: BrowserWindow) => {
|
||||
if (window.isVisible()) {
|
||||
window.hide()
|
||||
} else {
|
||||
window.show()
|
||||
window.focus()
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortcutKey(shortcut: string[]): string {
|
||||
return shortcut.join('+')
|
||||
}
|
||||
|
||||
function handleZoom(delta: number) {
|
||||
return (window: BrowserWindow) => {
|
||||
const currentZoom = window.webContents.getZoomFactor()
|
||||
const newZoom = currentZoom + delta
|
||||
if (newZoom >= 0.1 && newZoom <= 5.0) {
|
||||
window.webContents.setZoomFactor(newZoom)
|
||||
configManager.setZoomFactor(newZoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerShortcuts(window: BrowserWindow) {
|
||||
window.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
|
||||
const register = () => {
|
||||
if (window.isDestroyed()) return
|
||||
|
||||
const shortcuts = configManager.getShortcuts()
|
||||
if (!shortcuts) return
|
||||
|
||||
shortcuts.forEach((shortcut) => {
|
||||
try {
|
||||
if (shortcut.shortcut.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const handler = getShortcutHandler(shortcut)
|
||||
|
||||
if (!handler) {
|
||||
return
|
||||
}
|
||||
|
||||
const accelerator = formatShortcutKey(shortcut.shortcut)
|
||||
|
||||
if (shortcut.key === 'show_app') {
|
||||
showAppAccelerator = accelerator
|
||||
}
|
||||
|
||||
if (shortcut.key.includes('zoom')) {
|
||||
switch (shortcut.key) {
|
||||
case 'zoom_in':
|
||||
globalShortcut.register('CommandOrControl+=', () => shortcut.enabled && handler(window))
|
||||
globalShortcut.register('CommandOrControl+numadd', () => shortcut.enabled && handler(window))
|
||||
return
|
||||
case 'zoom_out':
|
||||
globalShortcut.register('CommandOrControl+-', () => shortcut.enabled && handler(window))
|
||||
globalShortcut.register('CommandOrControl+numsub', () => shortcut.enabled && handler(window))
|
||||
return
|
||||
case 'zoom_reset':
|
||||
globalShortcut.register('CommandOrControl+0', () => shortcut.enabled && handler(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (shortcut.enabled) {
|
||||
globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unregister = () => {
|
||||
if (window.isDestroyed()) return
|
||||
|
||||
try {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
if (showAppAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
|
||||
handler && globalShortcut.register(showAppAccelerator, () => handler(window))
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[ShortcutService] Failed to unregister shortcuts')
|
||||
}
|
||||
}
|
||||
|
||||
window.on('focus', () => register())
|
||||
window.on('blur', () => unregister())
|
||||
|
||||
if (!window.isDestroyed() && window.isFocused()) {
|
||||
register()
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAllShortcuts() {
|
||||
try {
|
||||
showAppAccelerator = null
|
||||
globalShortcut.unregisterAll()
|
||||
} catch (error) {
|
||||
Logger.error('[ShortcutService] Failed to unregister all shortcuts')
|
||||
}
|
||||
}
|
||||
90
src/main/services/TrayService.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { isMac } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron'
|
||||
|
||||
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 { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
private tray: Tray | null = null
|
||||
|
||||
constructor() {
|
||||
this.updateTray()
|
||||
this.watchTrayChanges()
|
||||
}
|
||||
|
||||
private createTray() {
|
||||
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
|
||||
const tray = new Tray(iconPath)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
tray.setImage(iconPath)
|
||||
} else if (process.platform === 'darwin') {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
resizedImage.setTemplateImage(true)
|
||||
tray.setImage(resizedImage)
|
||||
} else if (process.platform === 'linux') {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
tray.setImage(resizedImage)
|
||||
}
|
||||
|
||||
this.tray = tray
|
||||
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale } = locale.translation
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: trayLocale.show_window,
|
||||
click: () => windowService.showMainWindow()
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: trayLocale.quit,
|
||||
click: () => this.quit()
|
||||
}
|
||||
])
|
||||
|
||||
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', () => {
|
||||
windowService.showMainWindow()
|
||||
})
|
||||
}
|
||||
|
||||
private updateTray() {
|
||||
if (configManager.isTray()) {
|
||||
this.createTray()
|
||||
} else {
|
||||
this.destroyTray()
|
||||
}
|
||||
}
|
||||
|
||||
private destroyTray() {
|
||||
if (this.tray) {
|
||||
this.tray.destroy()
|
||||
this.tray = null
|
||||
}
|
||||
}
|
||||
|
||||
private watchTrayChanges() {
|
||||
configManager.subscribe<boolean>('tray', () => this.updateTray())
|
||||
}
|
||||
|
||||
private quit() {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
68
src/main/services/WebDav.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import Stream from 'stream'
|
||||
import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
|
||||
|
||||
export default class WebDav {
|
||||
public instance: WebDAVClient | undefined
|
||||
private webdavPath: string
|
||||
|
||||
constructor(params: WebDavConfig) {
|
||||
this.webdavPath = params.webdavPath
|
||||
|
||||
this.instance = createClient(params.webdavHost, {
|
||||
username: params.webdavUser,
|
||||
password: params.webdavPass,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity
|
||||
})
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
}
|
||||
|
||||
public putFileContents = async (
|
||||
filename: string,
|
||||
data: string | BufferLike | Stream.Readable,
|
||||
options?: PutFileContentsOptions
|
||||
) => {
|
||||
if (!this.instance) {
|
||||
return new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await this.instance.exists(this.webdavPath))) {
|
||||
await this.instance.createDirectory(this.webdavPath, {
|
||||
recursive: true
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error creating directory on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
|
||||
try {
|
||||
return await this.instance.putFileContents(remoteFilePath, data, options)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error putting file contents on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public getFileContents = async (filename: string, options?: GetFileContentsOptions) => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
|
||||
try {
|
||||
return await this.instance.getFileContents(remoteFilePath, options)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error getting file contents on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
214
src/main/services/WindowService.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isLinux, isWin } from '@main/constant'
|
||||
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
import path, { join } from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { locales } from '../utils/locales'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
export class WindowService {
|
||||
private static instance: WindowService | null = null
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
|
||||
public static getInstance(): WindowService {
|
||||
if (!WindowService.instance) {
|
||||
WindowService.instance = new WindowService()
|
||||
}
|
||||
return WindowService.instance
|
||||
}
|
||||
|
||||
public createMainWindow(): BrowserWindow {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1080,
|
||||
defaultHeight: 670
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
this.mainWindow = new BrowserWindow({
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
height: mainWindowState.height,
|
||||
minWidth: 1080,
|
||||
minHeight: 600,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: isLinux ? 'default' : 'hidden',
|
||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true
|
||||
}
|
||||
})
|
||||
|
||||
this.setupMainWindow(this.mainWindow, mainWindowState)
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
public createMinappWindow({
|
||||
url,
|
||||
parent,
|
||||
windowOptions
|
||||
}: {
|
||||
url: string
|
||||
parent?: BrowserWindow
|
||||
windowOptions?: Electron.BrowserWindowConstructorOptions
|
||||
}): BrowserWindow {
|
||||
const width = windowOptions?.width || 1000
|
||||
const height = windowOptions?.height || 680
|
||||
|
||||
const minappWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
autoHideMenuBar: true,
|
||||
title: 'Cherry Studio',
|
||||
...windowOptions,
|
||||
parent,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/minapp.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: false
|
||||
}
|
||||
})
|
||||
|
||||
minappWindow.loadURL(url)
|
||||
return minappWindow
|
||||
}
|
||||
|
||||
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
|
||||
mainWindowState.manage(mainWindow)
|
||||
|
||||
this.setupContextMenu(mainWindow)
|
||||
this.setupWindowEvents(mainWindow)
|
||||
this.setupWebContentsHandlers(mainWindow)
|
||||
this.setupWindowLifecycleEvents(mainWindow)
|
||||
this.loadMainWindowContent(mainWindow)
|
||||
}
|
||||
|
||||
private setupContextMenu(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.on('context-menu', () => {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { common } = locale.translation
|
||||
|
||||
const menu = new Menu()
|
||||
menu.append(new MenuItem({ label: common.copy, role: 'copy' }))
|
||||
menu.append(new MenuItem({ label: common.paste, role: 'paste' }))
|
||||
menu.append(new MenuItem({ label: common.cut, role: 'cut' }))
|
||||
menu.popup()
|
||||
})
|
||||
}
|
||||
|
||||
private setupWindowEvents(mainWindow: BrowserWindow) {
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
}
|
||||
|
||||
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if (url.includes('localhost:5173')) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
const { url } = details
|
||||
|
||||
if (url.includes('http://file/')) {
|
||||
const fileName = url.replace('http://file/', '')
|
||||
const storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
const filePath = storageDir + '/' + fileName
|
||||
shell.openPath(filePath).catch((err) => Logger.error('Failed to open file:', err))
|
||||
} else {
|
||||
shell.openExternal(details.url)
|
||||
}
|
||||
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
this.setupWebRequestHeaders(mainWindow)
|
||||
}
|
||||
|
||||
private setupWebRequestHeaders(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
if (details.responseHeaders?.['X-Frame-Options']) {
|
||||
delete details.responseHeaders['X-Frame-Options']
|
||||
}
|
||||
if (details.responseHeaders?.['x-frame-options']) {
|
||||
delete details.responseHeaders['x-frame-options']
|
||||
}
|
||||
if (details.responseHeaders?.['Content-Security-Policy']) {
|
||||
delete details.responseHeaders['Content-Security-Policy']
|
||||
}
|
||||
if (details.responseHeaders?.['content-security-policy']) {
|
||||
delete details.responseHeaders['content-security-policy']
|
||||
}
|
||||
callback({ cancel: false, responseHeaders: details.responseHeaders })
|
||||
})
|
||||
}
|
||||
|
||||
private loadMainWindowContent(mainWindow: BrowserWindow) {
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
public getMainWindow(): BrowserWindow | null {
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
private setupWindowLifecycleEvents(mainWindow: BrowserWindow) {
|
||||
mainWindow.on('close', (event) => {
|
||||
const notInTray = !configManager.isTray()
|
||||
|
||||
// Windows and Linux
|
||||
if ((isWin || isLinux) && notInTray) {
|
||||
return app.quit()
|
||||
}
|
||||
|
||||
// Mac
|
||||
if (!app.isQuitting) {
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public showMainWindow() {
|
||||
if (this.mainWindow) {
|
||||
if (this.mainWindow.isMinimized()) {
|
||||
return this.mainWindow.restore()
|
||||
}
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
} else {
|
||||
this.createMainWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const windowService = WindowService.getInstance()
|
||||
@@ -1,101 +1,7 @@
|
||||
import { FileTypes } from '../../renderer/src/types'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileTypes } from '@types'
|
||||
|
||||
export function getFileType(ext: string): FileTypes {
|
||||
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
const documentExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
|
||||
const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
'.md', // Markdown 文件
|
||||
'.mdx', // Markdown 文件
|
||||
'.html', // HTML 文件
|
||||
'.htm', // HTML 文件的另一种扩展名
|
||||
'.xml', // XML 文件
|
||||
'.json', // JSON 文件
|
||||
'.yaml', // YAML 文件
|
||||
'.yml', // YAML 文件的另一种扩展名
|
||||
'.csv', // 逗号分隔值文件
|
||||
'.tsv', // 制表符分隔值文件
|
||||
'.ini', // 配置文件
|
||||
'.log', // 日志文件
|
||||
'.rtf', // 富文本格式文件
|
||||
'.tex', // LaTeX 文件
|
||||
'.srt', // 字幕文件
|
||||
'.xhtml', // XHTML 文件
|
||||
'.nfo', // 信息文件(主要用于场景发布)
|
||||
'.conf', // 配置文件
|
||||
'.config', // 配置文件
|
||||
'.env', // 环境变量文件
|
||||
'.properties', // 配置属性文件
|
||||
'.latex', // LaTeX 文档文件
|
||||
'.rst', // reStructuredText 文件
|
||||
'.php', // PHP 脚本文件,包含嵌入的 HTML
|
||||
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
|
||||
'.ts', // TypeScript 文件
|
||||
'.jsp', // JavaServer Pages 文件
|
||||
'.aspx', // ASP.NET 文件
|
||||
'.bat', // Windows 批处理文件
|
||||
'.sh', // Unix/Linux Shell 脚本文件
|
||||
'.py', // Python 脚本文件
|
||||
'.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 代码文件
|
||||
'.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 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.kts' // Kotlin Script 文件
|
||||
]
|
||||
|
||||
ext = ext.toLowerCase()
|
||||
if (imageExts.includes(ext)) return FileTypes.IMAGE
|
||||
if (videoExts.includes(ext)) return FileTypes.VIDEO
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { app } from 'electron'
|
||||
@@ -5,3 +6,19 @@ import { app } from 'electron'
|
||||
export function getResourcePath() {
|
||||
return path.join(app.getAppPath(), 'resources')
|
||||
}
|
||||
|
||||
export function getDataPath() {
|
||||
const dataPath = path.join(app.getPath('userData'), 'Data')
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true })
|
||||
}
|
||||
return dataPath
|
||||
}
|
||||
|
||||
export function getInstanceName(baseURL: string) {
|
||||
try {
|
||||
return new URL(baseURL).host.split('.')[0]
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
15
src/main/utils/locales.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import EnUs from '../../renderer/src/i18n/locales/en-us.json'
|
||||
import JaJP from '../../renderer/src/i18n/locales/ja-jp.json'
|
||||
import RuRu from '../../renderer/src/i18n/locales/ru-ru.json'
|
||||
import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json'
|
||||
import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json'
|
||||
|
||||
const locales = {
|
||||
'en-US': EnUs,
|
||||
'zh-CN': ZhCn,
|
||||
'zh-TW': ZhTw,
|
||||
'ja-JP': JaJP,
|
||||
'ru-RU': RuRu
|
||||
}
|
||||
|
||||
export { locales }
|
||||
16
src/main/utils/windowUtil.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
function isTilingWindowManager() {
|
||||
if (process.platform === 'darwin') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
return true
|
||||
}
|
||||
|
||||
const desktopEnv = process.env.XDG_CURRENT_DESKTOP?.toLowerCase()
|
||||
const tilingSystems = ['hyprland', 'i3', 'sway', 'bspwm', 'dwm', 'awesome', 'qtile', 'herbstluftwm', 'xmonad']
|
||||
|
||||
return tilingSystems.some((system) => desktopEnv?.includes(system))
|
||||
}
|
||||
|
||||
export { isTilingWindowManager }
|
||||
@@ -1,126 +0,0 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { BrowserWindow, Menu, MenuItem, shell } from 'electron'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
import { join } from 'path'
|
||||
|
||||
import icon from '../../build/icon.png?asset'
|
||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
|
||||
export function createMainWindow() {
|
||||
// Load the previous state with fallback to defaults
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1080,
|
||||
defaultHeight: 670
|
||||
})
|
||||
|
||||
const theme = appConfig.get('theme') || 'light'
|
||||
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
height: mainWindowState.height,
|
||||
minWidth: 1080,
|
||||
minHeight: 600,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
transparent: process.platform === 'darwin',
|
||||
vibrancy: 'fullscreen-ui',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true
|
||||
// devTools: !app.isPackaged,
|
||||
}
|
||||
})
|
||||
|
||||
mainWindowState.manage(mainWindow)
|
||||
|
||||
mainWindow.webContents.on('context-menu', () => {
|
||||
const menu = new Menu()
|
||||
menu.append(new MenuItem({ label: '复制', role: 'copy', sublabel: '⌘ + C' }))
|
||||
menu.append(new MenuItem({ label: '粘贴', role: 'paste', sublabel: '⌘ + V' }))
|
||||
menu.append(new MenuItem({ label: '剪切', role: 'cut', sublabel: '⌘ + X' }))
|
||||
menu.append(new MenuItem({ type: 'separator' }))
|
||||
menu.append(new MenuItem({ label: '全选', role: 'selectAll', sublabel: '⌘ + A' }))
|
||||
menu.popup()
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
event.preventDefault()
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
if (details.responseHeaders?.['X-Frame-Options']) {
|
||||
delete details.responseHeaders['X-Frame-Options']
|
||||
}
|
||||
if (details.responseHeaders?.['x-frame-options']) {
|
||||
delete details.responseHeaders['x-frame-options']
|
||||
}
|
||||
if (details.responseHeaders?.['Content-Security-Policy']) {
|
||||
delete details.responseHeaders['Content-Security-Policy']
|
||||
}
|
||||
if (details.responseHeaders?.['content-security-policy']) {
|
||||
delete details.responseHeaders['content-security-policy']
|
||||
}
|
||||
callback({ cancel: false, responseHeaders: details.responseHeaders })
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
export function createMinappWindow({
|
||||
url,
|
||||
parent,
|
||||
windowOptions
|
||||
}: {
|
||||
url: string
|
||||
parent?: BrowserWindow
|
||||
windowOptions?: Electron.BrowserWindowConstructorOptions
|
||||
}) {
|
||||
const width = windowOptions?.width || 1000
|
||||
const height = windowOptions?.height || 680
|
||||
|
||||
const minappWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
autoHideMenuBar: true,
|
||||
title: 'Cherry Studio',
|
||||
...windowOptions,
|
||||
parent,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/minapp.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: false
|
||||
}
|
||||
})
|
||||
|
||||
minappWindow.loadURL(url)
|
||||
|
||||
return minappWindow
|
||||
}
|
||||
65
src/preload/index.d.ts
vendored
@@ -1,37 +1,80 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { WebDavConfig } from '@renderer/types'
|
||||
import { AppInfo, KnowledgeBaseParams, KnowledgeItem, LanguageVarious } from '@renderer/types'
|
||||
import type { OpenDialogOptions } from 'electron'
|
||||
import type { UpdateInfo } from 'electron-updater'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: {
|
||||
getAppInfo: () => Promise<{
|
||||
version: string
|
||||
isPackaged: boolean
|
||||
appPath: string
|
||||
}>
|
||||
checkForUpdate: () => void
|
||||
getAppInfo: () => Promise<AppInfo>
|
||||
checkForUpdate: () => Promise<{ currentVersion: string; updateInfo: UpdateInfo | null }>
|
||||
openWebsite: (url: string) => void
|
||||
setProxy: (proxy: string | undefined) => void
|
||||
setLanguage: (theme: LanguageVarious) => void
|
||||
setTray: (isActive: boolean) => void
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
|
||||
reload: () => void
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
decompress: (text: Buffer) => Promise<string>
|
||||
clearCache: () => Promise<{ success: boolean; error?: string }>
|
||||
zip: {
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
decompress: (text: Buffer) => Promise<string>
|
||||
}
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) => Promise<Readable>
|
||||
restore: (backupPath: string) => Promise<string>
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
|
||||
}
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
|
||||
upload: (file: FileType) => Promise<FileType>
|
||||
delete: (fileId: string) => Promise<void>
|
||||
read: (fileId: string) => Promise<string>
|
||||
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
|
||||
clear: () => Promise<void>
|
||||
get: (filePath: string) => Promise<FileType | null>
|
||||
selectFolder: () => Promise<string | null>
|
||||
create: (fileName: string) => Promise<string>
|
||||
write: (filePath: string, data: Uint8Array | string) => Promise<void>
|
||||
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
|
||||
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
||||
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; filePath: string; content: Buffer } | null>
|
||||
openPath: (path: string) => Promise<void>
|
||||
save: (
|
||||
path: string,
|
||||
content: string | NodeJS.ArrayBufferView,
|
||||
options?: SaveDialogOptions
|
||||
) => Promise<string | null>
|
||||
saveImage: (name: string, data: string) => void
|
||||
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
|
||||
download: (url: string) => Promise<FileType | null>
|
||||
copy: (fileId: string, destPath: string) => Promise<void>
|
||||
}
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => Promise<void>
|
||||
}
|
||||
openPath: (path: string) => Promise<void>
|
||||
shortcuts: {
|
||||
update: (shortcuts: Shortcut[]) => Promise<void>
|
||||
}
|
||||
knowledgeBase: {
|
||||
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise<void>
|
||||
reset: ({ base }: { base: KnowledgeBaseParams }) => Promise<void>
|
||||
delete: (id: string) => Promise<void>
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
forceReload = false
|
||||
}: {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
}) => Promise<AddLoaderReturn>
|
||||
remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) => Promise<void>
|
||||
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => Promise<ExtractChunkData[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,75 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { KnowledgeBaseParams, KnowledgeItem, Shortcut, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke('get-app-info'),
|
||||
checkForUpdate: () => ipcRenderer.invoke('check-for-update'),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke('open-website', url),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
|
||||
getAppInfo: () => ipcRenderer.invoke('app:info'),
|
||||
reload: () => ipcRenderer.invoke('app:reload'),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy),
|
||||
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
|
||||
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
reload: () => ipcRenderer.invoke('reload'),
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
|
||||
clearCache: () => ipcRenderer.invoke('app:clear-cache'),
|
||||
zip: {
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
|
||||
},
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) =>
|
||||
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
|
||||
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
|
||||
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
|
||||
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
|
||||
read: (fileId: string) => ipcRenderer.invoke('file:read', fileId),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
|
||||
clear: () => ipcRenderer.invoke('file:clear'),
|
||||
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
|
||||
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
|
||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data),
|
||||
open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options),
|
||||
save: (path: string, content: string, options?: { compress: boolean }) => {
|
||||
return ipcRenderer.invoke('file:save', path, content, options)
|
||||
},
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data)
|
||||
openPath: (path: string) => ipcRenderer.invoke('file:openPath', path),
|
||||
save: (path: string, content: string, options?: { compress: boolean }) =>
|
||||
ipcRenderer.invoke('file:save', path, content, options),
|
||||
selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
|
||||
download: (url: string) => ipcRenderer.invoke('file:download', url),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath)
|
||||
},
|
||||
export: {
|
||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
|
||||
},
|
||||
openPath: (path: string) => ipcRenderer.invoke('open:path', path),
|
||||
shortcuts: {
|
||||
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
|
||||
},
|
||||
knowledgeBase: {
|
||||
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) =>
|
||||
ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }),
|
||||
reset: ({ base }: { base: KnowledgeBaseParams }) => ipcRenderer.invoke('knowledge-base:reset', { base }),
|
||||
delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id),
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
forceReload = false
|
||||
}: {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
}) => ipcRenderer.invoke('knowledge-base:add', { base, item, forceReload }),
|
||||
remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) =>
|
||||
ipcRenderer.invoke('knowledge-base:remove', { uniqueId, base }),
|
||||
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
|
||||
ipcRenderer.invoke('knowledge-base:search', { search, base })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +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 *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo/cherry-text.svg" />
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<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:" />
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -8,11 +8,14 @@ import { PersistGate } from 'redux-persist/integration/react'
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import PaintingsPage from './pages/paintings/PaintingsPage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
@@ -21,21 +24,25 @@ function App(): JSX.Element {
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
<SyntaxHighlighterProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings" element={<PaintingsPage />} />
|
||||
<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>
|
||||
</SyntaxHighlighterProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'iconfont'; /* Project id 4563475 */
|
||||
src: url('iconfont.woff2?t=1725606177995') format('woff2');
|
||||
font-family: 'iconfont'; /* Project id 4753420 */
|
||||
src: url('iconfont.woff2?t=1733224456443') format('woff2');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@@ -11,6 +11,14 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-at1:before {
|
||||
content: '\e7df';
|
||||
}
|
||||
|
||||
.icon-at:before {
|
||||
content: '\e630';
|
||||
}
|
||||
|
||||
.icon-a-darkmode:before {
|
||||
content: '\e6cd';
|
||||
}
|
||||
@@ -27,10 +35,6 @@
|
||||
content: '\e942';
|
||||
}
|
||||
|
||||
.icon-grid-row-2copy:before {
|
||||
content: '\e681';
|
||||
}
|
||||
|
||||
.icon-inbox:before {
|
||||
content: '\e869';
|
||||
}
|
||||
@@ -71,10 +75,6 @@
|
||||
content: '\e944';
|
||||
}
|
||||
|
||||
.icon-a-addchat:before {
|
||||
content: '\e658';
|
||||
}
|
||||
|
||||
.icon-appstore:before {
|
||||
content: '\e792';
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
18
src/renderer/src/assets/images/apps/bolt.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="4" fill="black"/>
|
||||
<g filter="url(#filter0_i_2119_154)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.64368 11.7731C7.91976 11.7731 7.20901 11.5147 6.80099 10.9591L6.65707 11.6143L4 13L4.28684 11.6143L6.22186 3H8.59103L7.9066 6.03634C8.45941 5.44199 8.97273 5.22234 9.63083 5.22234C11.0523 5.22234 12 6.1397 12 7.81938C12 9.55074 10.9076 11.7731 8.64368 11.7731ZM9.55186 8.31036C9.55186 9.11144 8.97273 9.71871 8.22249 9.71871C7.8013 9.71871 7.4196 9.56366 7.16952 9.29233L7.53806 7.70309C7.81447 7.43176 8.13036 7.27671 8.49889 7.27671C9.06486 7.27671 9.55186 7.69017 9.55186 8.31036Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_i_2119_154" x="4" y="3" width="8" height="10" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.0192413"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.95 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2119_154"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/renderer/src/assets/images/apps/doubao.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/renderer/src/assets/images/apps/duckduckgo.webp
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/renderer/src/assets/images/apps/felo.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src/renderer/src/assets/images/apps/gemini.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src/renderer/src/assets/images/apps/github-copilot.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
14
src/renderer/src/assets/images/apps/huggingchat.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none">
|
||||
<path
|
||||
fill="#FFD21E"
|
||||
d="M4 15.55C4 9.72 8.72 5 14.55 5h4.11a9.34 9.34 0 1 1 0 18.68H7.58l-2.89 2.8a.41.41 0 0 1-.69-.3V15.55Z"
|
||||
/>
|
||||
<path
|
||||
fill="#32343D"
|
||||
d="M19.63 12.48c.37.14.52.9.9.7.71-.38.98-1.27.6-1.98a1.46 1.46 0 0 0-1.98-.61 1.47 1.47 0 0 0-.6 1.99c.17.34.74-.21 1.08-.1ZM12.72 12.48c-.37.14-.52.9-.9.7a1.47 1.47 0 0 1-.6-1.98 1.46 1.46 0 0 1 1.98-.61c.71.38.98 1.27.6 1.99-.18.34-.74-.21-1.08-.1ZM16.24 19.55c2.89 0 3.82-2.58 3.82-3.9 0-1.33-1.71.7-3.82.7-2.1 0-3.8-2.03-3.8-.7 0 1.32.92 3.9 3.8 3.9Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF323D"
|
||||
d="M18.56 18.8c-.57.44-1.33.75-2.32.75-.92 0-1.65-.27-2.2-.68.3-.63.87-1.11 1.55-1.32.12-.03.24.17.36.38.12.2.24.4.37.4s.26-.2.39-.4.26-.4.38-.36a2.56 2.56 0 0 1 1.47 1.23Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 810 B |
BIN
src/renderer/src/assets/images/apps/nm.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src/renderer/src/assets/images/apps/qingyan.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/renderer/src/assets/images/apps/thinkany.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/renderer/src/assets/images/apps/wanzhi.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 44 KiB |
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.45 66.73">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ea5e5d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #23af69;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #ea5756;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层_1">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-1" d="M16.72,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
|
||||
<path class="cls-1" d="M32.05,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
|
||||
</g>
|
||||
<path class="cls-2" d="M32.05,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M93.93,24.6l.55-.39c.69-.4,1.17-.61,1.46-.61.63,0,1.3.57,2.03,1.7.44.71.67,1.27.67,1.7s-.14.78-.41,1.06c-.27.28-.59.54-.96.76-.36.22-.71.43-1.05.64-.33.2-1.02.47-2.05.79-1.03.32-2.03.49-2.99.49s-1.93-.13-2.91-.38c-.98-.25-1.99-.68-3.03-1.27-1.04-.6-1.98-1.32-2.81-2.18-.83-.86-1.51-1.96-2.05-3.31-.54-1.35-.8-2.81-.8-4.38s.26-3.01.79-4.29c.53-1.28,1.2-2.35,2.02-3.19.82-.84,1.75-1.54,2.81-2.11,1.98-1.09,3.97-1.64,5.98-1.64.95,0,1.92.15,2.9.44.98.29,1.72.59,2.23.9l.73.42c.36.22.65.4.85.55.53.42.79.91.79,1.44s-.21,1.1-.64,1.68c-.79,1.09-1.5,1.64-2.12,1.64-.36,0-.88-.22-1.55-.67-.85-.69-1.98-1.03-3.4-1.03-1.31,0-2.61.46-3.88,1.36-.61.44-1.11,1.07-1.52,1.88-.4.81-.61,1.72-.61,2.75s.2,1.94.61,2.75c.4.81.92,1.45,1.55,1.91,1.23.89,2.52,1.34,3.85,1.34.63,0,1.22-.08,1.77-.24.56-.16.96-.32,1.2-.49Z"/>
|
||||
<path class="cls-3" d="M114.38,9.07c.16-.3.43-.52.82-.64.38-.12.87-.18,1.46-.18s1.05.05,1.4.15c.34.1.61.22.79.36.18.14.32.34.42.61.1.34.15.87.15,1.58v16.84c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58v-6.16h-8.04v6.19c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V10.92c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v6.19h8.04v-6.22c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8Z"/>
|
||||
<path class="cls-3" d="M127.21,25.1h9.34c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-12.01c-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55V10.9c0-1.03.19-1.73.58-2.11.38-.37,1.11-.56,2.18-.56h11.95c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-9.31v3.06h6.01c.46,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.38,2.25-1.15,2.49-.34.12-.87.18-1.58.18h-5.95v3.06Z"/>
|
||||
<path class="cls-3" d="M196.96,8.79c.99.69,1.49,1.35,1.49,2,0,.38-.23.92-.7,1.61l-6.55,9.8v5.79c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.16.3-.43.52-.82.64-.38.12-.9.18-1.55.18s-1.16-.06-1.55-.18c-.38-.12-.66-.34-.82-.65-.16-.31-.26-.59-.29-.82-.03-.23-.05-.59-.05-1.08v-5.73l-6.55-9.8c-.47-.69-.7-1.22-.7-1.61,0-.65.44-1.27,1.33-1.87.89-.6,1.53-.9,1.91-.9s.69.08.91.24c.34.22.71.64,1.09,1.24l4.7,7.52,4.7-7.52c.38-.61.72-1.01,1-1.2s.61-.29.99-.29.97.25,1.77.76Z"/>
|
||||
<g>
|
||||
<path class="cls-3" d="M81.93,56.63c-.53-.65-.79-1.23-.79-1.74s.43-1.2,1.3-2.05c.51-.49,1.04-.73,1.61-.73s1.36.51,2.37,1.52c.28.34.69.67,1.21.99.53.31,1.01.47,1.46.47,1.88,0,2.82-.77,2.82-2.31,0-.46-.26-.85-.77-1.17-.52-.31-1.16-.54-1.93-.68-.77-.14-1.6-.37-2.49-.68-.89-.31-1.72-.68-2.49-1.11-.77-.42-1.41-1.1-1.93-2.02-.52-.92-.77-2.03-.77-3.32,0-1.78.66-3.33,1.99-4.66s3.13-1.99,5.42-1.99c1.21,0,2.32.16,3.32.47,1,.31,1.69.63,2.08.96l.76.58c.63.59.94,1.08.94,1.49s-.24.96-.73,1.67c-.69,1.01-1.4,1.52-2.12,1.52-.42,0-.95-.2-1.58-.61-.06-.04-.18-.14-.35-.3-.17-.16-.33-.29-.47-.39-.42-.26-.97-.39-1.62-.39s-1.2.16-1.64.47c-.43.31-.65.75-.65,1.3s.26,1.01.77,1.35c.52.34,1.16.58,1.93.7.77.12,1.61.31,2.52.56.91.25,1.75.56,2.52.93.77.36,1.41,1,1.93,1.9.52.9.77,2.01.77,3.32s-.26,2.47-.79,3.47c-.53,1-1.21,1.77-2.06,2.32-1.64,1.07-3.39,1.61-5.25,1.61-.95,0-1.85-.12-2.7-.35-.85-.23-1.54-.52-2.06-.86-1.07-.65-1.82-1.27-2.24-1.88l-.27-.33Z"/>
|
||||
<path class="cls-3" d="M100.74,37.49h16.87c.65,0,1.12.08,1.43.23.3.15.51.39.61.71.1.32.15.75.15,1.27s-.05.95-.15,1.26c-.1.31-.27.53-.52.65-.36.18-.88.27-1.55.27h-5.79v15.26c0,.47-.02.81-.05,1.03s-.12.48-.27.77c-.15.29-.42.5-.8.62-.38.12-.89.18-1.52.18s-1.13-.06-1.5-.18c-.37-.12-.64-.33-.79-.62-.15-.29-.24-.56-.27-.79-.03-.23-.05-.58-.05-1.05v-15.23h-5.82c-.65,0-1.12-.08-1.43-.23-.3-.15-.51-.39-.61-.71-.1-.32-.15-.75-.15-1.27s.05-.95.15-1.26c.1-.31.27-.53.52-.65.36-.18.88-.27,1.55-.27Z"/>
|
||||
<path class="cls-3" d="M135.99,38.34c.2-.32.5-.55.88-.67.38-.12.86-.18,1.44-.18s1.04.05,1.38.15c.34.1.61.22.79.36.18.14.31.35.39.64.12.34.18.87.18,1.58v9.16c0,2.67-.83,5.1-2.49,7.28-.81,1.03-1.85,1.87-3.12,2.5s-2.68.96-4.23.96-2.95-.32-4.22-.97c-1.26-.65-2.29-1.5-3.08-2.55-1.64-2.14-2.46-4.57-2.46-7.28v-9.13c0-.49.02-.84.05-1.08.03-.23.13-.5.29-.8.16-.3.43-.52.82-.64.38-.12.9-.18,1.55-.18s1.16.06,1.55.18c.38.12.65.33.79.64.24.47.36,1.1.36,1.91v9.1c0,1.23.3,2.41.91,3.52.3.57.76,1.02,1.37,1.36.61.34,1.32.52,2.15.52,1.48,0,2.58-.55,3.31-1.64.73-1.09,1.09-2.36,1.09-3.79v-9.28c0-.79.1-1.34.3-1.67Z"/>
|
||||
<path class="cls-3" d="M146.18,37.49l5.61.03c2.93,0,5.51,1.06,7.74,3.17,2.22,2.11,3.34,4.71,3.34,7.8s-1.09,5.73-3.26,7.93c-2.17,2.2-4.81,3.31-7.9,3.31h-5.55c-1.23,0-2-.25-2.31-.76-.24-.42-.36-1.07-.36-1.94v-16.87c0-.49.02-.84.05-1.06s.13-.49.29-.79c.28-.55,1.07-.82,2.37-.82ZM151.79,54.35c1.46,0,2.77-.54,3.94-1.62,1.17-1.08,1.76-2.44,1.76-4.08s-.57-3.01-1.71-4.11c-1.14-1.1-2.48-1.65-4.02-1.65h-2.91v11.47h2.94Z"/>
|
||||
<path class="cls-3" d="M164.84,40.19c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v16.87c0,.49-.02.84-.05,1.06s-.13.49-.29.79c-.28.55-1.07.82-2.37.82-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55v-16.87Z"/>
|
||||
<path class="cls-3" d="M183.07,37.24c2.99,0,5.59,1.08,7.8,3.25,2.2,2.16,3.31,4.85,3.31,8.05s-1.05,5.94-3.16,8.19c-2.1,2.26-4.69,3.38-7.77,3.38s-5.69-1.11-7.84-3.34c-2.15-2.22-3.23-4.87-3.23-7.95,0-1.68.3-3.25.91-4.72.61-1.47,1.42-2.7,2.43-3.69,1.01-.99,2.17-1.77,3.49-2.34,1.31-.57,2.67-.85,4.07-.85ZM177.55,48.68c0,1.8.58,3.26,1.74,4.38,1.16,1.12,2.46,1.68,3.9,1.68s2.73-.55,3.88-1.64c1.15-1.09,1.73-2.56,1.73-4.4s-.58-3.32-1.74-4.43c-1.16-1.11-2.46-1.67-3.9-1.67s-2.73.56-3.88,1.68c-1.15,1.12-1.73,2.58-1.73,4.38Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M176.92,11.06c-.03-.23-.13-.5-.29-.8-.28-.55-1.07-.82-2.37-.82h-6.55c-1.78,0-3.51.65-5.19,1.94-.81.63-1.48,1.48-2,2.55-.53,1.07-.79,2.27-.79,3.58,0,2.29.76,4.17,2.28,5.64-.44,1.07-1.13,2.66-2.06,4.76-.3.73-.45,1.25-.45,1.58,0,.77.63,1.42,1.88,1.94.65.28,1.17.43,1.56.43s.72-.1.97-.29c.25-.19.44-.39.56-.59.2-.38.99-2.21,2.37-5.49l.94.06h3.82v3.43c0,.47.02.81.05,1.05.03.23.13.5.29.8.28.55,1.07.82,2.37.82,1.42,0,2.25-.37,2.49-1.12.12-.34.18-.87.18-1.58V12.11c0-.46-.02-.81-.05-1.05ZM172.81,19.44c-.09.14-.48.77-1.24.91-.2.04-.37.03-.48.02-.02.14-.04.26-.06.38-.16.83-.38,1.05-.57,1.07-.29.05-.51-.35-.93-.9-.23.01-.46.02-.69.02-.51,0-1.01-.03-1.49-.09-.25-.03-.5-.07-.74-.11-1.18-.32-2.03-1.27-2.03-2.4v-1.37c0-1.13.86-2.08,2.03-2.4.24-.04.49-.08.74-.11.48-.06.98-.09,1.49-.09s1.01.03,1.49.09c.25.03.5.07.74.11.6.16,1.12.49,1.49.93.34.41.55.92.55,1.47v1.37c0,.23-.01.66-.29,1.1Z"/>
|
||||
<circle class="cls-2" cx="167.24" cy="17.67" r=".49"/>
|
||||
<circle class="cls-2" cx="168.88" cy="17.71" r=".49"/>
|
||||
<circle class="cls-2" cx="170.59" cy="17.71" r=".49"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M141.01,8.24c.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82h6.55c1.78,0,3.51.65,5.19,1.94.81.63,1.48,1.48,2,2.55.53,1.07.79,2.27.79,3.58,0,2.29-.76,4.17-2.28,5.64.44,1.07,1.13,2.66,2.06,4.76.3.73.45,1.25.45,1.58,0,.77-.63,1.42-1.88,1.94-.65.28-1.17.43-1.56.43s-.72-.1-.97-.29c-.25-.19-.44-.39-.56-.59-.2-.38-.99-2.21-2.37-5.49l-.94.06h-3.82v3.43c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V9.28c0-.46.02-.81.05-1.05ZM145.12,16.62c.09.14.48.77,1.24.91.2.04.37.03.48.02.02.14.04.26.06.38.16.83.38,1.05.57,1.07.29.05.51-.35.93-.9.23.01.46.02.69.02.51,0,1.01-.03,1.49-.09.25-.03.5-.07.74-.11,1.18-.32,2.03-1.27,2.03-2.4v-1.37c0-1.13-.86-2.08-2.03-2.4-.24-.04-.49-.08-.74-.11-.48-.06-.98-.09-1.49-.09s-1.01.03-1.49.09c-.25.03-.5.07-.74.11-.6.16-1.12.49-1.49.93-.34.41-.55.92-.55,1.47v1.37c0,.23.01.66.29,1.1Z"/>
|
||||
<circle class="cls-2" cx="150.69" cy="14.84" r=".49"/>
|
||||
<circle class="cls-2" cx="149.05" cy="14.89" r=".49"/>
|
||||
<circle class="cls-2" cx="147.35" cy="14.89" r=".49"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.5 KiB |
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.39 115.44">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ea5e5d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #23af69;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #ea5756;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层_1">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-1" d="M25.31,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
|
||||
<path class="cls-1" d="M40.64,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04c3.17-3.23,7.36-5.01,11.81-5.01s8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
|
||||
</g>
|
||||
<path class="cls-2" d="M40.64,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M10.19,90.22l.39-.28c.49-.29.83-.43,1.03-.43.45,0,.93.4,1.44,1.21.32.5.47.9.47,1.21s-.1.55-.29.75c-.19.2-.42.38-.68.54-.26.16-.51.31-.74.45-.24.14-.72.33-1.45.56-.73.23-1.44.34-2.12.34s-1.37-.09-2.07-.27c-.7-.18-1.41-.48-2.15-.9-.74-.42-1.4-.94-1.99-1.55-.59-.61-1.07-1.39-1.45-2.35-.38-.96-.57-1.99-.57-3.11s.19-2.14.56-3.05c.37-.91.85-1.67,1.43-2.26.58-.6,1.25-1.09,1.99-1.5,1.41-.78,2.82-1.16,4.24-1.16.67,0,1.36.1,2.06.31.7.21,1.22.42,1.58.64l.52.3c.26.16.46.29.6.39.37.3.56.64.56,1.02s-.15.78-.45,1.2c-.56.78-1.06,1.16-1.51,1.16-.26,0-.62-.16-1.1-.47-.6-.49-1.41-.73-2.41-.73-.93,0-1.85.32-2.76.97-.43.32-.79.76-1.08,1.34-.29.57-.43,1.22-.43,1.95s.14,1.37.43,1.95c.29.57.65,1.03,1.1,1.36.88.63,1.79.95,2.74.95.45,0,.87-.06,1.26-.17.39-.11.68-.23.85-.34Z"/>
|
||||
<path class="cls-3" d="M24.7,79.2c.11-.22.31-.37.58-.45.27-.09.62-.13,1.03-.13s.75.04.99.11c.24.07.43.16.56.26.13.1.23.24.3.43.07.24.11.62.11,1.12v11.95c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.8-.09-.24-.13-.62-.13-1.12v-4.37h-5.71v4.39c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.8-.09-.24-.13-.62-.13-1.12v-11.95c0-.33.01-.58.03-.74.02-.17.09-.36.2-.57.2-.39.76-.58,1.68-.58,1.01,0,1.6.27,1.79.8.07.24.11.62.11,1.12v4.39h5.71v-4.42c0-.33.01-.58.03-.74.02-.17.09-.36.2-.57Z"/>
|
||||
<path class="cls-3" d="M33.82,90.58h6.63c.33,0,.58.01.74.03.17.02.36.09.57.2.39.2.58.76.58,1.68,0,1.01-.27,1.59-.8,1.77-.24.09-.62.13-1.12.13h-8.53c-1.01,0-1.59-.27-1.77-.82-.09-.23-.13-.6-.13-1.1v-11.98c0-.73.14-1.23.41-1.5.27-.27.79-.4,1.55-.4h8.49c.33,0,.58.01.74.03.17.02.36.09.57.2.39.2.58.76.58,1.68,0,1.01-.27,1.59-.8,1.77-.24.09-.62.13-1.12.13h-6.61v2.18h4.26c.33,0,.58.01.74.03.17.02.36.09.57.2.39.2.58.76.58,1.68,0,1.01-.27,1.59-.82,1.77-.24.09-.62.13-1.12.13h-4.22v2.18Z"/>
|
||||
<path class="cls-3" d="M83.34,79c.7.49,1.06.96,1.06,1.42,0,.27-.17.65-.5,1.14l-4.65,6.96v4.11c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.11.22-.31.37-.58.45-.27.09-.64.13-1.1.13s-.83-.04-1.1-.13c-.27-.09-.47-.24-.58-.46-.11-.22-.18-.42-.2-.58-.02-.17-.03-.42-.03-.76v-4.07l-4.65-6.96c-.33-.49-.5-.87-.5-1.14,0-.46.32-.9.95-1.32.63-.42,1.08-.64,1.36-.64s.49.06.65.17c.24.16.5.45.78.88l3.34,5.34,3.34-5.34c.27-.43.51-.71.71-.85s.43-.2.7-.2.69.18,1.26.54Z"/>
|
||||
<g>
|
||||
<path class="cls-3" d="M1.66,112.96c-.37-.46-.56-.87-.56-1.24s.31-.85.93-1.45c.36-.34.74-.52,1.14-.52s.96.36,1.68,1.08c.2.24.49.48.86.7.37.22.72.33,1.03.33,1.34,0,2-.55,2-1.64,0-.33-.18-.61-.55-.83-.37-.22-.82-.38-1.37-.48-.55-.1-1.13-.26-1.77-.48-.63-.22-1.22-.48-1.77-.79-.55-.3-1-.78-1.37-1.43-.37-.65-.55-1.44-.55-2.36,0-1.26.47-2.37,1.41-3.31s2.22-1.41,3.84-1.41c.86,0,1.65.11,2.36.33.71.22,1.2.45,1.48.68l.54.41c.45.42.67.77.67,1.06s-.17.68-.52,1.18c-.49.72-.99,1.08-1.51,1.08-.3,0-.67-.14-1.12-.43-.04-.03-.13-.1-.25-.22-.12-.11-.23-.21-.33-.28-.3-.19-.69-.28-1.15-.28s-.85.11-1.16.33c-.31.22-.46.53-.46.93s.18.71.55.96c.37.24.82.41,1.37.5.55.09,1.14.22,1.79.4.65.18,1.24.4,1.79.66.55.26,1,.71,1.37,1.35.37.64.55,1.42.55,2.36s-.19,1.76-.56,2.47c-.37.71-.86,1.26-1.46,1.65-1.16.76-2.4,1.14-3.73,1.14-.68,0-1.31-.08-1.92-.25-.6-.17-1.09-.37-1.46-.61-.76-.46-1.29-.9-1.59-1.34l-.19-.24Z"/>
|
||||
<path class="cls-3" d="M15.02,99.37h11.98c.46,0,.8.05,1.01.16.22.11.36.28.43.51.07.23.11.53.11.9s-.04.67-.11.89c-.07.22-.19.38-.37.46-.26.13-.62.19-1.1.19h-4.11v10.83c0,.33-.01.57-.03.73s-.09.34-.19.55c-.11.21-.3.36-.57.44-.27.09-.63.13-1.08.13s-.8-.04-1.07-.13c-.27-.09-.45-.23-.56-.44-.11-.21-.17-.4-.19-.56-.02-.17-.03-.41-.03-.74v-10.81h-4.14c-.46,0-.8-.05-1.01-.16-.22-.11-.36-.28-.43-.51-.07-.23-.11-.53-.11-.9s.04-.67.11-.89c.07-.22.19-.38.37-.46.26-.13.62-.19,1.1-.19Z"/>
|
||||
<path class="cls-3" d="M40.05,99.98c.14-.23.35-.39.62-.47.27-.09.61-.13,1.02-.13s.74.04.98.11c.24.07.43.16.56.26.13.1.22.25.28.45.09.24.13.62.13,1.12v6.5c0,1.9-.59,3.62-1.77,5.17-.57.73-1.31,1.32-2.22,1.78s-1.91.68-3,.68-2.1-.23-2.99-.69c-.9-.46-1.63-1.06-2.19-1.81-1.16-1.52-1.74-3.25-1.74-5.17v-6.48c0-.34.01-.6.03-.76.02-.17.09-.36.2-.57.11-.22.31-.37.58-.45.27-.09.64-.13,1.1-.13s.83.04,1.1.13c.27.09.46.24.56.45.17.33.26.78.26,1.36v6.46c0,.88.22,1.71.65,2.5.22.4.54.72.97.97.43.24.94.37,1.53.37,1.05,0,1.83-.39,2.35-1.16.52-.78.78-1.67.78-2.69v-6.59c0-.56.07-.95.22-1.18Z"/>
|
||||
<path class="cls-3" d="M47.28,99.37l3.98.02c2.08,0,3.91.75,5.49,2.25,1.58,1.5,2.37,3.35,2.37,5.54s-.77,4.07-2.32,5.63c-1.54,1.57-3.41,2.35-5.61,2.35h-3.94c-.88,0-1.42-.18-1.64-.54-.17-.3-.26-.76-.26-1.38v-11.98c0-.34.01-.6.03-.75s.09-.34.2-.56c.2-.39.76-.58,1.68-.58ZM51.27,111.35c1.03,0,1.97-.38,2.8-1.15.83-.77,1.25-1.73,1.25-2.9s-.41-2.14-1.22-2.92c-.81-.78-1.76-1.17-2.85-1.17h-2.07v8.14h2.09Z"/>
|
||||
<path class="cls-3" d="M60.53,101.29c0-.33.01-.58.03-.74.02-.17.09-.36.2-.57.2-.39.76-.58,1.68-.58,1.01,0,1.6.27,1.79.8.07.24.11.62.11,1.12v11.98c0,.34-.01.6-.03.75s-.09.34-.2.56c-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.82-.09-.23-.13-.6-.13-1.1v-11.98Z"/>
|
||||
<path class="cls-3" d="M73.47,99.2c2.13,0,3.97.77,5.54,2.3,1.57,1.54,2.35,3.44,2.35,5.72s-.75,4.21-2.24,5.82c-1.49,1.6-3.33,2.4-5.51,2.4s-4.04-.79-5.57-2.37c-1.53-1.58-2.29-3.46-2.29-5.64,0-1.19.22-2.31.65-3.35.43-1.04,1.01-1.91,1.72-2.62.72-.7,1.54-1.26,2.48-1.66.93-.4,1.9-.6,2.89-.6ZM69.55,107.32c0,1.28.41,2.32,1.24,3.11.83.8,1.75,1.2,2.77,1.2s1.94-.39,2.76-1.16c.82-.78,1.23-1.82,1.23-3.12s-.41-2.35-1.24-3.14c-.83-.79-1.75-1.18-2.77-1.18s-1.94.4-2.76,1.2c-.82.8-1.23,1.83-1.23,3.11Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M69.11,80.61c-.02-.17-.09-.36-.2-.57-.2-.39-.76-.58-1.68-.58h-4.65c-1.26,0-2.49.46-3.68,1.38-.57.45-1.05,1.05-1.42,1.81-.37.76-.56,1.61-.56,2.54,0,1.62.54,2.96,1.62,4.01-.32.76-.8,1.89-1.46,3.38-.22.52-.32.89-.32,1.12,0,.55.45,1.01,1.34,1.38.46.2.83.3,1.11.3s.51-.07.69-.2c.18-.14.31-.28.4-.42.14-.27.7-1.57,1.68-3.9l.67.04h2.71v2.43c0,.33.01.58.03.74.02.17.09.36.2.57.2.39.76.58,1.68.58,1.01,0,1.59-.27,1.77-.8.09-.24.13-.62.13-1.12v-11.95c0-.33-.01-.58-.03-.74ZM66.19,86.56c-.06.1-.34.54-.88.65-.14.03-.26.02-.34.02-.01.1-.03.19-.04.27-.11.59-.27.74-.4.76-.21.03-.36-.25-.66-.64-.16,0-.32.01-.49.01-.36,0-.72-.02-1.06-.06-.18-.02-.35-.05-.52-.08-.84-.22-1.44-.9-1.44-1.7v-.97c0-.8.61-1.48,1.44-1.7.17-.03.34-.06.52-.08.34-.04.69-.06,1.06-.06s.72.02,1.06.06c.18.02.35.05.52.08.43.12.8.35,1.06.66.24.29.39.65.39,1.05v.97c0,.16,0,.47-.21.78Z"/>
|
||||
<circle class="cls-2" cx="62.23" cy="85.3" r=".35"/>
|
||||
<circle class="cls-2" cx="63.4" cy="85.33" r=".35"/>
|
||||
<circle class="cls-2" cx="64.61" cy="85.33" r=".35"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M43.62,78.61c.02-.17.09-.36.2-.57.2-.39.76-.58,1.68-.58h4.65c1.26,0,2.49.46,3.68,1.38.57.45,1.05,1.05,1.42,1.81.37.76.56,1.61.56,2.54,0,1.62-.54,2.96-1.62,4.01.32.76.8,1.89,1.46,3.38.22.52.32.89.32,1.12,0,.55-.45,1.01-1.34,1.38-.46.2-.83.3-1.11.3s-.51-.07-.69-.2c-.18-.14-.31-.28-.4-.42-.14-.27-.7-1.57-1.68-3.9l-.67.04h-2.71v2.43c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.8-.09-.24-.13-.62-.13-1.12v-11.95c0-.33.01-.58.03-.74ZM46.53,84.56c.06.1.34.54.88.65.14.03.26.02.34.02.01.1.03.19.04.27.11.59.27.74.4.76.21.03.36-.25.66-.64.16,0,.32.01.49.01.36,0,.72-.02,1.06-.06.18-.02.35-.05.52-.08.84-.22,1.44-.9,1.44-1.7v-.97c0-.8-.61-1.48-1.44-1.7-.17-.03-.34-.06-.52-.08-.34-.04-.69-.06-1.06-.06s-.72.02-1.06.06c-.18.02-.35.05-.52.08-.43.12-.8.35-1.06.66-.24.29-.39.65-.39,1.05v.97c0,.16,0,.47.21.78Z"/>
|
||||
<circle class="cls-2" cx="50.49" cy="83.3" r=".35"/>
|
||||
<circle class="cls-2" cx="49.32" cy="83.33" r=".35"/>
|
||||
<circle class="cls-2" cx="48.11" cy="83.33" r=".35"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.2 KiB |
BIN
src/renderer/src/assets/images/models/adept.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |