Compare commits
848 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
2593de5872 | ||
|
d2ae740d5f | ||
|
426ce7fd35 | ||
|
2a191aacb7 | ||
|
50cd33dbb2 | ||
|
e6b49a60c0 | ||
|
a7e9356c16 | ||
|
58cc1c8482 | ||
|
88df4a2223 | ||
|
d046100875 | ||
|
0d4611052e | ||
|
bdb03e07fc | ||
|
1d790b9e8d | ||
|
24bf15af4f | ||
|
6410aa214e | ||
|
a023308d52 | ||
|
2516851056 | ||
|
a3a77006ff | ||
|
ebbd0128f1 | ||
|
773641aa16 | ||
|
57514e91b6 | ||
|
a9d336baf5 | ||
|
37da759fd5 | ||
|
2ebc26953d | ||
|
c8ba72b185 | ||
|
35fe6e2f54 | ||
|
f3d35e0ef3 | ||
|
68327907a9 | ||
|
44fe85b56d | ||
|
5e274130a6 | ||
|
94be03ec4f | ||
|
6502855a70 | ||
|
6bbdaf7ab0 | ||
|
846e323840 | ||
|
c198c33778 | ||
|
c1cc3d1d1f | ||
|
6b5e5a15d7 | ||
|
7ac03b4d89 | ||
|
6bd75fae33 | ||
|
3cdaf62fa1 | ||
|
9aea6c5585 | ||
|
d533895637 | ||
|
aa74a74c5c | ||
|
46f0a256d7 | ||
|
e33ad07e16 | ||
|
786d62905d | ||
|
75594b8fe5 | ||
|
96d2f05eb7 | ||
|
887f93181c | ||
|
9f4a80f6ae | ||
|
3e65ef3bea | ||
|
4ca34e0436 | ||
|
bb3f6ee086 | ||
|
89f0a4875c | ||
|
aa2be9b96c | ||
|
707c1a2f7e | ||
|
ed14a0029a | ||
|
989661e4df | ||
|
8874c687d8 | ||
|
58e1997bcb | ||
|
3e3055d7df | ||
|
b68d6e9d1a | ||
|
cf9d200b7c | ||
|
e84da3089a | ||
|
fee38b8d13 | ||
|
e0a69a9e57 | ||
|
0bdc95f9c4 | ||
|
4c9e4e507c | ||
|
adb50fe64f | ||
|
e883aefcc3 | ||
|
cd7e8bbd3e | ||
|
99317f759b | ||
|
7ff6a8651a | ||
|
4e1e1d4fef | ||
|
d34676c5b2 | ||
|
4cf659c29b | ||
|
ec61a5b32d | ||
|
58f726c602 | ||
|
bb0c3835f4 | ||
|
e9642c7505 | ||
|
f0b4ef5917 | ||
|
1f12753c68 | ||
|
0439d122a5 | ||
|
4dad7f2ab6 | ||
|
ce75dc502b | ||
|
23f6c2e8c9 | ||
|
d3461dd69b | ||
|
05b1b8b240 | ||
|
3118ba4466 | ||
|
35cec0f1df | ||
|
a19d238483 | ||
|
a57fa2e9ad | ||
|
c2b36cdffa | ||
|
600b1814a1 | ||
|
76e6957a8a | ||
|
18df79ce00 | ||
|
f14b413b7c | ||
|
d0e73bd6b2 | ||
|
f27b25a62e | ||
|
6d8c7ba140 | ||
|
af497c96ec | ||
|
697c7a8dfe | ||
|
3f5a189591 | ||
|
bcb18ff2f4 | ||
|
b1ba3df989 | ||
|
203ac0970d | ||
|
e5329dc28a | ||
|
f8ef6278a5 | ||
|
7f13a8d2bc | ||
|
b0b078c0fb | ||
|
c282433095 | ||
|
8d7f3bd215 | ||
|
f6c268dc1e | ||
|
48f25b0799 | ||
|
50cfbaaab5 | ||
|
de775511d0 | ||
|
a524a60c46 | ||
|
6cf2fa02e5 | ||
|
1a8cb877db | ||
|
33727aad62 | ||
|
ac79d810d0 | ||
|
2b912c6834 | ||
|
cf2404743d | ||
|
38bffd423c | ||
|
42711d76d6 | ||
|
789f3d993c | ||
|
d5376ab090 | ||
|
62ad7e5ad3 | ||
|
f0cb2fc21a | ||
|
3fe521421f | ||
|
85445c4ef2 | ||
|
b4faf3faad | ||
|
5f2745c32a | ||
|
9ef6713aa1 | ||
|
736c66f46a | ||
|
98b699c483 | ||
|
93044590cc | ||
|
30676d118f | ||
|
e306ac0197 | ||
|
65c6b4af82 | ||
|
a402f646fe | ||
|
db29fed99a | ||
|
94a2104b55 | ||
|
0f886a1ece | ||
|
3db024b24d | ||
|
75bf75d552 | ||
|
5bbe59c9a2 | ||
|
28e447ea4a | ||
|
31a874e24e | ||
|
987412db51 | ||
|
94ab5c7abf | ||
|
d358fb256b | ||
|
e00652ce86 | ||
|
086effa6cb | ||
|
de000a8b4e | ||
|
8f75317820 | ||
|
dbb016c9e4 | ||
|
2831b91e94 | ||
|
923b2594df | ||
|
44874fb5e0 | ||
|
5b3d7ccb46 | ||
|
daecd3efa1 | ||
|
86e4b58117 | ||
|
34426d86dc | ||
|
50a915b7b6 | ||
|
775ba2596a | ||
|
7141962cce | ||
|
d5a4527e9d | ||
|
cf775e3487 | ||
|
44e1bed57a | ||
|
915ba07f86 | ||
|
a852c5d0c3 | ||
|
8cde6cd4d7 | ||
|
a70d59eb45 | ||
|
06534fa0ae | ||
|
8a548ef252 | ||
|
99c854ce1d | ||
|
46fe3c520c | ||
|
8568fc3544 | ||
|
5ba0aef799 | ||
|
71cbf86b2c | ||
|
aed6b34950 | ||
|
e9076c1748 | ||
|
8b0cf7d248 | ||
|
9aa794248f | ||
|
b357e2ecef | ||
|
68e27e9513 | ||
|
7b4e4c2172 | ||
|
9e602eb575 | ||
|
4618c624c8 | ||
|
5979bdd48e | ||
|
2170392bdf | ||
|
e9335d9508 | ||
|
5f444c1c82 | ||
|
a3de277c43 | ||
|
b5b8593e7f | ||
|
03163d6a61 | ||
|
bd90caa99d | ||
|
cada0aa70b | ||
|
cbde357d94 | ||
|
75488e4bd5 | ||
|
d1bdf4a292 | ||
|
c73c063ad5 | ||
|
9b000ff242 | ||
|
a282937468 | ||
|
6da3aab046 | ||
|
ff2589c97f | ||
|
a9f000e7ef | ||
|
d9be63e6cb | ||
|
1a626a68f0 | ||
|
330504b91e | ||
|
6bb0166055 | ||
|
b22988e6b8 | ||
|
f7edac961f | ||
|
5b9b120fa6 | ||
|
f07e4fc87f | ||
|
46eb870c46 | ||
|
a0e192b6e4 | ||
|
dc3fa6c780 | ||
|
dd5604f5d9 | ||
|
170936a96e | ||
|
93c9974019 | ||
|
377579e802 | ||
|
881cf082c2 | ||
|
bfc924bc2a | ||
|
dbd92b2db9 | ||
|
505a68093d | ||
|
f2b81a2f23 | ||
|
c49dbab127 | ||
|
36adfe87fb | ||
|
cdfcf0f068 | ||
|
c3676091ee | ||
|
ec19b86ade | ||
|
ec43f4e6ab | ||
|
7bf74c6a5d | ||
|
cbb50c14e1 | ||
|
d42622a5c1 | ||
|
f9bee1485b | ||
|
ff72e8abab | ||
|
c87eee1fda | ||
|
960aa90c32 | ||
|
c4210be3c7 | ||
|
28a49827ff | ||
|
736869454b | ||
|
db9084b0dc | ||
|
c39d75b448 | ||
|
71a546dd44 | ||
|
96e3d3a22c | ||
|
0ad91101a4 | ||
|
e9df58709e | ||
|
a80dcaa1c3 | ||
|
d29b7fa1c7 | ||
|
89551774db | ||
|
be58d3afb6 | ||
|
652d803739 | ||
|
2688914125 | ||
|
1d489cfcea | ||
|
cb55ce084c | ||
|
dae7da0e4e | ||
|
e4630e6a0f | ||
|
6d9abf11b8 | ||
|
7e8def50aa | ||
|
2b7f72deec | ||
|
9b1f25140e | ||
|
f4caa0029e | ||
|
15e046b3ce | ||
|
9d0485fa22 | ||
|
eeef92b068 | ||
|
a1418fe33c | ||
|
8f21e736dd | ||
|
222301307f | ||
|
1b19fdfe11 | ||
|
1f2ef1cdb7 | ||
|
c394b21423 | ||
|
696e84ea88 | ||
|
4a17dca7b9 | ||
|
b3f38f3264 | ||
|
9bcfe6461d | ||
|
4a82a91f2d | ||
|
24cd905911 | ||
|
6d62ab4257 | ||
|
f6ff32f339 | ||
|
bc523d302b | ||
|
1facbb2906 | ||
|
a5eb87e835 | ||
|
8265436437 | ||
|
96545bd523 | ||
|
dd5ee68808 | ||
|
e773ed2912 | ||
|
9949fc4646 | ||
|
d88da1f6ab | ||
|
fe8e3f2bcf | ||
|
4b9d753254 | ||
|
5ba385b74d | ||
|
96c0a5c911 | ||
|
ec655f5182 | ||
|
093df395c7 | ||
|
92d2373d77 | ||
|
319959ad6e | ||
|
de1e4b4c6c | ||
|
c2e79d22d2 | ||
|
596c9b1d27 | ||
|
40223e6b3f | ||
|
eec1dd6448 | ||
|
6c1261d28d | ||
|
4697ea1c1a | ||
|
6e20031dce | ||
|
a5fe9bc6d6 | ||
|
637660df2a | ||
|
e1549d109e | ||
|
418d270d74 | ||
|
5f8fc3d155 | ||
|
fce3b3ce7b | ||
|
074bd9f045 | ||
|
b1ea26467d | ||
|
48ebd74859 | ||
|
ef5b7ce853 | ||
|
e0053d57f7 | ||
|
06268543d0 | ||
|
58eadd6d7b | ||
|
f250594e97 | ||
|
c1b6828ed4 | ||
|
328ecd1cfb | ||
|
14b3f300ae | ||
|
435e82c824 | ||
|
aeda7520fe | ||
|
f54fb177da | ||
|
56ef8e3ebf | ||
|
717c123b82 | ||
|
132f6c8420 | ||
|
116e16e30d | ||
|
5dbb6afc60 | ||
|
ae8050a3f7 | ||
|
8870e966a6 | ||
|
f5a5cffdec | ||
|
220c622f8f | ||
|
e509749421 | ||
|
a69cec89fb | ||
|
8f5c289818 | ||
|
5e544891aa | ||
|
1aaf4ae5bc | ||
|
9f3188fe45 | ||
|
b2fc7d476a | ||
|
c37885e743 | ||
|
0209ace221 | ||
|
a9c6c681ce | ||
|
b0cd8579f1 | ||
|
ba0753c418 | ||
|
d58545d340 | ||
|
6419ce345f | ||
|
f78a27491a | ||
|
7ef20f22c2 | ||
|
ace3f7f532 | ||
|
fb32770486 | ||
|
98de486534 | ||
|
6c1144f6f4 | ||
|
59edcc3e2e | ||
|
30040a0366 | ||
|
a604644da7 | ||
|
64af3ccf0c | ||
|
401c1364be | ||
|
c7c58ef031 | ||
|
0f9fe92fa3 | ||
|
bbf3d044cb | ||
|
7ed8517771 | ||
|
2deb5cbc9e | ||
|
fbacfba92a | ||
|
fcce9968e9 | ||
|
662b46101c | ||
|
3cda44e05b | ||
|
a7a8aad9bc | ||
|
2e01a93a4a | ||
|
c4ca05865d | ||
|
23287e9b47 | ||
|
ffa7302571 | ||
|
6b36b255ef | ||
|
d5bd77cca1 | ||
|
e5e2f6c2e1 | ||
|
708c6829f7 | ||
|
aeb986243c | ||
|
e654cee3c8 | ||
|
71aa9d05ed | ||
|
725054c7d5 | ||
|
bfad3df3b3 | ||
|
2efba6ea6f | ||
|
e7796f902a | ||
|
b23adf9d5d | ||
|
7345639af3 | ||
|
818629e58b | ||
|
1761289716 | ||
|
5d2fb8791c | ||
|
4fcb1498c2 | ||
|
8d0a420cc8 | ||
|
2e3aa940fb | ||
|
4cdb2f0fa3 | ||
|
79f58f5c6a | ||
|
56d098c317 | ||
|
4ca0ed3779 | ||
|
a3ca8ea5c4 | ||
|
ae479f4a92 | ||
|
ab826363ea | ||
|
e1ce1f2f40 | ||
|
209a727fe9 | ||
|
4d45c07bf2 | ||
|
d053bee8d3 | ||
|
596a46846a | ||
|
0b36daf6ac | ||
|
ffb0fa25f1 | ||
|
8966fd3b23 | ||
|
b6a7104b60 | ||
|
8a92904287 | ||
|
a62bca442e | ||
|
82ad0573be | ||
|
6a74d62e98 | ||
|
5185166e3b | ||
|
693dcf12d6 | ||
|
ee0f847827 | ||
|
55281ed548 | ||
|
2390da1165 | ||
|
06d503152b | ||
|
2e9e69d66c | ||
|
e3d2dd7279 | ||
|
7e8973c9ff | ||
|
78568ef7c0 | ||
|
35ccfb14c2 | ||
|
b2c711a5e8 | ||
|
21da781350 | ||
|
beb04d8181 | ||
|
072a35b4ee | ||
|
735afc64f6 | ||
|
e68f17b463 | ||
|
dfcae92ae0 | ||
|
1ec228e46c | ||
|
d1653ded9a | ||
|
8da581695f | ||
|
9e46ca31dd | ||
|
ad1c8ffe21 | ||
|
6b30e2beb0 | ||
|
d75b7d49b8 | ||
|
53173d9053 | ||
|
e68207be2c | ||
|
146ef1bf49 | ||
|
b178bd6bd6 | ||
|
bd85d9a36a | ||
|
c03b4adec0 | ||
|
b7b16aa33b | ||
|
789a779775 | ||
|
fdc8278b90 | ||
|
525a2ff9a7 | ||
|
78c9e66c1f | ||
|
3038dfdb27 | ||
|
7cd9f644ee | ||
|
64e78329ec | ||
|
e49acda806 | ||
|
b79845fcaa | ||
|
76ef5ef9a9 | ||
|
cc053b148d | ||
|
2979df1d82 | ||
|
b3a0b252ad | ||
|
c305ba3c92 | ||
|
12d4081311 | ||
|
124938ecc9 | ||
|
ea3e8a7459 | ||
|
dc3883ed1a | ||
|
bd69c8f5dd | ||
|
b751861bfb | ||
|
d035523f30 | ||
|
f9906aba7f | ||
|
410695d823 | ||
|
cd198ee1fa | ||
|
2c35c26749 | ||
|
f042d07ee7 | ||
|
4ce5f89c66 | ||
|
e0fa0d1936 | ||
|
424799191c | ||
|
96d65f7d39 | ||
|
98fd08d9e5 | ||
|
faafb45d83 | ||
|
e0f6c801d5 | ||
|
ad274b7cad | ||
|
0a79df3670 | ||
|
f5a05da792 | ||
|
47a2911ee2 | ||
|
fb14785cad | ||
|
b0f7aeece5 | ||
|
73216513dd | ||
|
75b3561594 | ||
|
563eb96282 | ||
|
480ffa0ab3 | ||
|
4baadefa1d | ||
|
9fef4cab73 | ||
|
7fc43b4745 | ||
|
86fb36163c | ||
|
5ad8a3e214 | ||
|
f6f07abf1d | ||
|
233e7bf406 | ||
|
3f34731adf | ||
|
8363cdd9fa | ||
|
e12ea69b6a | ||
|
d790b0b372 | ||
|
9146b98285 | ||
|
2fb67f52a1 | ||
|
c1bb53c4e3 | ||
|
2b921093e1 | ||
|
7c9248a764 | ||
|
5fc0ae1377 | ||
|
3ee99cae75 | ||
|
a15bd3ce33 | ||
|
88fff82e3a | ||
|
3f2a8cc0bb | ||
|
65aca5c38a | ||
|
d8ef4a1031 | ||
|
8d2abe36a9 | ||
|
5a56bc3465 | ||
|
b4cea45bfe | ||
|
4269775665 | ||
|
a1c709bb58 | ||
|
9f92968b96 | ||
|
f43d936ef0 | ||
|
0f739f442e | ||
|
6841846613 | ||
|
d6e6dd09f0 | ||
|
9b61cb1335 | ||
|
7aee53ea05 | ||
|
8df8ee8936 | ||
|
042e989ebb | ||
|
7670b8b738 | ||
|
ec985f6a1d | ||
|
2ec99bbb70 | ||
|
d7edcadec7 | ||
|
150735b001 | ||
|
3c1e81897a | ||
|
601e72b56c | ||
|
09fd743e2e | ||
|
6ae61c5357 | ||
|
eae5a8a2e6 | ||
|
0e05733bbb | ||
|
4a492264a1 | ||
|
174c745279 | ||
|
3cfec63a95 | ||
|
0c92d49f89 | ||
|
1bb7b4a653 | ||
|
72aa2bcad8 | ||
|
c8be5e4267 | ||
|
40b8b225f9 | ||
|
2666241df7 | ||
|
037d4638ea | ||
|
c4f1376faf | ||
|
7d2e850c42 | ||
|
70b6507299 | ||
|
00ba47c4de | ||
|
5b9fd1b101 | ||
|
913305190a | ||
|
3c6f2962cc | ||
|
45c8de42b9 | ||
|
637cda5b4c | ||
|
d935de3e57 | ||
|
13035ecb0d | ||
|
d6b2cf8bcb | ||
|
9afed21efd | ||
|
71d9fbc367 | ||
|
f83859113e | ||
|
00d45e7cc4 | ||
|
406ed8a02a | ||
|
e793b0c49d | ||
|
de740ec57f | ||
|
e5b4cb28fe | ||
|
cb210d82e1 | ||
|
58b956f7cc | ||
|
620b98fe6a | ||
|
3656c8458f | ||
|
fec055d8a8 | ||
|
6420f61566 | ||
|
905bf41cd8 | ||
|
0acb877b03 | ||
|
a8a8becf96 | ||
|
b8a605f07d | ||
|
52f80e0c44 | ||
|
a28bfdbaf8 | ||
|
4d675c11e8 | ||
|
cc56121e67 | ||
|
5952064362 | ||
|
806587c8ea | ||
|
f3dbe5a251 | ||
|
74cff2639b | ||
|
cd671066f7 | ||
|
fb3f5a414a | ||
|
9a952f0e45 | ||
|
cd3a11b16f | ||
|
b7cdea1b82 | ||
|
2092f30af5 | ||
|
dd20c36a55 | ||
|
85bf4ac077 | ||
|
29bc9a45df | ||
|
09fde0528a | ||
|
f7624f29b1 | ||
|
f7e42179d0 | ||
|
6823839f4b | ||
|
a68721fcf2 | ||
|
03a2a4e534 | ||
|
d92108453f | ||
|
03b3f16472 | ||
|
6bd2c2f121 | ||
|
998c5b2969 | ||
|
f509cc73ca | ||
|
f71354faed | ||
|
796eafbf8f | ||
|
46daafd190 | ||
|
b1e26a5063 | ||
|
c77f946be1 | ||
|
acfe6eec18 | ||
|
dce2546f5f | ||
|
8e560d2b2e | ||
|
f920b2001d | ||
|
c2b37f811b | ||
|
0e77177a60 | ||
|
9aaceda40d | ||
|
eb1573de4f | ||
|
301cbbfdfb | ||
|
3490c294dc | ||
|
dbdb9baf5f | ||
|
7da0cc6551 | ||
|
de78fd07c5 | ||
|
2ae62d9c0e | ||
|
ee4564d926 | ||
|
909e2ab60f | ||
|
17d6f3d715 | ||
|
f4fc682fa3 | ||
|
80904ac2bb | ||
|
91322f33eb | ||
|
962f434e17 | ||
|
4542c18385 | ||
|
2625c1246b | ||
|
0af55366f1 | ||
|
bd7567fa85 | ||
|
9d328e489b | ||
|
8ba1dedcea | ||
|
bf8d136936 | ||
|
0508b0080f | ||
|
1d2a95361b | ||
|
47f055872e | ||
|
15429cd67f | ||
|
be4144fab3 | ||
|
7d7f3716be | ||
|
4e644cfca7 | ||
|
30ff915e9d | ||
|
7572c99f4d | ||
|
bb30fdfa17 | ||
|
a4743034a9 | ||
|
c2a79e8a11 | ||
|
ec88785b6d | ||
|
d923869dc3 | ||
|
ae8ceb8dca | ||
|
8db26bbd5f | ||
|
efd3310711 | ||
|
a6890c0f58 | ||
|
b5a69b66b2 | ||
|
54447ba677 | ||
|
13a86aead7 | ||
|
73f4ea38c6 | ||
|
82c025a34a | ||
|
fb127f7857 | ||
|
6f82140ba5 | ||
|
6a9751efce | ||
|
5c75b6c784 | ||
|
34ea20b109 | ||
|
6e6d2a3310 | ||
|
56ba8a65e0 | ||
|
0e784c50ad | ||
|
73865651a0 | ||
|
f863a562a0 | ||
|
0d3bd42780 | ||
|
62f8675199 | ||
|
8a1c2f89be | ||
|
5843303076 | ||
|
e2c1475857 | ||
|
8d34b0f454 | ||
|
b44caeeefb | ||
|
502d22bd20 | ||
|
8d60a414f0 | ||
|
e8d71c815e | ||
|
1afca0b28a | ||
|
f9f29afba9 | ||
|
f52bcc2a37 | ||
|
2647bdb4ed | ||
|
c98517fc48 | ||
|
e8dd391ccf | ||
|
e3c3cd3d18 | ||
|
b05b96e3a4 | ||
|
b85245e317 | ||
|
f606a61e1c | ||
|
a10f4d8abc | ||
|
c978de2c10 | ||
|
6c1862797b | ||
|
4f0108b0ea | ||
|
7b5af271d5 | ||
|
37587f6f71 | ||
|
328a903c24 | ||
|
fdbdd33e77 | ||
|
a356ee857c | ||
|
cf6f09b7b8 | ||
|
a90e646381 | ||
|
16028795f9 | ||
|
12f342f015 | ||
|
e248e9196a | ||
|
fea4f561b4 | ||
|
d226090926 | ||
|
2d534bfdf4 | ||
|
ed5cd11d6a | ||
|
0a60a87c9f | ||
|
506cdbc83c | ||
|
a64c4384b1 | ||
|
d54c983187 | ||
|
3825b3c2c2 | ||
|
4369b26e22 | ||
|
6f7c2916ef | ||
|
b419e7d918 | ||
|
ad8e09d188 | ||
|
bce020fc8e | ||
|
a811637176 | ||
|
cd5f8f7407 | ||
|
00a282214e | ||
|
44145f11db | ||
|
ad63b10aea | ||
|
327ac765df | ||
|
83cea2adb8 | ||
|
9ba59351c5 | ||
|
2a79d35667 | ||
|
0385f6ede9 | ||
|
45bf2c3d25 | ||
|
e6b64b0f2c | ||
|
4dc1e025e1 | ||
|
ba08b10de1 | ||
|
de35862cc5 | ||
|
407c9fc9c3 | ||
|
536358cb3c | ||
|
5f7a264e52 | ||
|
c70c311989 | ||
|
e5aa72af76 | ||
|
4dbc984351 | ||
|
eb586ba361 | ||
|
1db210097c | ||
|
7f16698f01 | ||
|
61eb356fd9 | ||
|
35a402c67e | ||
|
5a910e0f29 | ||
|
be8a35063c | ||
|
df75b9973a | ||
|
2f2e0b6762 | ||
|
83862eae44 | ||
|
6e6faec398 | ||
|
e7e39ba56e | ||
|
fe9dd88c3f | ||
|
b3fdf3efec | ||
|
802ea20ec4 | ||
|
52a217883d | ||
|
7783545bff | ||
|
1b140a1ed3 | ||
|
bf50ebac94 | ||
|
7599ae385b | ||
|
7827b40f17 | ||
|
dea3d26335 | ||
|
9eb77207fb | ||
|
164d3fb4fe | ||
|
0c9add7988 | ||
|
166329abee | ||
|
7d7abca2c4 | ||
|
dab69c7507 | ||
|
852f8b8aa5 | ||
|
fe858621f2 | ||
|
6e4e804af8 | ||
|
e68aaf24f1 | ||
|
29c20a3d5c | ||
|
6ed61f533a | ||
|
ad7a365f32 | ||
|
2c5420ab9e | ||
|
8d6d6bbf5d | ||
|
d5235c81d0 | ||
|
9e5b119e92 | ||
|
d9fc9cd198 | ||
|
8b4db412d8 | ||
|
9c6f3ebb54 | ||
|
53e30e20db | ||
|
d908099798 | ||
|
469c8e9b00 | ||
|
cd9799588d | ||
|
447dec9444 | ||
|
45088a3e06 | ||
|
08f3c7026d | ||
|
e606810581 | ||
|
73b2ede53a | ||
|
f1b2f895b4 | ||
|
9724308008 | ||
|
be4706d02d | ||
|
752c083905 | ||
|
c47e90004a | ||
|
380f818285 | ||
|
f893f53b1c | ||
|
cac604aee3 | ||
|
525b5b8ee6 | ||
|
974c455bf9 | ||
|
b94607f636 | ||
|
8a05f84838 | ||
|
ca0082856a | ||
|
e12238ba8b | ||
|
71f119c9e8 | ||
|
067121d968 | ||
|
f5d775c055 | ||
|
f979822508 | ||
|
cfbe6d77b5 | ||
|
dee7950601 | ||
|
18d8eb4767 | ||
|
f2d019ff97 | ||
|
3a170d50cb | ||
|
17ee2ee135 | ||
|
07956486b5 | ||
|
e648a59b1f | ||
|
83400093a2 | ||
|
6782e65fdf | ||
|
7d5e742ea6 | ||
|
19b511e3f8 | ||
|
cde3cbed21 | ||
|
307be405ac | ||
|
1c017b8ee9 | ||
|
48dc2c2295 | ||
|
3a3999d73a | ||
|
7f3cbaa064 | ||
|
7a5c35baf3 | ||
|
eb72c83b7e | ||
|
e93ea0fa97 | ||
|
3b6f93afdf | ||
|
4597a2286a | ||
|
780968979d | ||
|
adc0db4c74 | ||
|
f0dd95a2a3 | ||
|
6155a190ac | ||
|
493aa8c591 | ||
|
6c82f804ae | ||
|
a2807c9815 | ||
|
d822f333c2 | ||
|
8f498075b9 | ||
|
c4bf6a6383 | ||
|
939402b2d9 | ||
|
684a3c41ef | ||
|
306f0850e9 | ||
|
55f37248f7 | ||
|
c93a46a02f | ||
|
77a3fdea5f |
29
.env.template
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
# Your openai api key. (required)
|
||||
OPENAI_API_KEY=sk-xxxx
|
||||
|
||||
# Access passsword, separated by comma. (optional)
|
||||
CODE=your-password
|
||||
|
||||
# You can start service behind a proxy
|
||||
PROXY_URL=http://localhost:7890
|
||||
|
||||
# Override openai api request base url. (optional)
|
||||
# Default: https://api.openai.com
|
||||
# Examples: http://your-openai-proxy.com
|
||||
BASE_URL=
|
||||
|
||||
# Specify OpenAI organization ID.(optional)
|
||||
# Default: Empty
|
||||
# If you do not want users to input their own API key, set this value to 1.
|
||||
OPENAI_ORG_ID=
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# If you do not want users to input their own API key, set this value to 1.
|
||||
HIDE_USER_API_KEY=
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# If you do not want users to use GPT-4, set this value to 1.
|
||||
DISABLE_GPT4=
|
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": ["prettier"]
|
||||
}
|
||||
|
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[Bug] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Deployment**
|
||||
- [ ] Docker
|
||||
- [ ] Vercel
|
||||
- [ ] Server
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional Logs**
|
||||
Add any logs about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
24
.github/ISSUE_TEMPLATE/功能建议.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 功能建议
|
||||
about: 请告诉我们你的灵光一闪
|
||||
title: "[Feature] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
|
||||
|
||||
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
|
||||
|
||||
**这个功能与现有的问题有关吗?**
|
||||
如果有关,请在此列出链接或者描述问题。
|
||||
|
||||
**你想要什么功能或者有什么建议?**
|
||||
尽管告诉我们。
|
||||
|
||||
**有没有可以参考的同类竞品?**
|
||||
可以给出参考产品的链接或者截图。
|
||||
|
||||
**其他信息**
|
||||
可以说说你的其他考虑。
|
36
.github/ISSUE_TEMPLATE/反馈问题.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: 反馈问题
|
||||
about: 请告诉我们你遇到的问题
|
||||
title: "[Bug] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
|
||||
|
||||
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
|
||||
|
||||
**反馈须知**
|
||||
|
||||
⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭,如果没有提供下方的信息,我们无法定位你的问题。
|
||||
|
||||
请在下方中括号内输入 x 来表示你已经知晓相关内容。
|
||||
- [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答;
|
||||
- [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。
|
||||
- [ ] 我确认已经在 [Vercel 使用教程](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/vercel-cn.md) 中搜索了此次反馈的问题,没有找到解答。
|
||||
|
||||
**描述问题**
|
||||
请在此描述你遇到了什么问题。
|
||||
|
||||
**如何复现**
|
||||
请告诉我们你是通过什么操作触发的该问题。
|
||||
|
||||
**截图**
|
||||
请在此提供控制台截图、屏幕截图或者服务端的 log 截图。
|
||||
|
||||
**一些必要的信息**
|
||||
- 系统:[比如 windows 10/ macos 12/ linux / android 11 / ios 16]
|
||||
- 浏览器: [比如 chrome, safari]
|
||||
- 版本: [填写设置页面的版本号]
|
||||
- 部署方式:[比如 vercel、docker 或者服务器部署]
|
35
.github/workflows/docker.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -9,25 +10,43 @@ jobs:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
-
|
||||
name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
|
||||
-
|
||||
name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
-
|
||||
name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: yidadaa/chatgpt-next-web
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
-
|
||||
name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
40
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Upstream Sync
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # every day
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
# Step 1: run a standard checkout action
|
||||
- name: Checkout target repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Step 2: run the sync action
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: Yidadaa/ChatGPT-Next-Web
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
|
||||
# Set test_mode true to run tests instead of the true action!!
|
||||
test_mode: false
|
||||
|
||||
- name: Sync check
|
||||
if: failure()
|
||||
run: |
|
||||
echo "::error::由于权限不足,导致同步失败(这是预期的行为),请前往仓库首页手动执行[Sync fork]。"
|
||||
echo "::error::Due to insufficient permissions, synchronization failed (as expected). Please go to the repository homepage and manually perform [Sync fork]."
|
||||
exit 1
|
8
.gitignore
vendored
@@ -34,4 +34,10 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
dev
|
||||
dev
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# docker-compose env files
|
||||
.env
|
||||
|
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
6
.lintstagedrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"./app/**/*.{js,ts,jsx,tsx,json,html,css,md}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
10
.prettierrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
trailingComma: 'all',
|
||||
bracketSpacing: true,
|
||||
arrowParens: 'always',
|
||||
};
|
4
.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible 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.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders 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, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
flynn.zhang@foxmail.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
34
Dockerfile
@@ -6,13 +6,10 @@ RUN apk add --no-cache libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock* package-lock.json* ./
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
RUN yarn config set registry 'https://registry.npmmirror.com/'
|
||||
RUN yarn install
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
@@ -20,7 +17,6 @@ RUN apk update && apk add --no-cache git
|
||||
|
||||
ENV OPENAI_API_KEY=""
|
||||
ENV CODE=""
|
||||
ARG DOCKER=true
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -31,6 +27,9 @@ RUN yarn build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add proxychains-ng
|
||||
|
||||
ENV PROXY_URL=""
|
||||
ENV OPENAI_API_KEY=""
|
||||
ENV CODE=""
|
||||
|
||||
@@ -41,4 +40,23 @@ COPY --from=builder /app/.next/server ./.next/server
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node","server.js"]
|
||||
CMD if [ -n "$PROXY_URL" ]; then \
|
||||
export HOSTNAME="127.0.0.1"; \
|
||||
protocol=$(echo $PROXY_URL | cut -d: -f1); \
|
||||
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
|
||||
port=$(echo $PROXY_URL | cut -d: -f3); \
|
||||
conf=/etc/proxychains.conf; \
|
||||
echo "strict_chain" > $conf; \
|
||||
echo "proxy_dns" >> $conf; \
|
||||
echo "remote_dns_subnet 224" >> $conf; \
|
||||
echo "tcp_read_time_out 15000" >> $conf; \
|
||||
echo "tcp_connect_time_out 8000" >> $conf; \
|
||||
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
|
||||
echo "localnet ::1/128" >> $conf; \
|
||||
echo "[ProxyList]" >> $conf; \
|
||||
echo "$protocol $host $port" >> $conf; \
|
||||
cat /etc/proxychains.conf; \
|
||||
proxychains -f $conf node server.js; \
|
||||
else \
|
||||
node server.js; \
|
||||
fi
|
||||
|
273
README.md
@@ -1,92 +1,140 @@
|
||||
<div align="center">
|
||||
<img src="./static/icon.svg" alt="预览"/>
|
||||
<img src="./docs/images/icon.svg" alt="icon"/>
|
||||
|
||||
<h1 align="center">ChatGPT Next Web</h1>
|
||||
|
||||
English / [简体中文](./README_CN.md)
|
||||
|
||||
One-Click to deploy well-designed ChatGPT web UI on Vercel.
|
||||
|
||||
一键免费部署你的私人 ChatGPT 网页应用。
|
||||
|
||||
One-Click to deploy your own ChatGPT web UI.
|
||||
[Demo](https://chatgpt.nextweb.fun/) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
||||
|
||||
[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) / [微信群](https://user-images.githubusercontent.com/16968934/227772522-b3ba3713-9206-4c8d-a81f-22300b7c313a.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg)
|
||||
[演示](https://chatgpt.nextweb.fun/) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 在 1 分钟内使用 Vercel **免费一键部署**
|
||||
- 精心设计的 UI,响应式设计,支持深色模式
|
||||
- 极快的首屏加载速度(~85kb)
|
||||
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
||||
- 一键导出聊天记录,完整的 Markdown 支持
|
||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
||||
|
||||
## Features
|
||||
|
||||
- **Deploy for free with one-click** on Vercel in under 1 minute
|
||||
- Responsive design, and dark mode
|
||||
- Fast first screen loading speed (~85kb)
|
||||
- Privacy first, all data stored locally in the browser
|
||||
- Markdown support: LaTex, mermaid, code highlight, etc.
|
||||
- Responsive design, dark mode and PWA
|
||||
- Fast first screen loading speed (~100kb), support streaming response
|
||||
- New in v2: create, share and debug your chat tools with prompt templates (mask)
|
||||
- Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts)
|
||||
- Automatically compresses chat history to support long conversations while also saving your tokens
|
||||
- One-click export all chat history with full Markdown support
|
||||
- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어
|
||||
|
||||
## 使用
|
||||
## Roadmap
|
||||
|
||||
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||
2. 点击右侧按钮开始部署:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登陆即可,记得在环境变量页填入 API Key;
|
||||
3. 部署完毕后,即可开始使用;
|
||||
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
||||
- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
||||
- [x] User Prompt: user can edit and save custom prompts to prompt list
|
||||
- [x] Prompt Template: create a new chat with pre-defined in-context prompts [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
||||
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||
- [ ] Desktop App with tauri
|
||||
- [ ] Self-host Model: support llama, alpaca, ChatGLM, BELLE etc.
|
||||
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||
|
||||
### Not in Plan
|
||||
|
||||
- User login, accounts, cloud sync
|
||||
- UI text customize
|
||||
|
||||
## What's New
|
||||
|
||||
- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/).
|
||||
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 在 1 分钟内使用 Vercel **免费一键部署**
|
||||
- 完整的 Markdown 支持:LaTex 公式、Mermaid 流程图、代码高亮等等
|
||||
- 精心设计的 UI,响应式设计,支持深色模式,支持 PWA
|
||||
- 极快的首屏加载速度(~100kb),支持流式响应
|
||||
- 隐私安全,所有数据保存在用户浏览器本地
|
||||
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
|
||||
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
||||
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
||||
- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština
|
||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
||||
|
||||
## 开发计划
|
||||
|
||||
- [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
||||
- [x] 允许用户自行编辑内置 Prompt 列表
|
||||
- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
||||
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||
- [ ] 使用 tauri 打包桌面应用
|
||||
- [ ] 支持自部署的大语言模型
|
||||
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||
|
||||
### 不会开发的功能
|
||||
|
||||
- 界面文字自定义
|
||||
- 用户登录、账号管理、消息云同步
|
||||
|
||||
## 最新动态
|
||||
|
||||
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||
|
||||
## Get Started
|
||||
|
||||
> [简体中文 > 如何开始使用](./README_CN.md#开始使用)
|
||||
|
||||
1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||
2. Click
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web);
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web), remember that `CODE` is your page password;
|
||||
3. Enjoy :)
|
||||
|
||||
## 保持更新 Keep Updated
|
||||
## FAQ
|
||||
|
||||
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
||||
推荐你按照下列步骤重新部署:
|
||||
[简体中文 > 常见问题](./docs/faq-cn.md)
|
||||
|
||||
- 删除掉原先的 repo;
|
||||
- fork 本项目;
|
||||
- 前往 vercel 控制台,删除掉原先的 project,然后新建 project,选择你刚刚 fork 出来的项目重新进行部署即可;
|
||||
- 在重新部署的过程中,请手动添加名为 `OPENAI_API_KEY` 的环境变量,并填入你的 api key 作为值。
|
||||
[English > FAQ](./docs/faq-en.md)
|
||||
|
||||
本项目会持续更新,如果你想让代码库总是保持更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步,建议定期进行同步操作以获得新功能。
|
||||
## Keep Updated
|
||||
|
||||
你可以 star/watch 本项目或者 follow 作者来及时获得新功能更新通知。
|
||||
> [简体中文 > 如何保持代码更新](./README_CN.md#保持更新)
|
||||
|
||||
If you have deployed your own project with just one click following the steps above, you may encounter the issue of "Updates Available" constantly showing up. This is because Vercel will create a new project for you by default instead of forking this project, resulting in the inability to detect updates correctly.
|
||||
|
||||
We recommend that you follow the steps below to re-deploy:
|
||||
|
||||
- Delete the original repo;
|
||||
- Fork this project;
|
||||
- Go to the Vercel dashboard, delete the original project, then create a new project and select the project you just forked to redeploy;
|
||||
- Please manually add an environment variable named `OPENAI_API_KEY` and enter your API key as the value during the redeploy process.
|
||||
- Delete the original repository;
|
||||
- Use the fork button in the upper right corner of the page to fork this project;
|
||||
- Choose and deploy in Vercel again, [please see the detailed tutorial](./docs/vercel-cn.md).
|
||||
|
||||
This project will be continuously maintained. If you want to keep the code repository up to date, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code. It is recommended to perform synchronization operations regularly.
|
||||
### Enable Automatic Updates
|
||||
|
||||
> If you encounter a failure of Upstream Sync execution, please manually sync fork once.
|
||||
|
||||
After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Manually Updating Code
|
||||
|
||||
If you want to update instantly, you can check out the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
|
||||
|
||||
You can star or watch this project or follow author to get release notifictions in time.
|
||||
|
||||
## 访问控制 Access Control
|
||||
## Access Password
|
||||
|
||||
本项目提供有限的权限控制功能,请在环境变量页增加名为 `CODE` 的环境变量,值为用英文逗号分隔的自定义控制码:
|
||||
> [简体中文 > 如何增加访问密码](./README_CN.md#配置页面访问密码)
|
||||
|
||||
```
|
||||
code1,code2,code3
|
||||
```
|
||||
|
||||
增加或修改该环境变量后,请**重新部署**项目使改动生效。
|
||||
|
||||
This project provides limited access control. Please add an environment variable named `CODE` on the environment variables page. The value should be a custom control code separated by comma like this:
|
||||
This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this:
|
||||
|
||||
```
|
||||
code1,code2,code3
|
||||
@@ -94,76 +142,137 @@ code1,code2,code3
|
||||
|
||||
After adding or modifying this environment variable, please redeploy the project for the changes to take effect.
|
||||
|
||||
## 开发 Development
|
||||
## Environment Variables
|
||||
|
||||
点击下方按钮,开始二次开发:
|
||||
> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量)
|
||||
|
||||
### `OPENAI_API_KEY` (required)
|
||||
|
||||
Your openai api key.
|
||||
|
||||
### `CODE` (optional)
|
||||
|
||||
Access passsword, separated by comma.
|
||||
|
||||
### `BASE_URL` (optional)
|
||||
|
||||
> Default: `https://api.openai.com`
|
||||
|
||||
> Examples: `http://your-openai-proxy.com`
|
||||
|
||||
Override openai api request base url.
|
||||
|
||||
### `OPENAI_ORG_ID` (optional)
|
||||
|
||||
Specify OpenAI organization ID.
|
||||
|
||||
### `HIDE_USER_API_KEY` (optional)
|
||||
|
||||
> Default: Empty
|
||||
|
||||
If you do not want users to input their own API key, set this value to 1.
|
||||
|
||||
### `DISABLE_GPT4` (optional)
|
||||
|
||||
> Default: Empty
|
||||
|
||||
If you do not want users to use GPT-4, set this value to 1.
|
||||
|
||||
## Development
|
||||
|
||||
> [简体中文 > 如何进行二次开发](./README_CN.md#开发)
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||
在开始写代码之前,需要在项目根目录新建一个 `.env.local` 文件,里面填入环境变量:
|
||||
|
||||
Before starting development, you must create a new `.env.local` file at project root, and place your api key into it:
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=<your api key here>
|
||||
```
|
||||
|
||||
### 本地开发 Local Development
|
||||
### Local Development
|
||||
|
||||
> 如果你是中国大陆用户,不建议在本地进行开发,除非你能够独立解决 OpenAI API 本地代理问题。
|
||||
|
||||
1. 安装 nodejs 和 yarn,具体细节请询问 ChatGPT;
|
||||
2. 执行 `yarn install && yarn dev` 即可。
|
||||
|
||||
### 本地部署 Local Deployment
|
||||
|
||||
请直接询问 ChatGPT,使用下列 Prompt:
|
||||
|
||||
```
|
||||
如何使用 pm2 和 yarn 部署 nextjs 项目到 ubuntu 服务器上,项目编译命令为 yarn build,启动命令为 yarn start,启动时需要设置环境变量为 OPENAI_API_KEY,端口为 3000,使用 ngnix 做反向代理
|
||||
```shell
|
||||
# 1. install nodejs and yarn first
|
||||
# 2. config local env vars in `.env.local`
|
||||
# 3. run
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Please ask ChatGPT with prompt:
|
||||
## Deployment
|
||||
|
||||
```
|
||||
how to deploy nextjs project with pm2 and yarn on my ubuntu server, the build command is `yarn build`, the start command is `yarn start`, the project must start with env var named `OPENAI_API_KEY`, the port is 3000, use ngnix
|
||||
```
|
||||
> [简体中文 > 如何部署到私人服务器](./README_CN.md#部署)
|
||||
|
||||
### 容器部署 Docker Deployment
|
||||
### Docker (Recommended)
|
||||
|
||||
```shell
|
||||
docker pull yidadaa/chatgpt-next-web
|
||||
|
||||
docker run -d -p 3000:3000 -e OPEN_API_KEY="" -e CODE="" aprilnea/chatgpt-next-web
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="your-password" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
## 截图 Screenshots
|
||||
You can start service behind a proxy:
|
||||
|
||||

|
||||
```shell
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="your-password" \
|
||||
-e PROXY_URL="http://localhost:7890" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||

|
||||
### Shell
|
||||
|
||||
## 说明 Attention
|
||||
```shell
|
||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||
```
|
||||
|
||||
本项目的演示地址所用的 OpenAI 账户的免费额度将于 2023-04-01 过期,届时将无法通过演示地址在线体验。
|
||||
## Screenshots
|
||||
|
||||
如果你想贡献出自己的 API Key,可以通过作者主页的邮箱发送给作者,并标注过期时间。
|
||||

|
||||
|
||||
The free trial of the OpenAI account used by the demo will expire on April 1, 2023, and the demo will not be available at that time.
|
||||

|
||||
|
||||
If you would like to contribute your API key, you can email it to the author and indicate the expiration date of the API key.
|
||||
## Donation
|
||||
|
||||
## 鸣谢 Special Thanks
|
||||
[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
||||
|
||||
### 捐赠者 Sponsor
|
||||
## Special Thanks
|
||||
|
||||
### Sponsor
|
||||
|
||||
> 仅列出捐赠金额 >= 100RMB 的用户。
|
||||
|
||||
[@mushan0x0](https://github.com/mushan0x0)
|
||||
[@ClarenceDan](https://github.com/ClarenceDan)
|
||||
[@zhangjia](https://github.com/zhangjia)
|
||||
[@hoochanlon](https://github.com/hoochanlon)
|
||||
[@relativequantum](https://github.com/relativequantum)
|
||||
[@desenmeng](https://github.com/desenmeng)
|
||||
[@webees](https://github.com/webees)
|
||||
[@chazzhou](https://github.com/chazzhou)
|
||||
[@hauy](https://github.com/hauy)
|
||||
[@Corwin006](https://github.com/Corwin006)
|
||||
[@yankunsong](https://github.com/yankunsong)
|
||||
[@ypwhs](https://github.com/ypwhs)
|
||||
[@fxxxchao](https://github.com/fxxxchao)
|
||||
[@hotic](https://github.com/hotic)
|
||||
[@WingCH](https://github.com/WingCH)
|
||||
[@jtung4](https://github.com/jtung4)
|
||||
[@micozhu](https://github.com/micozhu)
|
||||
[@jhansion](https://github.com/jhansion)
|
||||
[@Sha1rholder](https://github.com/Sha1rholder)
|
||||
[@AnsonHyq](https://github.com/AnsonHyq)
|
||||
[@synwith](https://github.com/synwith)
|
||||
|
||||
### 贡献者 Contributor
|
||||
### Contributor
|
||||
|
||||
[@AprilNEA](https://github.com/AprilNEA)
|
||||
[Contributors](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||
|
||||
## LICENSE
|
||||
|
||||
- [Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
||||
[Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
||||
|
175
README_CN.md
Normal file
@@ -0,0 +1,175 @@
|
||||
<div align="center">
|
||||
<img src="./docs/images/icon.svg" alt="预览"/>
|
||||
|
||||
<h1 align="center">ChatGPT Next Web</h1>
|
||||
|
||||
一键免费部署你的私人 ChatGPT 网页应用。
|
||||
|
||||
[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) / [QQ 群](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donate](#捐赠-donate-usdt)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## 开始使用
|
||||
|
||||
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||
2. 点击右侧按钮开始部署:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE;
|
||||
3. 部署完毕后,即可开始使用;
|
||||
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
||||
|
||||
## 保持更新
|
||||
|
||||
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
||||
推荐你按照下列步骤重新部署:
|
||||
|
||||
- 删除掉原先的仓库;
|
||||
- 使用页面右上角的 fork 按钮,fork 本项目;
|
||||
- 在 Vercel 重新选择并部署,[请查看详细教程](./docs/vercel-cn.md#如何新建项目)。
|
||||
|
||||
### 打开自动更新
|
||||
|
||||
> 如果你遇到了 Upstream Sync 执行错误,请手动 Sync Fork 一次!
|
||||
|
||||
当你 fork 项目之后,由于 Github 的限制,需要手动去你 fork 后的项目的 Actions 页面启用 Workflows,并启用 Upstream Sync Action,启用之后即可开启每小时定时自动更新:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 手动更新代码
|
||||
|
||||
如果你想让手动立即更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步。
|
||||
|
||||
你可以 star/watch 本项目或者 follow 作者来及时获得新功能更新通知。
|
||||
|
||||
## 配置页面访问密码
|
||||
|
||||
> 配置密码后,用户需要在设置页手动填写访问码才可以正常聊天,否则会通过消息提示未授权状态。
|
||||
|
||||
> **警告**:请务必将密码的位数设置得足够长,最好 7 位以上,否则[会被爆破](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
|
||||
|
||||
本项目提供有限的权限控制功能,请在 Vercel 项目控制面板的环境变量页增加名为 `CODE` 的环境变量,值为用英文逗号分隔的自定义密码:
|
||||
|
||||
```
|
||||
code1,code2,code3
|
||||
```
|
||||
|
||||
增加或修改该环境变量后,请**重新部署**项目使改动生效。
|
||||
|
||||
## 环境变量
|
||||
|
||||
> 本项目大多数配置项都通过环境变量来设置,教程:[如何修改 Vercel 环境变量](./docs/vercel-cn.md)。
|
||||
|
||||
### `OPENAI_API_KEY` (必填项)
|
||||
|
||||
OpanAI 密钥,你在 openai 账户页面申请的 api key。
|
||||
|
||||
### `CODE` (可选)
|
||||
|
||||
访问密码,可选,可以使用逗号隔开多个密码。
|
||||
|
||||
**警告**:如果不填写此项,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||
|
||||
### `BASE_URL` (可选)
|
||||
|
||||
> Default: `https://api.openai.com`
|
||||
|
||||
> Examples: `http://your-openai-proxy.com`
|
||||
|
||||
OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填写此选项。
|
||||
|
||||
> 如果遇到 ssl 证书问题,请将 `BASE_URL` 的协议设置为 http。
|
||||
|
||||
### `OPENAI_ORG_ID` (可选)
|
||||
|
||||
指定 OpenAI 中的组织 ID。
|
||||
|
||||
### `HIDE_USER_API_KEY` (可选)
|
||||
|
||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||
|
||||
### `DISABLE_GPT4` (可选)
|
||||
|
||||
如果你不想让用户使用 GPT-4,将此环境变量设置为 1 即可。
|
||||
|
||||
## 开发
|
||||
|
||||
> 强烈不建议在本地进行开发或者部署,由于一些技术原因,很难在本地配置好 OpenAI API 代理,除非你能保证可以直连 OpenAI 服务器。
|
||||
|
||||
点击下方按钮,开始二次开发:
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||
在开始写代码之前,需要在项目根目录新建一个 `.env.local` 文件,里面填入环境变量:
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=<your api key here>
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
||||
1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT;
|
||||
2. 执行 `yarn install && yarn dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署!
|
||||
3. 如果你想本地部署,请使用 `yarn install && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。
|
||||
|
||||
## 部署
|
||||
|
||||
### 容器部署 (推荐)
|
||||
|
||||
> Docker 版本需要在 20 及其以上,否则会提示找不到镜像。
|
||||
|
||||
> ⚠️ 注意:docker 版本在大多数时间都会落后最新的版本 1 到 2 天,所以部署后会持续出现“存在更新”的提示,属于正常现象。
|
||||
|
||||
```shell
|
||||
docker pull yidadaa/chatgpt-next-web
|
||||
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="页面访问密码" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
你也可以指定 proxy:
|
||||
|
||||
```shell
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="页面访问密码" \
|
||||
--net=host \
|
||||
-e PROXY_URL="http://127.0.0.1:7890" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
如果你需要指定其他环境变量,请自行在上述命令中增加 `-e 环境变量=环境变量值` 来指定。
|
||||
|
||||
### 本地部署
|
||||
|
||||
在控制台运行下方命令:
|
||||
|
||||
```shell
|
||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||
```
|
||||
|
||||
⚠️ 注意:如果你安装过程中遇到了问题,请使用 docker 部署。
|
||||
|
||||
## 鸣谢
|
||||
|
||||
### 捐赠者
|
||||
|
||||
> 见英文版。
|
||||
|
||||
### 贡献者
|
||||
|
||||
[见项目贡献者列表](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||
|
||||
## 开源协议
|
||||
|
||||
> 反对 996,从我开始。
|
||||
|
||||
[Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
171
README_ES.md
Normal file
@@ -0,0 +1,171 @@
|
||||
<div align="center">
|
||||
<img src="./docs/images/icon.svg" alt="预览"/>
|
||||
|
||||
<h1 align="center">ChatGPT Next Web</h1>
|
||||
|
||||
Implemente su aplicación web privada ChatGPT de forma gratuita con un solo clic.
|
||||
|
||||
[Demo demo](https://chat-gpt-next-web.vercel.app/) / [Problemas de comentarios](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Únete a Discord](https://discord.gg/zrhvHCr79N) / [Grupo QQ](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [Desarrolladores de consejos](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donar](#捐赠-donate-usdt)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web\&env=OPENAI_API_KEY\&env=CODE\&project-name=chatgpt-next-web\&repository-name=ChatGPT-Next-Web)
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Comenzar
|
||||
|
||||
1. Prepara el tuyo [Clave API OpenAI](https://platform.openai.com/account/api-keys);
|
||||
2. Haga clic en el botón de la derecha para iniciar la implementación:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web\&env=OPENAI_API_KEY\&env=CODE\&project-name=chatgpt-next-web\&repository-name=ChatGPT-Next-Web), inicie sesión directamente con su cuenta de Github y recuerde completar la clave API y la suma en la página de variables de entorno[Contraseña de acceso a la página](#配置页面访问密码) CÓDIGO;
|
||||
3. Una vez implementado, puede comenzar;
|
||||
4. (Opcional)[Enlazar un nombre de dominio personalizado](https://vercel.com/docs/concepts/projects/domains/add-a-domain): El nombre de dominio DNS asignado por Vercel está contaminado en algunas regiones y puede conectarse directamente enlazando un nombre de dominio personalizado.
|
||||
|
||||
## Manténgase actualizado
|
||||
|
||||
Si sigue los pasos anteriores para implementar su proyecto con un solo clic, es posible que siempre diga "La actualización existe" porque Vercel creará un nuevo proyecto para usted de forma predeterminada en lugar de bifurcar el proyecto, lo que evitará que la actualización se detecte correctamente.
|
||||
Le recomendamos que siga estos pasos para volver a implementar:
|
||||
|
||||
* Eliminar el repositorio original;
|
||||
* Utilice el botón de bifurcación en la esquina superior derecha de la página para bifurcar este proyecto;
|
||||
* En Vercel, vuelva a seleccionar e implementar,[Echa un vistazo al tutorial detallado](./docs/vercel-cn.md#如何新建项目)。
|
||||
|
||||
### Activar actualizaciones automáticas
|
||||
|
||||
> Si encuentra un error de ejecución de Upstream Sync, ¡Sync Fork manualmente una vez!
|
||||
|
||||
Cuando bifurca el proyecto, debido a las limitaciones de Github, debe ir manualmente a la página Acciones de su proyecto bifurcado para habilitar Flujos de trabajo y habilitar Upstream Sync Action, después de habilitarlo, puede activar las actualizaciones automáticas cada hora:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Actualizar el código manualmente
|
||||
|
||||
Si desea que el manual se actualice inmediatamente, puede consultarlo [Documentación para Github](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) Aprenda a sincronizar un proyecto bifurcado con código ascendente.
|
||||
|
||||
Puede destacar / ver este proyecto o seguir al autor para recibir notificaciones de nuevas actualizaciones de funciones.
|
||||
|
||||
## Configurar la contraseña de acceso a la página
|
||||
|
||||
> Después de configurar la contraseña, el usuario debe completar manualmente el código de acceso en la página de configuración para chatear normalmente, de lo contrario, se solicitará el estado no autorizado a través de un mensaje.
|
||||
|
||||
> **advertir**: Asegúrese de establecer el número de dígitos de la contraseña lo suficientemente largo, preferiblemente más de 7 dígitos, de lo contrario[Será volado](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
|
||||
|
||||
Este proyecto proporciona control de permisos limitado, agregue el nombre al nombre en la página Variables de entorno del Panel de control del proyecto Vercel `CODE` Variables de entorno con valores para contraseñas personalizadas separadas por comas:
|
||||
|
||||
code1,code2,code3
|
||||
|
||||
Después de agregar o modificar la variable de entorno, por favor**Redesplegar**proyecto para poner en vigor los cambios.
|
||||
|
||||
## Variable de entorno
|
||||
|
||||
> La mayoría de los elementos de configuración de este proyecto se establecen a través de variables de entorno, tutorial:[Cómo modificar las variables de entorno de Vercel](./docs/vercel-cn.md)。
|
||||
|
||||
### `OPENAI_API_KEY` (Requerido)
|
||||
|
||||
OpanAI key, la clave API que solicita en la página de su cuenta openai.
|
||||
|
||||
### `CODE` (Opcional)
|
||||
|
||||
Las contraseñas de acceso, opcionalmente, se pueden separar por comas.
|
||||
|
||||
**advertir**: Si no completa este campo, cualquiera puede usar directamente su sitio web implementado, lo que puede hacer que su token se consuma rápidamente, se recomienda completar esta opción.
|
||||
|
||||
### `BASE_URL` (Opcional)
|
||||
|
||||
> Predeterminado: `https://api.openai.com`
|
||||
|
||||
> Ejemplos: `http://your-openai-proxy.com`
|
||||
|
||||
URL del proxy de interfaz OpenAI, complete esta opción si configuró manualmente el proxy de interfaz openAI.
|
||||
|
||||
> Si encuentra problemas con el certificado SSL, establezca el `BASE_URL` El protocolo se establece en http.
|
||||
|
||||
### `OPENAI_ORG_ID` (Opcional)
|
||||
|
||||
Especifica el identificador de la organización en OpenAI.
|
||||
|
||||
### `HIDE_USER_API_KEY` (Opcional)
|
||||
|
||||
Si no desea que los usuarios rellenen la clave de API ellos mismos, establezca esta variable de entorno en 1.
|
||||
|
||||
### `DISABLE_GPT4` (Opcional)
|
||||
|
||||
Si no desea que los usuarios utilicen GPT-4, establezca esta variable de entorno en 1.
|
||||
|
||||
## explotación
|
||||
|
||||
> No se recomienda encarecidamente desarrollar o implementar localmente, debido a algunas razones técnicas, es difícil configurar el agente API de OpenAI localmente, a menos que pueda asegurarse de que puede conectarse directamente al servidor OpenAI.
|
||||
|
||||
Haga clic en el botón de abajo para iniciar el desarrollo secundario:
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||
Antes de empezar a escribir código, debe crear uno nuevo en la raíz del proyecto `.env.local` archivo, lleno de variables de entorno:
|
||||
|
||||
OPENAI_API_KEY=<your api key here>
|
||||
|
||||
### Desarrollo local
|
||||
|
||||
1. Instale nodejs 18 e hilo, pregunte a ChatGPT para obtener más detalles;
|
||||
2. ejecutar `yarn install && yarn dev` Enlatar. ⚠️ Nota: Este comando es solo para desarrollo local, no para implementación.
|
||||
3. Úselo si desea implementar localmente `yarn install && yarn start` comando, puede cooperar con pm2 a daemon para evitar ser asesinado, pregunte a ChatGPT para obtener más detalles.
|
||||
|
||||
## desplegar
|
||||
|
||||
### Implementación de contenedores (recomendado)
|
||||
|
||||
> La versión de Docker debe ser 20 o posterior, de lo contrario se indicará que no se puede encontrar la imagen.
|
||||
|
||||
> ⚠️ Nota: Las versiones de Docker están de 1 a 2 días por detrás de la última versión la mayor parte del tiempo, por lo que es normal que sigas diciendo "La actualización existe" después de la implementación.
|
||||
|
||||
```shell
|
||||
docker pull yidadaa/chatgpt-next-web
|
||||
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="页面访问密码" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
También puede especificar proxy:
|
||||
|
||||
```shell
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY="sk-xxxx" \
|
||||
-e CODE="页面访问密码" \
|
||||
--net=host \
|
||||
-e PROXY_URL="http://127.0.0.1:7890" \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
Si necesita especificar otras variables de entorno, agréguelas usted mismo en el comando anterior `-e 环境变量=环境变量值` para especificar.
|
||||
|
||||
### Implementación local
|
||||
|
||||
Ejecute el siguiente comando en la consola:
|
||||
|
||||
```shell
|
||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||
```
|
||||
|
||||
⚠️ Nota: Si tiene problemas durante la instalación, utilice la implementación de Docker.
|
||||
|
||||
## Reconocimiento
|
||||
|
||||
### donante
|
||||
|
||||
> Ver versión en inglés.
|
||||
|
||||
### Colaboradores
|
||||
|
||||
[Ver la lista de colaboradores del proyecto](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||
|
||||
## Licencia de código abierto
|
||||
|
||||
> Contra 996, empezando por mí.
|
||||
|
||||
[Licencia Anti 996](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
@@ -1,16 +0,0 @@
|
||||
import md5 from "spark-md5";
|
||||
|
||||
export function getAccessCodes(): Set<string> {
|
||||
const code = process.env.CODE;
|
||||
|
||||
try {
|
||||
const codes = (code?.split(",") ?? [])
|
||||
.filter((v) => !!v)
|
||||
.map((v) => md5.hash(v.trim()));
|
||||
return new Set(codes);
|
||||
} catch (e) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export const ACCESS_CODES = getAccessCodes();
|
66
app/api/auth.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSideConfig } from "../config/server";
|
||||
import md5 from "spark-md5";
|
||||
import { ACCESS_CODE_PREFIX } from "../constant";
|
||||
import { OPENAI_URL } from "./common";
|
||||
|
||||
function getIP(req: NextRequest) {
|
||||
let ip = req.ip ?? req.headers.get("x-real-ip");
|
||||
const forwardedFor = req.headers.get("x-forwarded-for");
|
||||
|
||||
if (!ip && forwardedFor) {
|
||||
ip = forwardedFor.split(",").at(0) ?? "";
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
function parseApiKey(bearToken: string) {
|
||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||
const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
||||
|
||||
return {
|
||||
accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
|
||||
apiKey: isOpenAiKey ? token : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function auth(req: NextRequest) {
|
||||
const authToken = req.headers.get("Authorization") ?? "";
|
||||
|
||||
// check if it is openai api key or user token
|
||||
const { accessCode, apiKey: token } = parseApiKey(authToken);
|
||||
|
||||
const hashedCode = md5.hash(accessCode ?? "").trim();
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]);
|
||||
console.log("[Auth] got access code:", accessCode);
|
||||
console.log("[Auth] hashed access code:", hashedCode);
|
||||
console.log("[User IP] ", getIP(req));
|
||||
console.log("[Time] ", new Date().toLocaleString());
|
||||
|
||||
if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) {
|
||||
return {
|
||||
error: true,
|
||||
msg: !accessCode ? "empty access code" : "wrong access code",
|
||||
};
|
||||
}
|
||||
|
||||
// if user does not provide an api key, inject system api key
|
||||
if (!token) {
|
||||
const apiKey = serverConfig.apiKey;
|
||||
if (apiKey) {
|
||||
console.log("[Auth] use system api key");
|
||||
req.headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
} else {
|
||||
console.log("[Auth] admin did not provide an api key");
|
||||
}
|
||||
} else {
|
||||
console.log("[Auth] use user api key");
|
||||
}
|
||||
|
||||
return {
|
||||
error: false,
|
||||
};
|
||||
}
|
@@ -1,66 +0,0 @@
|
||||
import { createParser } from "eventsource-parser";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
async function createStream(req: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let apiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
const userApiKey = req.headers.get("token");
|
||||
if (userApiKey) {
|
||||
apiKey = userApiKey;
|
||||
console.log("[Stream] using user api key");
|
||||
}
|
||||
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function onParse(event: any) {
|
||||
if (event.type === "event") {
|
||||
const data = event.data;
|
||||
// https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
|
||||
if (data === "[DONE]") {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const text = json.choices[0].delta.content;
|
||||
const queue = encoder.encode(text);
|
||||
controller.enqueue(queue);
|
||||
} catch (e) {
|
||||
controller.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parser = createParser(onParse);
|
||||
for await (const chunk of res.body as any) {
|
||||
parser.feed(decoder.decode(chunk));
|
||||
}
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const stream = await createStream(req);
|
||||
return new Response(stream);
|
||||
} catch (error) {
|
||||
console.error("[Chat Stream]", error);
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
runtime: "edge",
|
||||
};
|
1
app/api/chat/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
config.ts
|
@@ -1,29 +0,0 @@
|
||||
import { OpenAIApi, Configuration } from "openai";
|
||||
import { ChatRequest } from "./typing";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
let apiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
const userApiKey = req.headers.get("token");
|
||||
if (userApiKey) {
|
||||
apiKey = userApiKey;
|
||||
}
|
||||
|
||||
const openai = new OpenAIApi(
|
||||
new Configuration({
|
||||
apiKey,
|
||||
})
|
||||
);
|
||||
|
||||
const requestBody = (await req.json()) as ChatRequest;
|
||||
const completion = await openai!.createChatCompletion({
|
||||
...requestBody,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(completion.data));
|
||||
} catch (e) {
|
||||
console.error("[Chat] ", e);
|
||||
return new Response(JSON.stringify(e));
|
||||
}
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
import type {
|
||||
CreateChatCompletionRequest,
|
||||
CreateChatCompletionResponse,
|
||||
} from "openai";
|
||||
|
||||
export type ChatRequest = CreateChatCompletionRequest;
|
||||
export type ChatReponse = CreateChatCompletionResponse;
|
91
app/api/common.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const OPENAI_URL = "api.openai.com";
|
||||
const DEFAULT_PROTOCOL = "https";
|
||||
const PROTOCOL = process.env.PROTOCOL ?? DEFAULT_PROTOCOL;
|
||||
const BASE_URL = process.env.BASE_URL ?? OPENAI_URL;
|
||||
const DISABLE_GPT4 = !!process.env.DISABLE_GPT4;
|
||||
|
||||
export async function requestOpenai(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
const authValue = req.headers.get("Authorization") ?? "";
|
||||
const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||
"/api/openai/",
|
||||
"",
|
||||
);
|
||||
|
||||
let baseUrl = BASE_URL;
|
||||
|
||||
if (!baseUrl.startsWith("http")) {
|
||||
baseUrl = `${PROTOCOL}://${baseUrl}`;
|
||||
}
|
||||
|
||||
console.log("[Proxy] ", openaiPath);
|
||||
console.log("[Base Url]", baseUrl);
|
||||
|
||||
if (process.env.OPENAI_ORG_ID) {
|
||||
console.log("[Org ID]", process.env.OPENAI_ORG_ID);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
const fetchUrl = `${baseUrl}/${openaiPath}`;
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authValue,
|
||||
...(process.env.OPENAI_ORG_ID && {
|
||||
"OpenAI-Organization": process.env.OPENAI_ORG_ID,
|
||||
}),
|
||||
},
|
||||
cache: "no-store",
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
// #1815 try to refuse gpt4 request
|
||||
if (DISABLE_GPT4 && req.body) {
|
||||
try {
|
||||
const clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody);
|
||||
|
||||
if ((jsonBody?.model ?? "").includes("gpt-4")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: "you are not allowed to use gpt-4 model",
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[OpenAI] gpt4 filter", e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// to prevent browser prompt for credentials
|
||||
const newHeaders = new Headers(res.headers);
|
||||
newHeaders.delete("www-authenticate");
|
||||
|
||||
// to disbale ngnix buffering
|
||||
newHeaders.set("X-Accel-Buffering", "no");
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
26
app/api/config/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerSideConfig } from "../../config/server";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
// Danger! Don not write any secret value here!
|
||||
// 警告!不要在这里写入任何敏感信息!
|
||||
const DANGER_CONFIG = {
|
||||
needCode: serverConfig.needCode,
|
||||
hideUserApiKey: serverConfig.hideUserApiKey,
|
||||
enableGPT4: serverConfig.enableGPT4,
|
||||
};
|
||||
|
||||
declare global {
|
||||
type DangerConfig = typeof DANGER_CONFIG;
|
||||
}
|
||||
|
||||
async function handle() {
|
||||
return NextResponse.json(DANGER_CONFIG);
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
|
||||
export const runtime = "edge";
|
48
app/api/openai/[...path]/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { OpenaiPath } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "../../auth";
|
||||
import { requestOpenai } from "../../common";
|
||||
|
||||
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
||||
|
||||
async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[OpenAI Route] params ", params);
|
||||
|
||||
const subpath = params.path.join("/");
|
||||
|
||||
if (!ALLOWD_PATH.has(subpath)) {
|
||||
console.log("[OpenAI Route] forbidden path ", subpath);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
msg: "you are not allowed to request " + subpath,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const authResult = auth(req);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestOpenai(req);
|
||||
} catch (e) {
|
||||
console.error("[OpenAI] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
|
||||
export const runtime = "edge";
|
140
app/client/api.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ACCESS_CODE_PREFIX } from "../constant";
|
||||
import { ChatMessage, ModelType, useAccessStore } from "../store";
|
||||
import { ChatGPTApi } from "./platforms/openai";
|
||||
|
||||
export const ROLES = ["system", "user", "assistant"] as const;
|
||||
export type MessageRole = (typeof ROLES)[number];
|
||||
|
||||
export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
|
||||
export type ChatModel = ModelType;
|
||||
|
||||
export interface RequestMessage {
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
model: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stream?: boolean;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
messages: RequestMessage[];
|
||||
config: LLMConfig;
|
||||
|
||||
onUpdate?: (message: string, chunk: string) => void;
|
||||
onFinish: (message: string) => void;
|
||||
onError?: (err: Error) => void;
|
||||
onController?: (controller: AbortController) => void;
|
||||
}
|
||||
|
||||
export interface LLMUsage {
|
||||
used: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export abstract class LLMApi {
|
||||
abstract chat(options: ChatOptions): Promise<void>;
|
||||
abstract usage(): Promise<LLMUsage>;
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "azure" | "claude" | "palm";
|
||||
|
||||
interface Model {
|
||||
name: string;
|
||||
provider: ProviderName;
|
||||
ctxlen: number;
|
||||
}
|
||||
|
||||
interface ChatProvider {
|
||||
name: ProviderName;
|
||||
apiConfig: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
summaryModel: Model;
|
||||
};
|
||||
models: Model[];
|
||||
|
||||
chat: () => void;
|
||||
usage: () => void;
|
||||
}
|
||||
|
||||
export class ClientApi {
|
||||
public llm: LLMApi;
|
||||
|
||||
constructor() {
|
||||
this.llm = new ChatGPTApi();
|
||||
}
|
||||
|
||||
config() {}
|
||||
|
||||
prompts() {}
|
||||
|
||||
masks() {}
|
||||
|
||||
async share(messages: ChatMessage[], avatarUrl: string | null = null) {
|
||||
const msgs = messages
|
||||
.map((m) => ({
|
||||
from: m.role === "user" ? "human" : "gpt",
|
||||
value: m.content,
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
from: "human",
|
||||
value:
|
||||
"Share from [ChatGPT Next Web]: https://github.com/Yidadaa/ChatGPT-Next-Web",
|
||||
},
|
||||
]);
|
||||
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
||||
// Please do not modify this message
|
||||
|
||||
console.log("[Share]", msgs);
|
||||
const res = await fetch("/sharegpt", {
|
||||
body: JSON.stringify({
|
||||
avatarUrl,
|
||||
items: msgs,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const resJson = await res.json();
|
||||
console.log("[Share]", resJson);
|
||||
if (resJson.id) {
|
||||
return `https://shareg.pt/${resJson.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ClientApi();
|
||||
|
||||
export function getHeaders() {
|
||||
const accessStore = useAccessStore.getState();
|
||||
let headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
};
|
||||
|
||||
const makeBearer = (token: string) => `Bearer ${token.trim()}`;
|
||||
const validString = (x: string) => x && x.length > 0;
|
||||
|
||||
// use user's api key first
|
||||
if (validString(accessStore.token)) {
|
||||
headers.Authorization = makeBearer(accessStore.token);
|
||||
} else if (
|
||||
accessStore.enabledAccessControl() &&
|
||||
validString(accessStore.accessCode)
|
||||
) {
|
||||
headers.Authorization = makeBearer(
|
||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||
);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
37
app/client/controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// To store message streaming controller
|
||||
export const ChatControllerPool = {
|
||||
controllers: {} as Record<string, AbortController>,
|
||||
|
||||
addController(
|
||||
sessionIndex: number,
|
||||
messageId: number,
|
||||
controller: AbortController,
|
||||
) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
this.controllers[key] = controller;
|
||||
return key;
|
||||
},
|
||||
|
||||
stop(sessionIndex: number, messageId: number) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
const controller = this.controllers[key];
|
||||
controller?.abort();
|
||||
},
|
||||
|
||||
stopAll() {
|
||||
Object.values(this.controllers).forEach((v) => v.abort());
|
||||
},
|
||||
|
||||
hasPending() {
|
||||
return Object.values(this.controllers).length > 0;
|
||||
},
|
||||
|
||||
remove(sessionIndex: number, messageId: number) {
|
||||
const key = this.key(sessionIndex, messageId);
|
||||
delete this.controllers[key];
|
||||
},
|
||||
|
||||
key(sessionIndex: number, messageIndex: number) {
|
||||
return `${sessionIndex},${messageIndex}`;
|
||||
},
|
||||
};
|
227
app/client/platforms/openai.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { OpenaiPath, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import { ChatOptions, getHeaders, LLMApi, LLMUsage } from "../api";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
|
||||
export class ChatGPTApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
let openaiUrl = useAccessStore.getState().openaiUrl;
|
||||
if (openaiUrl.endsWith("/")) {
|
||||
openaiUrl = openaiUrl.slice(0, openaiUrl.length - 1);
|
||||
}
|
||||
return [openaiUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: v.content,
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const requestPayload = {
|
||||
messages,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
};
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(OpenaiPath.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let finished = false;
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
options.onFinish(responseText);
|
||||
finished = true;
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[OpenAI] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
let extraInfo = await res.clone().text();
|
||||
try {
|
||||
const resJson = await res.clone().json();
|
||||
extraInfo = prettyObject(resJson);
|
||||
} catch {}
|
||||
|
||||
if (res.status === 401) {
|
||||
responseTexts.push(Locale.Error.Unauthorized);
|
||||
}
|
||||
|
||||
if (extraInfo) {
|
||||
responseTexts.push(extraInfo);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const delta = json.choices[0].delta.content;
|
||||
if (delta) {
|
||||
responseText += delta;
|
||||
options.onUpdate?.(responseText, delta);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat reqeust", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
async usage() {
|
||||
const formatDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
|
||||
.getDate()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startDate = formatDate(startOfMonth);
|
||||
const endDate = formatDate(new Date(Date.now() + ONE_DAY));
|
||||
|
||||
const [used, subs] = await Promise.all([
|
||||
fetch(
|
||||
this.path(
|
||||
`${OpenaiPath.UsagePath}?start_date=${startDate}&end_date=${endDate}`,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeaders(),
|
||||
},
|
||||
),
|
||||
fetch(this.path(OpenaiPath.SubsPath), {
|
||||
method: "GET",
|
||||
headers: getHeaders(),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (used.status === 401) {
|
||||
throw new Error(Locale.Error.Unauthorized);
|
||||
}
|
||||
|
||||
if (!used.ok || !subs.ok) {
|
||||
throw new Error("Failed to query usage from openai");
|
||||
}
|
||||
|
||||
const response = (await used.json()) as {
|
||||
total_usage?: number;
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const total = (await subs.json()) as {
|
||||
hard_limit_usd?: number;
|
||||
};
|
||||
|
||||
if (response.error && response.error.type) {
|
||||
throw Error(response.error.message);
|
||||
}
|
||||
|
||||
if (response.total_usage) {
|
||||
response.total_usage = Math.round(response.total_usage) / 100;
|
||||
}
|
||||
|
||||
if (total.hard_limit_usd) {
|
||||
total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
|
||||
}
|
||||
|
||||
return {
|
||||
used: response.total_usage,
|
||||
total: total.hard_limit_usd,
|
||||
} as LLMUsage;
|
||||
}
|
||||
}
|
||||
export { OpenaiPath };
|
28
app/command.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
type Command = (param: string) => void;
|
||||
interface Commands {
|
||||
fill?: Command;
|
||||
submit?: Command;
|
||||
mask?: Command;
|
||||
}
|
||||
|
||||
export function useCommand(commands: Commands = {}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
if (commands === undefined) return;
|
||||
|
||||
let shouldUpdate = false;
|
||||
searchParams.forEach((param, name) => {
|
||||
const commandName = name as keyof Commands;
|
||||
if (typeof commands[commandName] === "function") {
|
||||
commands[commandName]!(param);
|
||||
searchParams.delete(name);
|
||||
shouldUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldUpdate) {
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}
|
36
app/components/auth.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
.auth-logo {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.auth-tips {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
margin: 3vh 0;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
46
app/components/auth.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import styles from "./auth.module.scss";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
|
||||
export function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const access = useAccessStore();
|
||||
|
||||
const goHome = () => navigate(Path.Home);
|
||||
|
||||
return (
|
||||
<div className={styles["auth-page"]}>
|
||||
<div className={`no-dark ${styles["auth-logo"]}`}>
|
||||
<BotIcon />
|
||||
</div>
|
||||
|
||||
<div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
|
||||
<div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
|
||||
|
||||
<input
|
||||
className={styles["auth-input"]}
|
||||
type="password"
|
||||
placeholder={Locale.Auth.Input}
|
||||
value={access.accessCode}
|
||||
onChange={(e) => {
|
||||
access.updateCode(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles["auth-actions"]}>
|
||||
<IconButton
|
||||
text={Locale.Auth.Confirm}
|
||||
type="primary"
|
||||
onClick={goHome}
|
||||
/>
|
||||
<IconButton text={Locale.Auth.Later} onClick={goHome} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -6,11 +6,31 @@
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
|
||||
box-shadow: var(--card-shadow);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
color: var(--black);
|
||||
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
|
||||
path {
|
||||
fill: white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.border {
|
||||
@@ -18,7 +38,6 @@
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
filter: brightness(0.9);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -36,25 +55,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark-button {
|
||||
div:not(:global(.no-dark))>.icon-button-icon {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) {
|
||||
@include dark-button;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@include dark-button;
|
||||
}
|
||||
|
||||
.icon-button-text {
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@@ -4,25 +4,42 @@ import styles from "./button.module.scss";
|
||||
|
||||
export function IconButton(props: {
|
||||
onClick?: () => void;
|
||||
icon: JSX.Element;
|
||||
icon?: JSX.Element;
|
||||
type?: "primary" | "danger";
|
||||
text?: string;
|
||||
bordered?: boolean;
|
||||
shadow?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={
|
||||
styles["icon-button"] +
|
||||
` ${props.bordered && styles.border} ${props.className ?? ""}`
|
||||
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
|
||||
props.className ?? ""
|
||||
} clickable ${styles[props.type ?? ""]}`
|
||||
}
|
||||
onClick={props.onClick}
|
||||
title={props.title}
|
||||
disabled={props.disabled}
|
||||
role="button"
|
||||
>
|
||||
<div className={styles["icon-button-icon"]}>{props.icon}</div>
|
||||
{props.icon && (
|
||||
<div
|
||||
className={
|
||||
styles["icon-button-icon"] +
|
||||
` ${props.type === "primary" && "no-dark"}`
|
||||
}
|
||||
>
|
||||
{props.icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.text && (
|
||||
<div className={styles["icon-button-text"]}>{props.text}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
159
app/components/chat-list.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import { useChatStore } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { MaskAvatar } from "./mask";
|
||||
import { Mask } from "../store/mask";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
export function ChatItem(props: {
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
title: string;
|
||||
count: number;
|
||||
time: string;
|
||||
selected: boolean;
|
||||
id: number;
|
||||
index: number;
|
||||
narrow?: boolean;
|
||||
mask: Mask;
|
||||
}) {
|
||||
const draggableRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (props.selected && draggableRef.current) {
|
||||
draggableRef.current?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [props.selected]);
|
||||
return (
|
||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={`${styles["chat-item"]} ${
|
||||
props.selected && styles["chat-item-selected"]
|
||||
}`}
|
||||
onClick={props.onClick}
|
||||
ref={(ele) => {
|
||||
draggableRef.current = ele;
|
||||
provided.innerRef(ele);
|
||||
}}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
|
||||
props.count,
|
||||
)}`}
|
||||
>
|
||||
{props.narrow ? (
|
||||
<div className={styles["chat-item-narrow"]}>
|
||||
<div className={styles["chat-item-avatar"] + " no-dark"}>
|
||||
<MaskAvatar mask={props.mask} />
|
||||
</div>
|
||||
<div className={styles["chat-item-narrow-count"]}>
|
||||
{props.count}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles["chat-item-title"]}>{props.title}</div>
|
||||
<div className={styles["chat-item-info"]}>
|
||||
<div className={styles["chat-item-count"]}>
|
||||
{Locale.ChatItem.ChatItemCount(props.count)}
|
||||
</div>
|
||||
<div className={styles["chat-item-date"]}>
|
||||
{new Date(props.time).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={styles["chat-item-delete"]}
|
||||
onClickCapture={props.onDelete}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatList(props: { narrow?: boolean }) {
|
||||
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
|
||||
(state) => [
|
||||
state.sessions,
|
||||
state.currentSessionIndex,
|
||||
state.selectSession,
|
||||
state.moveSession,
|
||||
],
|
||||
);
|
||||
const chatStore = useChatStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onDragEnd: OnDragEndResponder = (result) => {
|
||||
const { destination, source } = result;
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveSession(source.index, destination.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="chat-list">
|
||||
{(provided) => (
|
||||
<div
|
||||
className={styles["chat-list"]}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{sessions.map((item, i) => (
|
||||
<ChatItem
|
||||
title={item.topic}
|
||||
time={new Date(item.lastUpdate).toLocaleString()}
|
||||
count={item.messages.length}
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
index={i}
|
||||
selected={i === selectedIndex}
|
||||
onClick={() => {
|
||||
navigate(Path.Chat);
|
||||
selectSession(i);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (!props.narrow || confirm(Locale.Home.DeleteChat)) {
|
||||
chatStore.deleteSession(i);
|
||||
}
|
||||
}}
|
||||
narrow={props.narrow}
|
||||
mask={item.mask}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
204
app/components/chat.module.scss
Normal file
@@ -0,0 +1,204 @@
|
||||
@import "../styles/animation.scss";
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.chat-input-action {
|
||||
display: inline-flex;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
border: var(--border-in-light);
|
||||
padding: 4px 10px;
|
||||
animation: slide-in ease 0.3s;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: all ease 0.3s;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
width: var(--icon-width);
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
padding-left: 5px;
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
transition: all ease 0.3s;
|
||||
transition-delay: 0.1s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
width: var(--full-width);
|
||||
|
||||
.text {
|
||||
opacity: 1;
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.text,
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-toast {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: calc(100% - 40px);
|
||||
|
||||
.prompt-toast-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 10px 20px;
|
||||
border-radius: 100px;
|
||||
|
||||
animation: slide-in-from-top ease 0.3s;
|
||||
|
||||
.prompt-toast-content {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.section-title-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt {
|
||||
.context-prompt-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.context-role {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-delete-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.memory-prompt {
|
||||
margin: 20px 0;
|
||||
|
||||
.memory-prompt-content {
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-context {
|
||||
margin: 20px 0 0 0;
|
||||
padding: 4px 0;
|
||||
|
||||
border-top: var(--border-in-light);
|
||||
border-bottom: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow) inset;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: var(--black);
|
||||
transition: all ease 0.3s;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
$linear: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
mask-image: $linear;
|
||||
|
||||
@mixin show {
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
transition: all ease 0.3s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@mixin hide {
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
transition: all ease 0.1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-tips {
|
||||
@include show;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-revert-btn {
|
||||
color: var(--primary);
|
||||
@include hide;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--primary);
|
||||
|
||||
.clear-context-tips {
|
||||
@include hide;
|
||||
}
|
||||
|
||||
.clear-context-revert-btn {
|
||||
@include show;
|
||||
}
|
||||
}
|
||||
}
|
935
app/components/chat.tsx
Normal file
@@ -0,0 +1,935 @@
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
|
||||
import SendWhiteIcon from "../icons/send-white.svg";
|
||||
import BrainIcon from "../icons/brain.svg";
|
||||
import RenameIcon from "../icons/rename.svg";
|
||||
import ExportIcon from "../icons/share.svg";
|
||||
import ReturnIcon from "../icons/return.svg";
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import PromptIcon from "../icons/prompt.svg";
|
||||
import MaskIcon from "../icons/mask.svg";
|
||||
import MaxIcon from "../icons/max.svg";
|
||||
import MinIcon from "../icons/min.svg";
|
||||
import ResetIcon from "../icons/reload.svg";
|
||||
import BreakIcon from "../icons/break.svg";
|
||||
import SettingsIcon from "../icons/chat-settings.svg";
|
||||
|
||||
import LightIcon from "../icons/light.svg";
|
||||
import DarkIcon from "../icons/dark.svg";
|
||||
import AutoIcon from "../icons/auto.svg";
|
||||
import BottomIcon from "../icons/bottom.svg";
|
||||
import StopIcon from "../icons/pause.svg";
|
||||
|
||||
import {
|
||||
ChatMessage,
|
||||
SubmitKey,
|
||||
useChatStore,
|
||||
BOT_HELLO,
|
||||
createMessage,
|
||||
useAccessStore,
|
||||
Theme,
|
||||
useAppConfig,
|
||||
DEFAULT_TOPIC,
|
||||
} from "../store";
|
||||
|
||||
import {
|
||||
copyToClipboard,
|
||||
downloadAs,
|
||||
selectOrCopy,
|
||||
autoGrowTextArea,
|
||||
useMobileScreen,
|
||||
} from "../utils";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { ChatControllerPool } from "../client/controller";
|
||||
import { Prompt, usePromptStore } from "../store/prompt";
|
||||
import Locale from "../locales";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import styles from "./home.module.scss";
|
||||
import chatStyle from "./chat.module.scss";
|
||||
|
||||
import { ListItem, Modal } from "./ui-lib";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
|
||||
import { Avatar } from "./emoji";
|
||||
import { MaskAvatar, MaskConfig } from "./mask";
|
||||
import { useMaskStore } from "../store/mask";
|
||||
import { useCommand } from "../command";
|
||||
import { prettyObject } from "../utils/format";
|
||||
import { ExportMessageModal } from "./exporter";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
});
|
||||
|
||||
export function SessionConfigModel(props: { onClose: () => void }) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const maskStore = useMaskStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Context.Edit}
|
||||
onClose={() => props.onClose()}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="reset"
|
||||
icon={<ResetIcon />}
|
||||
bordered
|
||||
text={Locale.Chat.Config.Reset}
|
||||
onClick={() => {
|
||||
if (confirm(Locale.Memory.ResetConfirm)) {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.memoryPrompt = ""),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
icon={<CopyIcon />}
|
||||
bordered
|
||||
text={Locale.Chat.Config.SaveAs}
|
||||
onClick={() => {
|
||||
navigate(Path.Masks);
|
||||
setTimeout(() => {
|
||||
maskStore.create(session.mask);
|
||||
}, 500);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<MaskConfig
|
||||
mask={session.mask}
|
||||
updateMask={(updater) => {
|
||||
const mask = { ...session.mask };
|
||||
updater(mask);
|
||||
chatStore.updateCurrentSession((session) => (session.mask = mask));
|
||||
}}
|
||||
shouldSyncFromGlobal
|
||||
extraListItems={
|
||||
session.mask.modelConfig.sendMemory ? (
|
||||
<ListItem
|
||||
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
||||
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
||||
></ListItem>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
></MaskConfig>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptToast(props: {
|
||||
showToast?: boolean;
|
||||
showModal?: boolean;
|
||||
setShowModal: (_: boolean) => void;
|
||||
}) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const context = session.mask.context;
|
||||
|
||||
return (
|
||||
<div className={chatStyle["prompt-toast"]} key="prompt-toast">
|
||||
{props.showToast && (
|
||||
<div
|
||||
className={chatStyle["prompt-toast-inner"] + " clickable"}
|
||||
role="button"
|
||||
onClick={() => props.setShowModal(true)}
|
||||
>
|
||||
<BrainIcon />
|
||||
<span className={chatStyle["prompt-toast-content"]}>
|
||||
{Locale.Context.Toast(context.length)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{props.showModal && (
|
||||
<SessionConfigModel onClose={() => props.setShowModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSubmitHandler() {
|
||||
const config = useAppConfig();
|
||||
const submitKey = config.submitKey;
|
||||
|
||||
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== "Enter") return false;
|
||||
if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
|
||||
return (
|
||||
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
|
||||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
|
||||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
|
||||
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
|
||||
(config.submitKey === SubmitKey.Enter &&
|
||||
!e.altKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
submitKey,
|
||||
shouldSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
export function PromptHints(props: {
|
||||
prompts: Prompt[];
|
||||
onPromptSelect: (prompt: Prompt) => void;
|
||||
}) {
|
||||
const noPrompts = props.prompts.length === 0;
|
||||
const [selectIndex, setSelectIndex] = useState(0);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectIndex(0);
|
||||
}, [props.prompts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (noPrompts) return;
|
||||
if (e.metaKey || e.altKey || e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
// arrow up / down to select prompt
|
||||
const changeIndex = (delta: number) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.max(
|
||||
0,
|
||||
Math.min(props.prompts.length - 1, selectIndex + delta),
|
||||
);
|
||||
setSelectIndex(nextIndex);
|
||||
selectedRef.current?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
changeIndex(1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
changeIndex(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
const selectedPrompt = props.prompts.at(selectIndex);
|
||||
if (selectedPrompt) {
|
||||
props.onPromptSelect(selectedPrompt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.prompts.length, selectIndex]);
|
||||
|
||||
if (noPrompts) return null;
|
||||
return (
|
||||
<div className={styles["prompt-hints"]}>
|
||||
{props.prompts.map((prompt, i) => (
|
||||
<div
|
||||
ref={i === selectIndex ? selectedRef : null}
|
||||
className={
|
||||
styles["prompt-hint"] +
|
||||
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
|
||||
}
|
||||
key={prompt.title + i.toString()}
|
||||
onClick={() => props.onPromptSelect(prompt)}
|
||||
onMouseEnter={() => setSelectIndex(i)}
|
||||
>
|
||||
<div className={styles["hint-title"]}>{prompt.title}</div>
|
||||
<div className={styles["hint-content"]}>{prompt.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearContextDivider() {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={chatStyle["clear-context"]}
|
||||
onClick={() =>
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.clearContextIndex = undefined),
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={chatStyle["clear-context-tips"]}>
|
||||
{Locale.Context.Clear}
|
||||
</div>
|
||||
<div className={chatStyle["clear-context-revert-btn"]}>
|
||||
{Locale.Context.Revert}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatAction(props: {
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState({
|
||||
full: 20,
|
||||
icon: 20,
|
||||
});
|
||||
|
||||
function updateWidth() {
|
||||
if (!iconRef.current || !textRef.current) return;
|
||||
const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
|
||||
const textWidth = getWidth(textRef.current);
|
||||
const iconWidth = getWidth(iconRef.current);
|
||||
setWidth({
|
||||
full: textWidth + iconWidth,
|
||||
icon: iconWidth,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateWidth();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${chatStyle["chat-input-action"]} clickable`}
|
||||
onClick={() => {
|
||||
props.onClick();
|
||||
setTimeout(updateWidth, 1);
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--icon-width": `${width.icon}px`,
|
||||
"--full-width": `${width.full}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div ref={iconRef} className={chatStyle["icon"]}>
|
||||
{props.icon}
|
||||
</div>
|
||||
<div className={chatStyle["text"]} ref={textRef}>
|
||||
{props.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useScrollToBottom() {
|
||||
// for auto-scroll
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollToBottom = () => {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
|
||||
}
|
||||
};
|
||||
|
||||
// auto scroll
|
||||
useLayoutEffect(() => {
|
||||
autoScroll && scrollToBottom();
|
||||
});
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
scrollToBottom,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatActions(props: {
|
||||
showPromptModal: () => void;
|
||||
scrollToBottom: () => void;
|
||||
showPromptHints: () => void;
|
||||
hitBottom: boolean;
|
||||
}) {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// switch themes
|
||||
const theme = config.theme;
|
||||
function nextTheme() {
|
||||
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
|
||||
const themeIndex = themes.indexOf(theme);
|
||||
const nextIndex = (themeIndex + 1) % themes.length;
|
||||
const nextTheme = themes[nextIndex];
|
||||
config.update((config) => (config.theme = nextTheme));
|
||||
}
|
||||
|
||||
// stop all responses
|
||||
const couldStop = ChatControllerPool.hasPending();
|
||||
const stopAll = () => ChatControllerPool.stopAll();
|
||||
|
||||
return (
|
||||
<div className={chatStyle["chat-input-actions"]}>
|
||||
{couldStop && (
|
||||
<ChatAction
|
||||
onClick={stopAll}
|
||||
text={Locale.Chat.InputActions.Stop}
|
||||
icon={<StopIcon />}
|
||||
/>
|
||||
)}
|
||||
{!props.hitBottom && (
|
||||
<ChatAction
|
||||
onClick={props.scrollToBottom}
|
||||
text={Locale.Chat.InputActions.ToBottom}
|
||||
icon={<BottomIcon />}
|
||||
/>
|
||||
)}
|
||||
{props.hitBottom && (
|
||||
<ChatAction
|
||||
onClick={props.showPromptModal}
|
||||
text={Locale.Chat.InputActions.Settings}
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatAction
|
||||
onClick={nextTheme}
|
||||
text={Locale.Chat.InputActions.Theme[theme]}
|
||||
icon={
|
||||
<>
|
||||
{theme === Theme.Auto ? (
|
||||
<AutoIcon />
|
||||
) : theme === Theme.Light ? (
|
||||
<LightIcon />
|
||||
) : theme === Theme.Dark ? (
|
||||
<DarkIcon />
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
onClick={props.showPromptHints}
|
||||
text={Locale.Chat.InputActions.Prompt}
|
||||
icon={<PromptIcon />}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
onClick={() => {
|
||||
navigate(Path.Masks);
|
||||
}}
|
||||
text={Locale.Chat.InputActions.Masks}
|
||||
icon={<MaskIcon />}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.InputActions.Clear}
|
||||
icon={<BreakIcon />}
|
||||
onClick={() => {
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
if (session.clearContextIndex === session.messages.length) {
|
||||
session.clearContextIndex = undefined;
|
||||
} else {
|
||||
session.clearContextIndex = session.messages.length;
|
||||
session.memoryPrompt = ""; // will clear memory
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
type RenderMessage = ChatMessage & { preview?: boolean };
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const [session, sessionIndex] = useChatStore((state) => [
|
||||
state.currentSession(),
|
||||
state.currentSessionIndex,
|
||||
]);
|
||||
const config = useAppConfig();
|
||||
const fontSize = config.fontSize;
|
||||
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { submitKey, shouldSubmit } = useSubmitHandler();
|
||||
const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
|
||||
const [hitBottom, setHitBottom] = useState(true);
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onChatBodyScroll = (e: HTMLElement) => {
|
||||
const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 100;
|
||||
setHitBottom(isTouchBottom);
|
||||
};
|
||||
|
||||
// prompt hints
|
||||
const promptStore = usePromptStore();
|
||||
const [promptHints, setPromptHints] = useState<Prompt[]>([]);
|
||||
const onSearch = useDebouncedCallback(
|
||||
(text: string) => {
|
||||
setPromptHints(promptStore.search(text));
|
||||
},
|
||||
100,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
const onPromptSelect = (prompt: Prompt) => {
|
||||
setPromptHints([]);
|
||||
inputRef.current?.focus();
|
||||
setTimeout(() => setUserInput(prompt.content), 60);
|
||||
};
|
||||
|
||||
// auto grow input
|
||||
const [inputRows, setInputRows] = useState(2);
|
||||
const measure = useDebouncedCallback(
|
||||
() => {
|
||||
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
|
||||
const inputRows = Math.min(
|
||||
20,
|
||||
Math.max(2 + Number(!isMobileScreen), rows),
|
||||
);
|
||||
setInputRows(inputRows);
|
||||
},
|
||||
100,
|
||||
{
|
||||
leading: true,
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(measure, [userInput]);
|
||||
|
||||
// only search prompts when user input is short
|
||||
const SEARCH_TEXT_LIMIT = 30;
|
||||
const onInput = (text: string) => {
|
||||
setUserInput(text);
|
||||
const n = text.trim().length;
|
||||
|
||||
// clear search results
|
||||
if (n === 0) {
|
||||
setPromptHints([]);
|
||||
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
||||
// check if need to trigger auto completion
|
||||
if (text.startsWith("/")) {
|
||||
let searchText = text.slice(1);
|
||||
onSearch(searchText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const doSubmit = (userInput: string) => {
|
||||
if (userInput.trim() === "") return;
|
||||
setIsLoading(true);
|
||||
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
|
||||
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
||||
setUserInput("");
|
||||
setPromptHints([]);
|
||||
if (!isMobileScreen) inputRef.current?.focus();
|
||||
setAutoScroll(true);
|
||||
};
|
||||
|
||||
// stop response
|
||||
const onUserStop = (messageId: number) => {
|
||||
ChatControllerPool.stop(sessionIndex, messageId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
|
||||
session.messages.forEach((m) => {
|
||||
// check if should stop all stale messages
|
||||
if (m.isError || new Date(m.date).getTime() < stopTiming) {
|
||||
if (m.streaming) {
|
||||
m.streaming = false;
|
||||
}
|
||||
|
||||
if (m.content.length === 0) {
|
||||
m.isError = true;
|
||||
m.content = prettyObject({
|
||||
error: true,
|
||||
message: "empty response",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// auto sync mask config from global config
|
||||
if (session.mask.syncGlobalConfig) {
|
||||
console.log("[Mask] syncing from global, name = ", session.mask.name);
|
||||
session.mask.modelConfig = { ...config.modelConfig };
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// check if should send message
|
||||
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// if ArrowUp and no userInput, fill with last input
|
||||
if (
|
||||
e.key === "ArrowUp" &&
|
||||
userInput.length <= 0 &&
|
||||
!(e.metaKey || e.altKey || e.ctrlKey)
|
||||
) {
|
||||
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (shouldSubmit(e) && promptHints.length === 0) {
|
||||
doSubmit(userInput);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const onRightClick = (e: any, message: ChatMessage) => {
|
||||
// copy to clipboard
|
||||
if (selectOrCopy(e.currentTarget, message.content)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const findLastUserIndex = (messageId: number) => {
|
||||
// find last user input message and resend
|
||||
let lastUserMessageIndex: number | null = null;
|
||||
for (let i = 0; i < session.messages.length; i += 1) {
|
||||
const message = session.messages[i];
|
||||
if (message.id === messageId) {
|
||||
break;
|
||||
}
|
||||
if (message.role === "user") {
|
||||
lastUserMessageIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return lastUserMessageIndex;
|
||||
};
|
||||
|
||||
const deleteMessage = (userIndex: number) => {
|
||||
chatStore.updateCurrentSession((session) =>
|
||||
session.messages.splice(userIndex, 2),
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (botMessageId: number) => {
|
||||
const userIndex = findLastUserIndex(botMessageId);
|
||||
if (userIndex === null) return;
|
||||
deleteMessage(userIndex);
|
||||
};
|
||||
|
||||
const onResend = (botMessageId: number) => {
|
||||
// find last user input message and resend
|
||||
const userIndex = findLastUserIndex(botMessageId);
|
||||
if (userIndex === null) return;
|
||||
|
||||
setIsLoading(true);
|
||||
const content = session.messages[userIndex].content;
|
||||
deleteMessage(userIndex);
|
||||
chatStore.onUserInput(content).then(() => setIsLoading(false));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const context: RenderMessage[] = session.mask.hideContext
|
||||
? []
|
||||
: session.mask.context.slice();
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
if (
|
||||
context.length === 0 &&
|
||||
session.messages.at(0)?.content !== BOT_HELLO.content
|
||||
) {
|
||||
const copiedHello = Object.assign({}, BOT_HELLO);
|
||||
if (!accessStore.isAuthorized()) {
|
||||
copiedHello.content = Locale.Error.Unauthorized;
|
||||
}
|
||||
context.push(copiedHello);
|
||||
}
|
||||
|
||||
// clear context index = context length + index in messages
|
||||
const clearContextIndex =
|
||||
(session.clearContextIndex ?? -1) >= 0
|
||||
? session.clearContextIndex! + context.length
|
||||
: -1;
|
||||
|
||||
// preview messages
|
||||
const messages = context
|
||||
.concat(session.messages as RenderMessage[])
|
||||
.concat(
|
||||
isLoading
|
||||
? [
|
||||
{
|
||||
...createMessage({
|
||||
role: "assistant",
|
||||
content: "……",
|
||||
}),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
.concat(
|
||||
userInput.length > 0 && config.sendPreviewBubble
|
||||
? [
|
||||
{
|
||||
...createMessage({
|
||||
role: "user",
|
||||
content: userInput,
|
||||
}),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const renameSession = () => {
|
||||
const newTopic = prompt(Locale.Chat.Rename, session.topic);
|
||||
if (newTopic && newTopic !== session.topic) {
|
||||
chatStore.updateCurrentSession((session) => (session.topic = newTopic!));
|
||||
}
|
||||
};
|
||||
|
||||
const location = useLocation();
|
||||
const isChat = location.pathname === Path.Chat;
|
||||
const autoFocus = !isMobileScreen || isChat; // only focus in chat page
|
||||
|
||||
useCommand({
|
||||
fill: setUserInput,
|
||||
submit: (text) => {
|
||||
doSubmit(text);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.chat} key={session.id}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div
|
||||
className={`window-header-main-title " ${styles["chat-body-title"]}`}
|
||||
onClickCapture={renameSession}
|
||||
>
|
||||
{!session.topic ? DEFAULT_TOPIC : session.topic}
|
||||
</div>
|
||||
<div className="window-header-sub-title">
|
||||
{Locale.Chat.SubTitle(session.messages.length)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="window-actions">
|
||||
<div className={"window-action-button" + " " + styles.mobile}>
|
||||
<IconButton
|
||||
icon={<ReturnIcon />}
|
||||
bordered
|
||||
title={Locale.Chat.Actions.ChatList}
|
||||
onClick={() => navigate(Path.Home)}
|
||||
/>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<RenameIcon />}
|
||||
bordered
|
||||
onClick={renameSession}
|
||||
/>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<ExportIcon />}
|
||||
bordered
|
||||
title={Locale.Chat.Actions.Export}
|
||||
onClick={() => {
|
||||
setShowExport(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isMobileScreen && (
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
||||
bordered
|
||||
onClick={() => {
|
||||
config.update(
|
||||
(config) => (config.tightBorder = !config.tightBorder),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PromptToast
|
||||
showToast={!hitBottom}
|
||||
showModal={showPromptModal}
|
||||
setShowModal={setShowPromptModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles["chat-body"]}
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
onMouseDown={() => inputRef.current?.blur()}
|
||||
onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
|
||||
onTouchStart={() => {
|
||||
inputRef.current?.blur();
|
||||
setAutoScroll(false);
|
||||
}}
|
||||
>
|
||||
{messages.map((message, i) => {
|
||||
const isUser = message.role === "user";
|
||||
const showActions =
|
||||
!isUser &&
|
||||
i > 0 &&
|
||||
!(message.preview || message.content.length === 0);
|
||||
const showTyping = message.preview || message.streaming;
|
||||
|
||||
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
isUser ? styles["chat-message-user"] : styles["chat-message"]
|
||||
}
|
||||
>
|
||||
<div className={styles["chat-message-container"]}>
|
||||
<div className={styles["chat-message-avatar"]}>
|
||||
{message.role === "user" ? (
|
||||
<Avatar avatar={config.avatar} />
|
||||
) : (
|
||||
<MaskAvatar mask={session.mask} />
|
||||
)}
|
||||
</div>
|
||||
{showTyping && (
|
||||
<div className={styles["chat-message-status"]}>
|
||||
{Locale.Chat.Typing}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles["chat-message-item"]}>
|
||||
{showActions && (
|
||||
<div className={styles["chat-message-top-actions"]}>
|
||||
{message.streaming ? (
|
||||
<div
|
||||
className={styles["chat-message-top-action"]}
|
||||
onClick={() => onUserStop(message.id ?? i)}
|
||||
>
|
||||
{Locale.Chat.Actions.Stop}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={styles["chat-message-top-action"]}
|
||||
onClick={() => onDelete(message.id ?? i)}
|
||||
>
|
||||
{Locale.Chat.Actions.Delete}
|
||||
</div>
|
||||
<div
|
||||
className={styles["chat-message-top-action"]}
|
||||
onClick={() => onResend(message.id ?? i)}
|
||||
>
|
||||
{Locale.Chat.Actions.Retry}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={styles["chat-message-top-action"]}
|
||||
onClick={() => copyToClipboard(message.content)}
|
||||
>
|
||||
{Locale.Chat.Actions.Copy}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Markdown
|
||||
content={message.content}
|
||||
loading={
|
||||
(message.preview || message.content.length === 0) &&
|
||||
!isUser
|
||||
}
|
||||
onContextMenu={(e) => onRightClick(e, message)}
|
||||
onDoubleClickCapture={() => {
|
||||
if (!isMobileScreen) return;
|
||||
setUserInput(message.content);
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
parentRef={scrollRef}
|
||||
defaultShow={i >= messages.length - 10}
|
||||
/>
|
||||
</div>
|
||||
{!isUser && !message.preview && (
|
||||
<div className={styles["chat-message-actions"]}>
|
||||
<div className={styles["chat-message-action-date"]}>
|
||||
{message.date.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowClearContextDivider && <ClearContextDivider />}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles["chat-input-panel"]}>
|
||||
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
||||
|
||||
<ChatActions
|
||||
showPromptModal={() => setShowPromptModal(true)}
|
||||
scrollToBottom={scrollToBottom}
|
||||
hitBottom={hitBottom}
|
||||
showPromptHints={() => {
|
||||
// Click again to close
|
||||
if (promptHints.length > 0) {
|
||||
setPromptHints([]);
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.focus();
|
||||
setUserInput("/");
|
||||
onSearch("");
|
||||
}}
|
||||
/>
|
||||
<div className={styles["chat-input-panel-inner"]}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles["chat-input"]}
|
||||
placeholder={Locale.Chat.Input(submitKey)}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
value={userInput}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={() => setAutoScroll(true)}
|
||||
onBlur={() => setAutoScroll(false)}
|
||||
rows={inputRows}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<SendWhiteIcon />}
|
||||
text={Locale.Chat.Send}
|
||||
className={styles["chat-input-send"]}
|
||||
type="primary"
|
||||
onClick={() => doSubmit(userInput)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExport && (
|
||||
<ExportMessageModal onClose={() => setShowExport(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
59
app/components/emoji.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import EmojiPicker, {
|
||||
Emoji,
|
||||
EmojiStyle,
|
||||
Theme as EmojiTheme,
|
||||
} from "emoji-picker-react";
|
||||
|
||||
import { ModelType } from "../store";
|
||||
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
import BlackBotIcon from "../icons/black-bot.svg";
|
||||
|
||||
export function getEmojiUrl(unified: string, style: EmojiStyle) {
|
||||
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
|
||||
}
|
||||
|
||||
export function AvatarPicker(props: {
|
||||
onEmojiClick: (emojiId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<EmojiPicker
|
||||
lazyLoadEmojis
|
||||
theme={EmojiTheme.AUTO}
|
||||
getEmojiUrl={getEmojiUrl}
|
||||
onEmojiClick={(e) => {
|
||||
props.onEmojiClick(e.unified);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
||||
if (props.model) {
|
||||
return (
|
||||
<div className="no-dark">
|
||||
{props.model?.startsWith("gpt-4") ? (
|
||||
<BlackBotIcon className="user-avatar" />
|
||||
) : (
|
||||
<BotIcon className="user-avatar" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-avatar">
|
||||
{props.avatar && <EmojiAvatar avatar={props.avatar} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmojiAvatar(props: { avatar: string; size?: number }) {
|
||||
return (
|
||||
<Emoji
|
||||
unified={props.avatar}
|
||||
size={props.size ?? 18}
|
||||
getEmojiUrl={getEmojiUrl}
|
||||
/>
|
||||
);
|
||||
}
|
73
app/components/error.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "./button";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import ResetIcon from "../icons/reload.svg";
|
||||
import { ISSUE_URL } from "../constant";
|
||||
import Locale from "../locales";
|
||||
import { downloadAs } from "../utils";
|
||||
|
||||
interface IErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
info: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, info: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
// Update state with error details
|
||||
this.setState({ hasError: true, error, info });
|
||||
}
|
||||
|
||||
clearAndSaveData() {
|
||||
try {
|
||||
downloadAs(
|
||||
JSON.stringify(localStorage),
|
||||
"chatgpt-next-web-snapshot.json",
|
||||
);
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Render error message
|
||||
return (
|
||||
<div className="error">
|
||||
<h2>Oops, something went wrong!</h2>
|
||||
<pre>
|
||||
<code>{this.state.error?.toString()}</code>
|
||||
<code>{this.state.info?.componentStack}</code>
|
||||
</pre>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<a href={ISSUE_URL} className="report">
|
||||
<IconButton
|
||||
text="Report This Error"
|
||||
icon={<GithubIcon />}
|
||||
bordered
|
||||
/>
|
||||
</a>
|
||||
<IconButton
|
||||
icon={<ResetIcon />}
|
||||
text="Clear All Data"
|
||||
onClick={() =>
|
||||
confirm(Locale.Settings.Actions.ConfirmClearAll) &&
|
||||
this.clearAndSaveData()
|
||||
}
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// if no error occurred, render children
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
217
app/components/exporter.module.scss
Normal file
@@ -0,0 +1,217 @@
|
||||
.message-exporter {
|
||||
&-body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.export-content {
|
||||
white-space: break-spaces;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.steps {
|
||||
background-color: var(--gray);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
position: relative;
|
||||
box-shadow: var(--card-shadow) inset;
|
||||
|
||||
.steps-progress {
|
||||
$padding: 5px;
|
||||
height: calc(100% - 2 * $padding);
|
||||
width: calc(100% - 2 * $padding);
|
||||
position: absolute;
|
||||
top: $padding;
|
||||
left: $padding;
|
||||
|
||||
&-inner {
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--card-shadow);
|
||||
border: var(--border-in-light);
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background-color: var(--white);
|
||||
transition: all ease 0.3s;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.steps-inner {
|
||||
display: flex;
|
||||
transform: scale(1);
|
||||
|
||||
.step {
|
||||
flex-grow: 1;
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
color: var(--black);
|
||||
opacity: 0.5;
|
||||
transition: all ease 0.3s;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
$radius: 8px;
|
||||
|
||||
&-finished {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&-current {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.step-index {
|
||||
background-color: var(--gray);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
padding: 0px 5px;
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
button {
|
||||
flex-grow: 1;
|
||||
&:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-previewer {
|
||||
.preview-body {
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--card-shadow) inset;
|
||||
background-color: var(--gray);
|
||||
|
||||
.chat-info {
|
||||
background-color: var(--second);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.icons {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 50%;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 20px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon-space {
|
||||
font-size: 12px;
|
||||
margin: 0 10px;
|
||||
font-weight: bolder;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-info-item {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
padding: 2px 15px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--card-shadow);
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
|
||||
.avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.body {
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
max-width: calc(100% - 104px);
|
||||
box-shadow: var(--card-shadow);
|
||||
border: var(--border-in-light);
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&-assistant {
|
||||
.body {
|
||||
background-color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
&-user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.avatar {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: var(--second);
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.default-theme {
|
||||
}
|
||||
}
|
528
app/components/exporter.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
import styles from "./exporter.module.scss";
|
||||
import { List, ListItem, Modal, Select, showToast } from "./ui-lib";
|
||||
import { IconButton } from "./button";
|
||||
import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
|
||||
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import ChatGptIcon from "../icons/chatgpt.png";
|
||||
import ShareIcon from "../icons/share.svg";
|
||||
import BotIcon from "../icons/bot.png";
|
||||
|
||||
import DownloadIcon from "../icons/download.svg";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MessageSelector, useMessageSelector } from "./message-selector";
|
||||
import { Avatar } from "./emoji";
|
||||
import dynamic from "next/dynamic";
|
||||
import NextImage from "next/image";
|
||||
|
||||
import { toBlob, toJpeg, toPng } from "html-to-image";
|
||||
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
||||
import { api } from "../client/api";
|
||||
import { prettyObject } from "../utils/format";
|
||||
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
});
|
||||
|
||||
export function ExportMessageModal(props: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal title={Locale.Export.Title} onClose={props.onClose}>
|
||||
<div style={{ minHeight: "40vh" }}>
|
||||
<MessageExporter />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSteps(
|
||||
steps: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>,
|
||||
) {
|
||||
const stepCount = steps.length;
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const nextStep = () =>
|
||||
setCurrentStepIndex((currentStepIndex + 1) % stepCount);
|
||||
const prevStep = () =>
|
||||
setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
|
||||
|
||||
return {
|
||||
currentStepIndex,
|
||||
setCurrentStepIndex,
|
||||
nextStep,
|
||||
prevStep,
|
||||
currentStep: steps[currentStepIndex],
|
||||
};
|
||||
}
|
||||
|
||||
function Steps<
|
||||
T extends {
|
||||
name: string;
|
||||
value: string;
|
||||
}[],
|
||||
>(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
|
||||
const steps = props.steps;
|
||||
const stepCount = steps.length;
|
||||
|
||||
return (
|
||||
<div className={styles["steps"]}>
|
||||
<div className={styles["steps-progress"]}>
|
||||
<div
|
||||
className={styles["steps-progress-inner"]}
|
||||
style={{
|
||||
width: `${((props.index + 1) / stepCount) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className={styles["steps-inner"]}>
|
||||
{steps.map((step, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles["step"]} ${
|
||||
styles[i <= props.index ? "step-finished" : ""]
|
||||
} ${i === props.index && styles["step-current"]} clickable`}
|
||||
onClick={() => {
|
||||
props.onStepChange?.(i);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<span className={styles["step-index"]}>{i + 1}</span>
|
||||
<span className={styles["step-name"]}>{step.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageExporter() {
|
||||
const steps = [
|
||||
{
|
||||
name: Locale.Export.Steps.Select,
|
||||
value: "select",
|
||||
},
|
||||
{
|
||||
name: Locale.Export.Steps.Preview,
|
||||
value: "preview",
|
||||
},
|
||||
];
|
||||
const { currentStep, setCurrentStepIndex, currentStepIndex } =
|
||||
useSteps(steps);
|
||||
const formats = ["text", "image"] as const;
|
||||
type ExportFormat = (typeof formats)[number];
|
||||
|
||||
const [exportConfig, setExportConfig] = useState({
|
||||
format: "image" as ExportFormat,
|
||||
includeContext: true,
|
||||
});
|
||||
|
||||
function updateExportConfig(updater: (config: typeof exportConfig) => void) {
|
||||
const config = { ...exportConfig };
|
||||
updater(config);
|
||||
setExportConfig(config);
|
||||
}
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const { selection, updateSelection } = useMessageSelector();
|
||||
const selectedMessages = useMemo(() => {
|
||||
const ret: ChatMessage[] = [];
|
||||
if (exportConfig.includeContext) {
|
||||
ret.push(...session.mask.context);
|
||||
}
|
||||
ret.push(...session.messages.filter((m, i) => selection.has(m.id ?? i)));
|
||||
return ret;
|
||||
}, [
|
||||
exportConfig.includeContext,
|
||||
session.messages,
|
||||
session.mask.context,
|
||||
selection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Steps
|
||||
steps={steps}
|
||||
index={currentStepIndex}
|
||||
onStepChange={setCurrentStepIndex}
|
||||
/>
|
||||
<div
|
||||
className={styles["message-exporter-body"]}
|
||||
style={currentStep.value !== "select" ? { display: "none" } : {}}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Export.Format.Title}
|
||||
subTitle={Locale.Export.Format.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={exportConfig.format}
|
||||
onChange={(e) =>
|
||||
updateExportConfig(
|
||||
(config) =>
|
||||
(config.format = e.currentTarget.value as ExportFormat),
|
||||
)
|
||||
}
|
||||
>
|
||||
{formats.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Export.IncludeContext.Title}
|
||||
subTitle={Locale.Export.IncludeContext.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportConfig.includeContext}
|
||||
onChange={(e) => {
|
||||
updateExportConfig(
|
||||
(config) => (config.includeContext = e.currentTarget.checked),
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
</List>
|
||||
<MessageSelector
|
||||
selection={selection}
|
||||
updateSelection={updateSelection}
|
||||
defaultSelectAll
|
||||
/>
|
||||
</div>
|
||||
{currentStep.value === "preview" && (
|
||||
<div className={styles["message-exporter-body"]}>
|
||||
{exportConfig.format === "text" ? (
|
||||
<MarkdownPreviewer
|
||||
messages={selectedMessages}
|
||||
topic={session.topic}
|
||||
/>
|
||||
) : (
|
||||
<ImagePreviewer messages={selectedMessages} topic={session.topic} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderExport(props: {
|
||||
messages: ChatMessage[];
|
||||
onRender: (messages: ChatMessage[]) => void;
|
||||
}) {
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!domRef.current) return;
|
||||
const dom = domRef.current;
|
||||
const messages = Array.from(
|
||||
dom.getElementsByClassName(EXPORT_MESSAGE_CLASS_NAME),
|
||||
);
|
||||
|
||||
if (messages.length !== props.messages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMsgs = messages.map((v) => {
|
||||
const [_, role] = v.id.split(":");
|
||||
return {
|
||||
role: role as any,
|
||||
content: v.innerHTML,
|
||||
date: "",
|
||||
};
|
||||
});
|
||||
|
||||
props.onRender(renderMsgs);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={domRef}>
|
||||
{props.messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
id={`${m.role}:${i}`}
|
||||
className={EXPORT_MESSAGE_CLASS_NAME}
|
||||
>
|
||||
<Markdown content={m.content} defaultShow />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewActions(props: {
|
||||
download: () => void;
|
||||
copy: () => void;
|
||||
showCopy?: boolean;
|
||||
messages?: ChatMessage[];
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shouldExport, setShouldExport] = useState(false);
|
||||
|
||||
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
||||
setShouldExport(false);
|
||||
|
||||
api
|
||||
.share(msgs)
|
||||
.then((res) => {
|
||||
if (!res) return;
|
||||
copyToClipboard(res);
|
||||
setTimeout(() => {
|
||||
window.open(res, "_blank");
|
||||
}, 800);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[Share]", e);
|
||||
showToast(prettyObject(e));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const share = async () => {
|
||||
if (props.messages?.length) {
|
||||
setLoading(true);
|
||||
setShouldExport(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles["preview-actions"]}>
|
||||
{props.showCopy && (
|
||||
<IconButton
|
||||
text={Locale.Export.Copy}
|
||||
bordered
|
||||
shadow
|
||||
icon={<CopyIcon />}
|
||||
onClick={props.copy}
|
||||
></IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
text={Locale.Export.Download}
|
||||
bordered
|
||||
shadow
|
||||
icon={<DownloadIcon />}
|
||||
onClick={props.download}
|
||||
></IconButton>
|
||||
<IconButton
|
||||
text={Locale.Export.Share}
|
||||
bordered
|
||||
shadow
|
||||
icon={loading ? <LoadingIcon /> : <ShareIcon />}
|
||||
onClick={share}
|
||||
></IconButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: "200vw",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{shouldExport && (
|
||||
<RenderExport
|
||||
messages={props.messages ?? []}
|
||||
onRender={onRenderMsgs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportAvatar(props: { avatar: string }) {
|
||||
if (props.avatar === DEFAULT_MASK_AVATAR) {
|
||||
return (
|
||||
<NextImage
|
||||
src={BotIcon.src}
|
||||
width={30}
|
||||
height={30}
|
||||
alt="bot"
|
||||
className="user-avatar"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Avatar avatar={props.avatar}></Avatar>;
|
||||
}
|
||||
|
||||
export function ImagePreviewer(props: {
|
||||
messages: ChatMessage[];
|
||||
topic: string;
|
||||
}) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const mask = session.mask;
|
||||
const config = useAppConfig();
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const copy = () => {
|
||||
const dom = previewRef.current;
|
||||
if (!dom) return;
|
||||
toBlob(dom).then((blob) => {
|
||||
if (!blob) return;
|
||||
try {
|
||||
navigator.clipboard
|
||||
.write([
|
||||
new ClipboardItem({
|
||||
"image/png": blob,
|
||||
}),
|
||||
])
|
||||
.then(() => {
|
||||
showToast(Locale.Copy.Success);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Copy Image] ", e);
|
||||
showToast(Locale.Copy.Failed);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isMobile = useMobileScreen();
|
||||
|
||||
const download = () => {
|
||||
const dom = previewRef.current;
|
||||
if (!dom) return;
|
||||
toPng(dom)
|
||||
.then((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
if (isMobile) {
|
||||
const image = new Image();
|
||||
image.src = blob;
|
||||
const win = window.open("");
|
||||
win?.document.write(image.outerHTML);
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.download = `${props.topic}.png`;
|
||||
link.href = blob;
|
||||
link.click();
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log("[Export Image] ", e));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["image-previewer"]}>
|
||||
<PreviewActions
|
||||
copy={copy}
|
||||
download={download}
|
||||
showCopy={!isMobile}
|
||||
messages={props.messages}
|
||||
/>
|
||||
<div
|
||||
className={`${styles["preview-body"]} ${styles["default-theme"]}`}
|
||||
ref={previewRef}
|
||||
>
|
||||
<div className={styles["chat-info"]}>
|
||||
<div className={styles["logo"] + " no-dark"}>
|
||||
<NextImage
|
||||
src={ChatGptIcon.src}
|
||||
alt="logo"
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles["main-title"]}>ChatGPT Next Web</div>
|
||||
<div className={styles["sub-title"]}>
|
||||
github.com/Yidadaa/ChatGPT-Next-Web
|
||||
</div>
|
||||
<div className={styles["icons"]}>
|
||||
<ExportAvatar avatar={config.avatar} />
|
||||
<span className={styles["icon-space"]}>&</span>
|
||||
<ExportAvatar avatar={mask.avatar} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Model: {mask.modelConfig.model}
|
||||
</div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Messages: {props.messages.length}
|
||||
</div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Topic: {session.topic}
|
||||
</div>
|
||||
<div className={styles["chat-info-item"]}>
|
||||
Time:{" "}
|
||||
{new Date(
|
||||
props.messages.at(-1)?.date ?? Date.now(),
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{props.messages.map((m, i) => {
|
||||
return (
|
||||
<div
|
||||
className={styles["message"] + " " + styles["message-" + m.role]}
|
||||
key={i}
|
||||
>
|
||||
<div className={styles["avatar"]}>
|
||||
<ExportAvatar
|
||||
avatar={m.role === "user" ? config.avatar : mask.avatar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles["body"]}>
|
||||
<Markdown
|
||||
content={m.content}
|
||||
fontSize={config.fontSize}
|
||||
defaultShow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownPreviewer(props: {
|
||||
messages: ChatMessage[];
|
||||
topic: string;
|
||||
}) {
|
||||
const mdText =
|
||||
`# ${props.topic}\n\n` +
|
||||
props.messages
|
||||
.map((m) => {
|
||||
return m.role === "user"
|
||||
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
|
||||
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(mdText);
|
||||
};
|
||||
const download = () => {
|
||||
downloadAs(mdText, `${props.topic}.md`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreviewActions
|
||||
copy={copy}
|
||||
download={download}
|
||||
messages={props.messages}
|
||||
/>
|
||||
<div className="markdown-body">
|
||||
<pre className={styles["export-content"]}>{mdText}</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,5 +1,3 @@
|
||||
@import "./window.scss";
|
||||
|
||||
@mixin container {
|
||||
background-color: var(--white);
|
||||
border: var(--border-in-light);
|
||||
@@ -9,7 +7,7 @@
|
||||
background-color: var(--white);
|
||||
min-width: 600px;
|
||||
min-height: 480px;
|
||||
max-width: 900px;
|
||||
max-width: 1200px;
|
||||
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
@@ -26,15 +24,16 @@
|
||||
@media only screen and (min-width: 600px) {
|
||||
.tight-container {
|
||||
--window-width: 100vw;
|
||||
--window-height: 100vh;
|
||||
--window-height: var(--full-height);
|
||||
--window-content-width: calc(100% - var(--sidebar-width));
|
||||
|
||||
@include container();
|
||||
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
max-height: var(--full-height);
|
||||
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +46,40 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
transition: width ease 0.05s;
|
||||
|
||||
.sidebar-header-bar {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.sidebar-bar-button {
|
||||
flex-grow: 1;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-drag {
|
||||
$width: 10px;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: $width;
|
||||
background-color: var(--black);
|
||||
cursor: ew-resize;
|
||||
opacity: 0;
|
||||
transition: all ease 0.3s;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.window-content {
|
||||
@@ -73,8 +106,8 @@
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
left: -100%;
|
||||
z-index: 999;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
height: var(--full-height);
|
||||
transition: all ease 0.3s;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -103,19 +136,19 @@
|
||||
.sidebar-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
|
||||
.sidebar-sub-title {
|
||||
font-size: 12px;
|
||||
font-weight: 400px;
|
||||
font-weight: 400;
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
@@ -124,24 +157,11 @@
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: all 0.3s ease;
|
||||
transition: background-color 0.3s ease;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border: 2px solid transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-item:hover {
|
||||
@@ -156,23 +176,25 @@
|
||||
font-size: 14px;
|
||||
font-weight: bolder;
|
||||
display: block;
|
||||
width: 200px;
|
||||
width: calc(100% - 15px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
|
||||
.chat-item-delete {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: -20px;
|
||||
right: 0;
|
||||
transition: all ease 0.3s;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-item:hover > .chat-item-delete {
|
||||
opacity: 0.5;
|
||||
right: 10px;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.chat-item:hover > .chat-item-delete:hover {
|
||||
@@ -185,12 +207,105 @@
|
||||
color: rgb(166, 166, 166);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
|
||||
.chat-item-count {
|
||||
}
|
||||
|
||||
.chat-item-count,
|
||||
.chat-item-date {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.narrow-sidebar {
|
||||
.sidebar-title,
|
||||
.sidebar-sub-title {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-logo {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-header-bar {
|
||||
flex-direction: column;
|
||||
|
||||
.sidebar-bar-button {
|
||||
&:not(:last-child) {
|
||||
margin-right: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
padding: 0;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all ease 0.3s;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
.chat-item-narrow {
|
||||
transform: scale(0.7) translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-item-narrow {
|
||||
line-height: 0;
|
||||
font-weight: lighter;
|
||||
color: var(--black);
|
||||
transform: translateX(0);
|
||||
transition: all ease 0.3s;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.chat-item-avatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
opacity: 0.2;
|
||||
position: absolute;
|
||||
transform: scale(4);
|
||||
}
|
||||
|
||||
.chat-item-narrow-count {
|
||||
font-size: 24px;
|
||||
font-weight: bolder;
|
||||
text-align: center;
|
||||
color: var(--primary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-item-delete {
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
.chat-item:hover > .chat-item-delete {
|
||||
opacity: 0.5;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.sidebar-tail {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
|
||||
.sidebar-actions {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
|
||||
.sidebar-action {
|
||||
margin-right: 0;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-tail {
|
||||
@@ -218,12 +333,26 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
margin-bottom: 100px;
|
||||
padding-bottom: 40px;
|
||||
position: relative;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.chat-body-title {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&:last-child {
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user {
|
||||
@@ -236,12 +365,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
&:hover {
|
||||
.chat-message-top-actions {
|
||||
opacity: 1;
|
||||
right: 10px;
|
||||
transform: translateX(10px);
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
@@ -262,17 +390,6 @@
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.user-avtar {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.chat-message-item {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
@@ -288,10 +405,12 @@
|
||||
}
|
||||
|
||||
.chat-message-top-actions {
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: -26px;
|
||||
left: 30px;
|
||||
transition: all ease 0.3s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -302,6 +421,7 @@
|
||||
.chat-message-top-action {
|
||||
opacity: 0.5;
|
||||
color: var(--black);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
@@ -332,12 +452,69 @@
|
||||
}
|
||||
|
||||
.chat-input-panel {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
padding-top: 10px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-top: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
@mixin single-line {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.prompt-hints {
|
||||
min-height: 20px;
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
|
||||
background-color: var(--white);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
.prompt-hint {
|
||||
color: var(--black);
|
||||
padding: 6px 10px;
|
||||
animation: slide-in ease 0.3s;
|
||||
cursor: pointer;
|
||||
transition: all ease 0.3s;
|
||||
border: transparent 1px solid;
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hint-title {
|
||||
font-size: 12px;
|
||||
font-weight: bolder;
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
.hint-content {
|
||||
font-size: 12px;
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
|
||||
&-selected,
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-panel-inner {
|
||||
@@ -354,17 +531,11 @@
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
font-family: inherit;
|
||||
padding: 10px 14px;
|
||||
padding: 10px 90px 10px 14px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.chat-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
@@ -375,11 +546,17 @@
|
||||
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
bottom: 10px;
|
||||
bottom: 32px;
|
||||
}
|
||||
|
||||
.export-content {
|
||||
white-space: break-spaces;
|
||||
@media only screen and (max-width: 600px) {
|
||||
.chat-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
|
@@ -1,400 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
require("../polyfill");
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
import SettingsIcon from "../icons/settings.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import ChatGptIcon from "../icons/chatgpt.svg";
|
||||
import SendWhiteIcon from "../icons/send-white.svg";
|
||||
import BrainIcon from "../icons/brain.svg";
|
||||
import ExportIcon from "../icons/export.svg";
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import MenuIcon from "../icons/menu.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
import DownloadIcon from "../icons/download.svg";
|
||||
|
||||
import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
|
||||
import { showModal, showToast } from "./ui-lib";
|
||||
import { copyToClipboard, downloadAs, isIOS, selectOrCopy } from "../utils";
|
||||
import Locale from "../locales";
|
||||
import { getCSSVar, useMobileScreen } from "../utils";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { REPO_URL } from "../constant";
|
||||
import { ControllerPool } from "../requests";
|
||||
import { Path, SlotID } from "../constant";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import {
|
||||
HashRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { SideBar } from "./sidebar";
|
||||
import { useAppConfig } from "../store/config";
|
||||
import { AuthPage } from "./auth";
|
||||
import { getClientConfig } from "../config/client";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
<div className={styles["loading-content"]}>
|
||||
<div className={styles["loading-content"] + " no-dark"}>
|
||||
{!props.noLogo && <BotIcon />}
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
});
|
||||
|
||||
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
|
||||
loading: () => <LoadingIcon />,
|
||||
const Chat = dynamic(async () => (await import("./chat")).Chat, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
export function Avatar(props: { role: Message["role"] }) {
|
||||
const config = useChatStore((state) => state.config);
|
||||
const NewChat = dynamic(async () => (await import("./new-chat")).NewChat, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
if (props.role === "assistant") {
|
||||
return <BotIcon className={styles["user-avtar"]} />;
|
||||
}
|
||||
const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles["user-avtar"]}>
|
||||
<Emoji unified={config.avatar} size={18} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatItem(props: {
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
title: string;
|
||||
count: number;
|
||||
time: string;
|
||||
selected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles["chat-item"]} ${
|
||||
props.selected && styles["chat-item-selected"]
|
||||
}`}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<div className={styles["chat-item-title"]}>{props.title}</div>
|
||||
<div className={styles["chat-item-info"]}>
|
||||
<div className={styles["chat-item-count"]}>
|
||||
{Locale.ChatItem.ChatItemCount(props.count)}
|
||||
</div>
|
||||
<div className={styles["chat-item-date"]}>{props.time}</div>
|
||||
</div>
|
||||
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatList() {
|
||||
const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
|
||||
(state) => [
|
||||
state.sessions,
|
||||
state.currentSessionIndex,
|
||||
state.selectSession,
|
||||
state.removeSession,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles["chat-list"]}>
|
||||
{sessions.map((item, i) => (
|
||||
<ChatItem
|
||||
title={item.topic}
|
||||
time={item.lastUpdate}
|
||||
count={item.messages.length}
|
||||
key={i}
|
||||
selected={i === selectedIndex}
|
||||
onClick={() => selectSession(i)}
|
||||
onDelete={() => removeSession(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSubmitHandler() {
|
||||
const config = useChatStore((state) => state.config);
|
||||
const submitKey = config.submitKey;
|
||||
|
||||
const shouldSubmit = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") return false;
|
||||
|
||||
return (
|
||||
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
|
||||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
|
||||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
|
||||
(config.submitKey === SubmitKey.Enter &&
|
||||
!e.altKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.shiftKey)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
submitKey,
|
||||
shouldSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
export function Chat(props: { showSideBar?: () => void }) {
|
||||
type RenderMessage = Message & { preview?: boolean };
|
||||
|
||||
const [session, sessionIndex] = useChatStore((state) => [
|
||||
state.currentSession(),
|
||||
state.currentSessionIndex,
|
||||
]);
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { submitKey, shouldSubmit } = useSubmitHandler();
|
||||
|
||||
const onUserInput = useChatStore((state) => state.onUserInput);
|
||||
|
||||
// submit user input
|
||||
const onUserSubmit = () => {
|
||||
if (userInput.length <= 0) return;
|
||||
setIsLoading(true);
|
||||
onUserInput(userInput).then(() => setIsLoading(false));
|
||||
setUserInput("");
|
||||
};
|
||||
|
||||
// stop response
|
||||
const onUserStop = (messageIndex: number) => {
|
||||
console.log(ControllerPool, sessionIndex, messageIndex);
|
||||
ControllerPool.stop(sessionIndex, messageIndex);
|
||||
};
|
||||
|
||||
// check if should send message
|
||||
const onInputKeyDown = (e: KeyboardEvent) => {
|
||||
if (shouldSubmit(e)) {
|
||||
onUserSubmit();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const onRightClick = (e: any, message: Message) => {
|
||||
// auto fill user input
|
||||
if (message.role === "user") {
|
||||
setUserInput(message.content);
|
||||
}
|
||||
|
||||
// copy to clipboard
|
||||
if (selectOrCopy(e.currentTarget, message.content)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onResend = (botIndex: number) => {
|
||||
// find last user input message and resend
|
||||
for (let i = botIndex; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
setIsLoading(true);
|
||||
onUserInput(messages[i].content).then(() => setIsLoading(false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// for auto-scroll
|
||||
const latestMessageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// wont scroll while hovering messages
|
||||
const [autoScroll, setAutoScroll] = useState(false);
|
||||
|
||||
// preview messages
|
||||
const messages = (session.messages as RenderMessage[])
|
||||
.concat(
|
||||
isLoading
|
||||
? [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "……",
|
||||
date: new Date().toLocaleString(),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
)
|
||||
.concat(
|
||||
userInput.length > 0
|
||||
? [
|
||||
{
|
||||
role: "user",
|
||||
content: userInput,
|
||||
date: new Date().toLocaleString(),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
// auto scroll
|
||||
useLayoutEffect(() => {
|
||||
setTimeout(() => {
|
||||
const dom = latestMessageRef.current;
|
||||
if (dom && !isIOS() && autoScroll) {
|
||||
dom.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.chat} key={session.id}>
|
||||
<div className={styles["window-header"]}>
|
||||
<div
|
||||
className={styles["window-header-title"]}
|
||||
onClick={props?.showSideBar}
|
||||
>
|
||||
<div className={styles["window-header-main-title"]}>
|
||||
{session.topic}
|
||||
</div>
|
||||
<div className={styles["window-header-sub-title"]}>
|
||||
{Locale.Chat.SubTitle(session.messages.length)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["window-actions"]}>
|
||||
<div className={styles["window-action-button"] + " " + styles.mobile}>
|
||||
<IconButton
|
||||
icon={<MenuIcon />}
|
||||
bordered
|
||||
title={Locale.Chat.Actions.ChatList}
|
||||
onClick={props?.showSideBar}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["window-action-button"]}>
|
||||
<IconButton
|
||||
icon={<BrainIcon />}
|
||||
bordered
|
||||
title={Locale.Chat.Actions.CompressedHistory}
|
||||
onClick={() => {
|
||||
showMemoryPrompt(session);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["window-action-button"]}>
|
||||
<IconButton
|
||||
icon={<ExportIcon />}
|
||||
bordered
|
||||
title={Locale.Chat.Actions.Export}
|
||||
onClick={() => {
|
||||
exportMessages(session.messages, session.topic);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["chat-body"]}>
|
||||
{messages.map((message, i) => {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
isUser ? styles["chat-message-user"] : styles["chat-message"]
|
||||
}
|
||||
>
|
||||
<div className={styles["chat-message-container"]}>
|
||||
<div className={styles["chat-message-avatar"]}>
|
||||
<Avatar role={message.role} />
|
||||
</div>
|
||||
{(message.preview || message.streaming) && (
|
||||
<div className={styles["chat-message-status"]}>
|
||||
{Locale.Chat.Typing}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles["chat-message-item"]}>
|
||||
{!isUser && (
|
||||
<div className={styles["chat-message-top-actions"]}>
|
||||
{message.streaming ? (
|
||||
<div
|
||||
className={styles["chat-message-top-action"]}
|
||||
onClick={() => onUserStop(i)}
|
||||
>
|
||||
{Locale.Chat.Actions.Stop}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={styles["chat-message-top-action"]}
|
||||
onClick={() => onResend(i)}
|
||||
>
|
||||
{Locale.Chat.Actions.Retry}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={styles["chat-message-top-action"]}
|
||||
onClick={() => copyToClipboard(message.content)}
|
||||
>
|
||||
{Locale.Chat.Actions.Copy}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(message.preview || message.content.length === 0) &&
|
||||
!isUser ? (
|
||||
<LoadingIcon />
|
||||
) : (
|
||||
<div
|
||||
className="markdown-body"
|
||||
onContextMenu={(e) => onRightClick(e, message)}
|
||||
>
|
||||
<Markdown content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isUser && !message.preview && (
|
||||
<div className={styles["chat-message-actions"]}>
|
||||
<div className={styles["chat-message-action-date"]}>
|
||||
{message.date.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={latestMessageRef} style={{ opacity: 0, height: "2em" }}>
|
||||
-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["chat-input-panel"]}>
|
||||
<div className={styles["chat-input-panel-inner"]}>
|
||||
<textarea
|
||||
className={styles["chat-input"]}
|
||||
placeholder={Locale.Chat.Input(submitKey)}
|
||||
rows={3}
|
||||
onInput={(e) => setUserInput(e.currentTarget.value)}
|
||||
value={userInput}
|
||||
onKeyDown={(e) => onInputKeyDown(e as any)}
|
||||
onFocus={() => setAutoScroll(true)}
|
||||
onBlur={() => setAutoScroll(false)}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton
|
||||
icon={<SendWhiteIcon />}
|
||||
text={Locale.Chat.Send}
|
||||
className={styles["chat-input-send"] + " no-dark"}
|
||||
onClick={onUserSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSwitchTheme() {
|
||||
const config = useChatStore((state) => state.config);
|
||||
export function useSwitchTheme() {
|
||||
const config = useAppConfig();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.remove("light");
|
||||
@@ -406,70 +64,24 @@ function useSwitchTheme() {
|
||||
document.body.classList.add("light");
|
||||
}
|
||||
|
||||
const themeColor = getComputedStyle(document.body).getPropertyValue("--theme-color").trim();
|
||||
const metaDescription = document.querySelector('meta[name="theme-color"]');
|
||||
metaDescription?.setAttribute('content', themeColor);
|
||||
const metaDescriptionDark = document.querySelector(
|
||||
'meta[name="theme-color"][media*="dark"]',
|
||||
);
|
||||
const metaDescriptionLight = document.querySelector(
|
||||
'meta[name="theme-color"][media*="light"]',
|
||||
);
|
||||
|
||||
if (config.theme === "auto") {
|
||||
metaDescriptionDark?.setAttribute("content", "#151515");
|
||||
metaDescriptionLight?.setAttribute("content", "#fafafa");
|
||||
} else {
|
||||
const themeColor = getCSSVar("--theme-color");
|
||||
metaDescriptionDark?.setAttribute("content", themeColor);
|
||||
metaDescriptionLight?.setAttribute("content", themeColor);
|
||||
}
|
||||
}, [config.theme]);
|
||||
}
|
||||
|
||||
function exportMessages(messages: Message[], topic: string) {
|
||||
const mdText =
|
||||
`# ${topic}\n\n` +
|
||||
messages
|
||||
.map((m) => {
|
||||
return m.role === "user" ? `## ${m.content}` : m.content.trim();
|
||||
})
|
||||
.join("\n\n");
|
||||
const filename = `${topic}.md`;
|
||||
|
||||
showModal({
|
||||
title: Locale.Export.Title,
|
||||
children: (
|
||||
<div className="markdown-body">
|
||||
<pre className={styles["export-content"]}>{mdText}</pre>
|
||||
</div>
|
||||
),
|
||||
actions: [
|
||||
<IconButton
|
||||
key="copy"
|
||||
icon={<CopyIcon />}
|
||||
bordered
|
||||
text={Locale.Export.Copy}
|
||||
onClick={() => copyToClipboard(mdText)}
|
||||
/>,
|
||||
<IconButton
|
||||
key="download"
|
||||
icon={<DownloadIcon />}
|
||||
bordered
|
||||
text={Locale.Export.Download}
|
||||
onClick={() => downloadAs(mdText, filename)}
|
||||
/>,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function showMemoryPrompt(session: ChatSession) {
|
||||
showModal({
|
||||
title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
|
||||
children: (
|
||||
<div className="markdown-body">
|
||||
<pre className={styles["export-content"]}>
|
||||
{session.memoryPrompt || Locale.Memory.EmptyContent}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
actions: [
|
||||
<IconButton
|
||||
key="copy"
|
||||
icon={<CopyIcon />}
|
||||
bordered
|
||||
text={Locale.Memory.Copy}
|
||||
onClick={() => copyToClipboard(session.memoryPrompt)}
|
||||
/>,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const useHasHydrated = () => {
|
||||
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
||||
|
||||
@@ -480,105 +92,75 @@ const useHasHydrated = () => {
|
||||
return hasHydrated;
|
||||
};
|
||||
|
||||
export function Home() {
|
||||
const [createNewSession, currentIndex, removeSession] = useChatStore(
|
||||
(state) => [
|
||||
state.newSession,
|
||||
state.currentSessionIndex,
|
||||
state.removeSession,
|
||||
]
|
||||
const loadAsyncGoogleFont = () => {
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.rel = "stylesheet";
|
||||
linkEl.href =
|
||||
"/google-fonts/css2?family=Noto+Sans+SC:wght@300;400;700;900&display=swap";
|
||||
document.head.appendChild(linkEl);
|
||||
};
|
||||
|
||||
function Screen() {
|
||||
const config = useAppConfig();
|
||||
const location = useLocation();
|
||||
const isHome = location.pathname === Path.Home;
|
||||
const isAuth = location.pathname === Path.Auth;
|
||||
const isMobileScreen = useMobileScreen();
|
||||
|
||||
useEffect(() => {
|
||||
loadAsyncGoogleFont();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
styles.container +
|
||||
` ${
|
||||
config.tightBorder && !isMobileScreen
|
||||
? styles["tight-container"]
|
||||
: styles.container
|
||||
}`
|
||||
}
|
||||
>
|
||||
{isAuth ? (
|
||||
<>
|
||||
<AuthPage />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
||||
|
||||
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route path={Path.NewChat} element={<NewChat />} />
|
||||
<Route path={Path.Masks} element={<MaskPage />} />
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const loading = !useHasHydrated();
|
||||
const [showSideBar, setShowSideBar] = useState(true);
|
||||
|
||||
// setting
|
||||
const [openSettings, setOpenSettings] = useState(false);
|
||||
const config = useChatStore((state) => state.config);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
useSwitchTheme();
|
||||
|
||||
if (loading) {
|
||||
useEffect(() => {
|
||||
console.log("[Config] got config from build time", getClientConfig());
|
||||
}, []);
|
||||
|
||||
if (!useHasHydrated()) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
config.tightBorder ? styles["tight-container"] : styles.container
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`}
|
||||
>
|
||||
<div className={styles["sidebar-header"]}>
|
||||
<div className={styles["sidebar-title"]}>ChatGPT Next</div>
|
||||
<div className={styles["sidebar-sub-title"]}>
|
||||
Build your own AI assistant.
|
||||
</div>
|
||||
<div className={styles["sidebar-logo"]}>
|
||||
<ChatGptIcon />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles["sidebar-body"]}
|
||||
onClick={() => {
|
||||
setOpenSettings(false);
|
||||
setShowSideBar(false);
|
||||
}}
|
||||
>
|
||||
<ChatList />
|
||||
</div>
|
||||
|
||||
<div className={styles["sidebar-tail"]}>
|
||||
<div className={styles["sidebar-actions"]}>
|
||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
onClick={() => {
|
||||
if (confirm(Locale.Home.DeleteChat)) {
|
||||
removeSession(currentIndex);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<IconButton
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => {
|
||||
setOpenSettings(true);
|
||||
setShowSideBar(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<a href={REPO_URL} target="_blank">
|
||||
<IconButton icon={<GithubIcon />} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Home.NewChat}
|
||||
onClick={createNewSession}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["window-content"]}>
|
||||
{openSettings ? (
|
||||
<Settings
|
||||
closeSettings={() => {
|
||||
setOpenSettings(false);
|
||||
setShowSideBar(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Chat key="chat" showSideBar={() => setShowSideBar(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
<Screen />
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
12
app/components/input-range.module.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.input-range {
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
padding: 5px 15px 5px 10px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
max-width: 40%;
|
||||
|
||||
input[type="range"] {
|
||||
max-width: calc(100% - 50px);
|
||||
}
|
||||
}
|
37
app/components/input-range.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import styles from "./input-range.module.scss";
|
||||
|
||||
interface InputRangeProps {
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
title?: string;
|
||||
value: number | string;
|
||||
className?: string;
|
||||
min: string;
|
||||
max: string;
|
||||
step: string;
|
||||
}
|
||||
|
||||
export function InputRange({
|
||||
onChange,
|
||||
title,
|
||||
value,
|
||||
className,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: InputRangeProps) {
|
||||
return (
|
||||
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
||||
{title || value}
|
||||
<input
|
||||
type="range"
|
||||
title={title}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={onChange}
|
||||
></input>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,14 +1,73 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
import RemarkMath from "remark-math";
|
||||
import RemarkBreaks from "remark-breaks";
|
||||
import RehypeKatex from "rehype-katex";
|
||||
import RemarkGfm from "remark-gfm";
|
||||
import RehypePrsim from "rehype-prism-plus";
|
||||
import { useRef } from "react";
|
||||
import RehypeHighlight from "rehype-highlight";
|
||||
import { useRef, useState, RefObject, useEffect } from "react";
|
||||
import { copyToClipboard } from "../utils";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import React from "react";
|
||||
|
||||
export function Mermaid(props: { code: string; onError: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.code && ref.current) {
|
||||
mermaid
|
||||
.run({
|
||||
nodes: [ref.current],
|
||||
})
|
||||
.catch((e) => {
|
||||
props.onError();
|
||||
console.error("[Mermaid] ", e.message);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.code]);
|
||||
|
||||
function viewSvgInNewWindow() {
|
||||
const svg = ref.current?.querySelector("svg");
|
||||
if (!svg) return;
|
||||
const text = new XMLSerializer().serializeToString(svg);
|
||||
const blob = new Blob([text], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const win = window.open(url);
|
||||
if (win) {
|
||||
win.onload = () => URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="no-dark"
|
||||
style={{ cursor: "pointer", overflow: "auto" }}
|
||||
ref={ref}
|
||||
onClick={() => viewSvgInNewWindow()}
|
||||
>
|
||||
{props.code}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const [mermaidCode, setMermaidCode] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
||||
if (mermaidDom) {
|
||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||
}
|
||||
}, [props.children]);
|
||||
|
||||
if (mermaidCode) {
|
||||
return <Mermaid code={mermaidCode} onError={() => setMermaidCode("")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre ref={ref}>
|
||||
@@ -26,16 +85,96 @@ export function PreCode(props: { children: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Markdown(props: { content: string }) {
|
||||
function _MarkDownContent(props: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[RemarkMath, RemarkGfm]}
|
||||
rehypePlugins={[RehypeKatex, [RehypePrsim, { ignoreMissing: true }]]}
|
||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||
rehypePlugins={[
|
||||
RehypeKatex,
|
||||
[
|
||||
RehypeHighlight,
|
||||
{
|
||||
detect: false,
|
||||
ignoreMissing: true,
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
pre: PreCode,
|
||||
a: (aProps) => {
|
||||
const href = aProps.href || "";
|
||||
const isInternal = /^\/#/i.test(href);
|
||||
const target = isInternal ? "_self" : aProps.target ?? "_blank";
|
||||
return <a {...aProps} target={target} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
export const MarkdownContent = React.memo(_MarkDownContent);
|
||||
|
||||
export function Markdown(
|
||||
props: {
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
fontSize?: number;
|
||||
parentRef?: RefObject<HTMLDivElement>;
|
||||
defaultShow?: boolean;
|
||||
} & React.DOMAttributes<HTMLDivElement>,
|
||||
) {
|
||||
const mdRef = useRef<HTMLDivElement>(null);
|
||||
const renderedHeight = useRef(0);
|
||||
const inView = useRef(!!props.defaultShow);
|
||||
|
||||
const parent = props.parentRef?.current;
|
||||
const md = mdRef.current;
|
||||
|
||||
const checkInView = () => {
|
||||
if (parent && md) {
|
||||
const parentBounds = parent.getBoundingClientRect();
|
||||
const twoScreenHeight = Math.max(500, parentBounds.height * 2);
|
||||
const mdBounds = md.getBoundingClientRect();
|
||||
const parentTop = parentBounds.top - twoScreenHeight;
|
||||
const parentBottom = parentBounds.bottom + twoScreenHeight;
|
||||
const isOverlap =
|
||||
Math.max(parentTop, mdBounds.top) <=
|
||||
Math.min(parentBottom, mdBounds.bottom);
|
||||
inView.current = isOverlap;
|
||||
}
|
||||
|
||||
if (inView.current && md) {
|
||||
renderedHeight.current = Math.max(
|
||||
renderedHeight.current,
|
||||
md.getBoundingClientRect().height,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => checkInView(), 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-body"
|
||||
style={{
|
||||
fontSize: `${props.fontSize ?? 14}px`,
|
||||
height:
|
||||
!inView.current && renderedHeight.current > 0
|
||||
? renderedHeight.current
|
||||
: "auto",
|
||||
}}
|
||||
ref={mdRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClickCapture={props.onDoubleClickCapture}
|
||||
>
|
||||
{inView.current &&
|
||||
(props.loading ? (
|
||||
<LoadingIcon />
|
||||
) : (
|
||||
<MarkdownContent content={props.content} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
108
app/components/mask.module.scss
Normal file
@@ -0,0 +1,108 @@
|
||||
@import "../styles/animation.scss";
|
||||
.mask-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.mask-page-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.mask-filter {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
animation: slide-in ease 0.3s;
|
||||
height: 40px;
|
||||
|
||||
display: flex;
|
||||
|
||||
.search-bar {
|
||||
flex-grow: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mask-filter-lang {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.mask-create {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
box-sizing: border-box;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.mask-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
border: var(--border-in-light);
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.mask-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.mask-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.mask-title {
|
||||
.mask-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mask-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mask-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.mask-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
494
app/components/mask.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import styles from "./mask.module.scss";
|
||||
|
||||
import DownloadIcon from "../icons/download.svg";
|
||||
import UploadIcon from "../icons/upload.svg";
|
||||
import EditIcon from "../icons/edit.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
|
||||
import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
|
||||
import { ChatMessage, ModelConfig, useAppConfig, useChatStore } from "../store";
|
||||
import { ROLES } from "../client/api";
|
||||
import { Input, List, ListItem, Modal, Popover, Select } from "./ui-lib";
|
||||
import { Avatar, AvatarPicker } from "./emoji";
|
||||
import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import chatStyle from "./chat.module.scss";
|
||||
import { useEffect, useState } from "react";
|
||||
import { downloadAs, readFromFile } from "../utils";
|
||||
import { Updater } from "../typing";
|
||||
import { ModelConfigList } from "./model-config";
|
||||
import { FileName, Path } from "../constant";
|
||||
import { BUILTIN_MASK_STORE } from "../masks";
|
||||
|
||||
export function MaskAvatar(props: { mask: Mask }) {
|
||||
return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
|
||||
<Avatar avatar={props.mask.avatar} />
|
||||
) : (
|
||||
<Avatar model={props.mask.modelConfig.model} />
|
||||
);
|
||||
}
|
||||
|
||||
export function MaskConfig(props: {
|
||||
mask: Mask;
|
||||
updateMask: Updater<Mask>;
|
||||
extraListItems?: JSX.Element;
|
||||
readonly?: boolean;
|
||||
shouldSyncFromGlobal?: boolean;
|
||||
}) {
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const updateConfig = (updater: (config: ModelConfig) => void) => {
|
||||
if (props.readonly) return;
|
||||
|
||||
const config = { ...props.mask.modelConfig };
|
||||
updater(config);
|
||||
props.updateMask((mask) => {
|
||||
mask.modelConfig = config;
|
||||
// if user changed current session mask, it will disable auto sync
|
||||
mask.syncGlobalConfig = false;
|
||||
});
|
||||
};
|
||||
|
||||
const globalConfig = useAppConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextPrompts
|
||||
context={props.mask.context}
|
||||
updateContext={(updater) => {
|
||||
const context = props.mask.context.slice();
|
||||
updater(context);
|
||||
props.updateMask((mask) => (mask.context = context));
|
||||
}}
|
||||
/>
|
||||
|
||||
<List>
|
||||
<ListItem title={Locale.Mask.Config.Avatar}>
|
||||
<Popover
|
||||
content={
|
||||
<AvatarPicker
|
||||
onEmojiClick={(emoji) => {
|
||||
props.updateMask((mask) => (mask.avatar = emoji));
|
||||
setShowPicker(false);
|
||||
}}
|
||||
></AvatarPicker>
|
||||
}
|
||||
open={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
>
|
||||
<div
|
||||
onClick={() => setShowPicker(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<MaskAvatar mask={props.mask} />
|
||||
</div>
|
||||
</Popover>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Mask.Config.Name}>
|
||||
<input
|
||||
type="text"
|
||||
value={props.mask.name}
|
||||
onInput={(e) =>
|
||||
props.updateMask((mask) => {
|
||||
mask.name = e.currentTarget.value;
|
||||
})
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.HideContext.Title}
|
||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.mask.hideContext}
|
||||
onChange={(e) => {
|
||||
props.updateMask((mask) => {
|
||||
mask.hideContext = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
{props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Sync.Title}
|
||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.mask.syncGlobalConfig}
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.currentTarget.checked &&
|
||||
confirm(Locale.Mask.Config.Sync.Confirm)
|
||||
) {
|
||||
props.updateMask((mask) => {
|
||||
mask.syncGlobalConfig = e.currentTarget.checked;
|
||||
mask.modelConfig = { ...globalConfig.modelConfig };
|
||||
});
|
||||
}
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ModelConfigList
|
||||
modelConfig={{ ...props.mask.modelConfig }}
|
||||
updateConfig={updateConfig}
|
||||
/>
|
||||
{props.extraListItems}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextPromptItem(props: {
|
||||
prompt: ChatMessage;
|
||||
update: (prompt: ChatMessage) => void;
|
||||
remove: () => void;
|
||||
}) {
|
||||
const [focusingInput, setFocusingInput] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={chatStyle["context-prompt-row"]}>
|
||||
{!focusingInput && (
|
||||
<Select
|
||||
value={props.prompt.role}
|
||||
className={chatStyle["context-role"]}
|
||||
onChange={(e) =>
|
||||
props.update({
|
||||
...props.prompt,
|
||||
role: e.target.value as any,
|
||||
})
|
||||
}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<Input
|
||||
value={props.prompt.content}
|
||||
type="text"
|
||||
className={chatStyle["context-content"]}
|
||||
rows={focusingInput ? 5 : 1}
|
||||
onFocus={() => setFocusingInput(true)}
|
||||
onBlur={() => setFocusingInput(false)}
|
||||
onInput={(e) =>
|
||||
props.update({
|
||||
...props.prompt,
|
||||
content: e.currentTarget.value as any,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!focusingInput && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
className={chatStyle["context-delete-button"]}
|
||||
onClick={() => props.remove()}
|
||||
bordered
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextPrompts(props: {
|
||||
context: ChatMessage[];
|
||||
updateContext: (updater: (context: ChatMessage[]) => void) => void;
|
||||
}) {
|
||||
const context = props.context;
|
||||
|
||||
const addContextPrompt = (prompt: ChatMessage) => {
|
||||
props.updateContext((context) => context.push(prompt));
|
||||
};
|
||||
|
||||
const removeContextPrompt = (i: number) => {
|
||||
props.updateContext((context) => context.splice(i, 1));
|
||||
};
|
||||
|
||||
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
|
||||
props.updateContext((context) => (context[i] = prompt));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
|
||||
{context.map((c, i) => (
|
||||
<ContextPromptItem
|
||||
key={i}
|
||||
prompt={c}
|
||||
update={(prompt) => updateContextPrompt(i, prompt)}
|
||||
remove={() => removeContextPrompt(i)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className={chatStyle["context-prompt-row"]}>
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Context.Add}
|
||||
bordered
|
||||
className={chatStyle["context-prompt-button"]}
|
||||
onClick={() =>
|
||||
addContextPrompt({
|
||||
role: "user",
|
||||
content: "",
|
||||
date: "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MaskPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const maskStore = useMaskStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const [filterLang, setFilterLang] = useState<Lang>();
|
||||
|
||||
const allMasks = maskStore
|
||||
.getAll()
|
||||
.filter((m) => !filterLang || m.lang === filterLang);
|
||||
|
||||
const [searchMasks, setSearchMasks] = useState<Mask[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const masks = searchText.length > 0 ? searchMasks : allMasks;
|
||||
|
||||
// simple search, will refactor later
|
||||
const onSearch = (text: string) => {
|
||||
setSearchText(text);
|
||||
if (text.length > 0) {
|
||||
const result = allMasks.filter((m) => m.name.includes(text));
|
||||
setSearchMasks(result);
|
||||
} else {
|
||||
setSearchMasks(allMasks);
|
||||
}
|
||||
};
|
||||
|
||||
const [editingMaskId, setEditingMaskId] = useState<number | undefined>();
|
||||
const editingMask =
|
||||
maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
|
||||
const closeMaskModal = () => setEditingMaskId(undefined);
|
||||
|
||||
const downloadAll = () => {
|
||||
downloadAs(JSON.stringify(masks), FileName.Masks);
|
||||
};
|
||||
|
||||
const importFromFile = () => {
|
||||
readFromFile().then((content) => {
|
||||
try {
|
||||
const importMasks = JSON.parse(content);
|
||||
if (Array.isArray(importMasks)) {
|
||||
for (const mask of importMasks) {
|
||||
if (mask.name) {
|
||||
maskStore.create(mask);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
//if the content is a single mask.
|
||||
if (importMasks.name) {
|
||||
maskStore.create(importMasks);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.Mask.Page.Title}
|
||||
</div>
|
||||
<div className="window-header-submai-title">
|
||||
{Locale.Mask.Page.SubTitle(allMasks.length)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<DownloadIcon />}
|
||||
bordered
|
||||
onClick={downloadAll}
|
||||
/>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<UploadIcon />}
|
||||
bordered
|
||||
onClick={() => importFromFile()}
|
||||
/>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mask-page-body"]}>
|
||||
<div className={styles["mask-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={Locale.Mask.Page.Search}
|
||||
autoFocus
|
||||
onInput={(e) => onSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
className={styles["mask-filter-lang"]}
|
||||
value={filterLang ?? Locale.Settings.Lang.All}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
if (value === Locale.Settings.Lang.All) {
|
||||
setFilterLang(undefined);
|
||||
} else {
|
||||
setFilterLang(value as Lang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option key="all" value={Locale.Settings.Lang.All}>
|
||||
{Locale.Settings.Lang.All}
|
||||
</option>
|
||||
{AllLangs.map((lang) => (
|
||||
<option value={lang} key={lang}>
|
||||
{ALL_LANG_OPTIONS[lang]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<IconButton
|
||||
className={styles["mask-create"]}
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Mask.Page.Create}
|
||||
bordered
|
||||
onClick={() => {
|
||||
const createdMask = maskStore.create();
|
||||
setEditingMaskId(createdMask.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{masks.map((m) => (
|
||||
<div className={styles["mask-item"]} key={m.id}>
|
||||
<div className={styles["mask-header"]}>
|
||||
<div className={styles["mask-icon"]}>
|
||||
<MaskAvatar mask={m} />
|
||||
</div>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>{m.name}</div>
|
||||
<div className={styles["mask-info"] + " one-line"}>
|
||||
{`${Locale.Mask.Item.Info(m.context.length)} / ${
|
||||
ALL_LANG_OPTIONS[m.lang]
|
||||
} / ${m.modelConfig.model}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["mask-actions"]}>
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Mask.Item.Chat}
|
||||
onClick={() => {
|
||||
chatStore.newSession(m);
|
||||
navigate(Path.Chat);
|
||||
}}
|
||||
/>
|
||||
{m.builtin ? (
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text={Locale.Mask.Item.View}
|
||||
onClick={() => setEditingMaskId(m.id)}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text={Locale.Mask.Item.Edit}
|
||||
onClick={() => setEditingMaskId(m.id)}
|
||||
/>
|
||||
)}
|
||||
{!m.builtin && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
text={Locale.Mask.Item.Delete}
|
||||
onClick={() => {
|
||||
if (confirm(Locale.Mask.Item.DeleteConfirm)) {
|
||||
maskStore.delete(m.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingMask && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Mask.EditModal.Title(editingMask?.builtin)}
|
||||
onClose={closeMaskModal}
|
||||
actions={[
|
||||
<IconButton
|
||||
icon={<DownloadIcon />}
|
||||
text={Locale.Mask.EditModal.Download}
|
||||
key="export"
|
||||
bordered
|
||||
onClick={() =>
|
||||
downloadAs(
|
||||
JSON.stringify(editingMask),
|
||||
`${editingMask.name}.json`,
|
||||
)
|
||||
}
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
icon={<CopyIcon />}
|
||||
bordered
|
||||
text={Locale.Mask.EditModal.Clone}
|
||||
onClick={() => {
|
||||
navigate(Path.Masks);
|
||||
maskStore.create(editingMask);
|
||||
setEditingMaskId(undefined);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<MaskConfig
|
||||
mask={editingMask}
|
||||
updateMask={(updater) =>
|
||||
maskStore.update(editingMaskId!, updater)
|
||||
}
|
||||
readonly={editingMask.builtin}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
76
app/components/message-selector.module.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.message-selector {
|
||||
.message-filter {
|
||||
display: flex;
|
||||
|
||||
.search-bar {
|
||||
max-width: unset;
|
||||
flex-grow: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
|
||||
.search-bar {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
margin-top: 20px;
|
||||
border-radius: 10px;
|
||||
border: var(--border-in-light);
|
||||
overflow: hidden;
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&-selected {
|
||||
background-color: var(--second);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex-grow: 1;
|
||||
max-width: calc(100% - 40px);
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
215
app/components/message-selector.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
||||
import { Updater } from "../typing";
|
||||
import { IconButton } from "./button";
|
||||
import { Avatar } from "./emoji";
|
||||
import { MaskAvatar } from "./mask";
|
||||
import Locale from "../locales";
|
||||
|
||||
import styles from "./message-selector.module.scss";
|
||||
|
||||
function useShiftRange() {
|
||||
const [startIndex, setStartIndex] = useState<number>();
|
||||
const [endIndex, setEndIndex] = useState<number>();
|
||||
const [shiftDown, setShiftDown] = useState(false);
|
||||
|
||||
const onClickIndex = (index: number) => {
|
||||
if (shiftDown && startIndex !== undefined) {
|
||||
setEndIndex(index);
|
||||
} else {
|
||||
setStartIndex(index);
|
||||
setEndIndex(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Shift") return;
|
||||
setShiftDown(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Shift") return;
|
||||
setShiftDown(false);
|
||||
setStartIndex(undefined);
|
||||
setEndIndex(undefined);
|
||||
};
|
||||
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onClickIndex,
|
||||
startIndex,
|
||||
endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMessageSelector() {
|
||||
const [selection, setSelection] = useState(new Set<number>());
|
||||
const updateSelection: Updater<Set<number>> = (updater) => {
|
||||
const newSelection = new Set<number>(selection);
|
||||
updater(newSelection);
|
||||
setSelection(newSelection);
|
||||
};
|
||||
|
||||
return {
|
||||
selection,
|
||||
updateSelection,
|
||||
};
|
||||
}
|
||||
|
||||
export function MessageSelector(props: {
|
||||
selection: Set<number>;
|
||||
updateSelection: Updater<Set<number>>;
|
||||
defaultSelectAll?: boolean;
|
||||
onSelected?: (messages: ChatMessage[]) => void;
|
||||
}) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
|
||||
const messages = session.messages.filter(
|
||||
(m, i) =>
|
||||
m.id && // message must have id
|
||||
isValid(m) &&
|
||||
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
|
||||
);
|
||||
const messageCount = messages.length;
|
||||
const config = useAppConfig();
|
||||
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchIds, setSearchIds] = useState(new Set<number>());
|
||||
const isInSearchResult = (id: number) => {
|
||||
return searchInput.length === 0 || searchIds.has(id);
|
||||
};
|
||||
const doSearch = (text: string) => {
|
||||
const searchResults = new Set<number>();
|
||||
if (text.length > 0) {
|
||||
messages.forEach((m) =>
|
||||
m.content.includes(text) ? searchResults.add(m.id!) : null,
|
||||
);
|
||||
}
|
||||
setSearchIds(searchResults);
|
||||
};
|
||||
|
||||
// for range selection
|
||||
const { startIndex, endIndex, onClickIndex } = useShiftRange();
|
||||
|
||||
const selectAll = () => {
|
||||
props.updateSelection((selection) =>
|
||||
messages.forEach((m) => selection.add(m.id!)),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.defaultSelectAll) {
|
||||
selectAll();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (startIndex === undefined || endIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
|
||||
props.updateSelection((selection) => {
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
selection.add(messages[i].id ?? i);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
const LATEST_COUNT = 4;
|
||||
|
||||
return (
|
||||
<div className={styles["message-selector"]}>
|
||||
<div className={styles["message-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={Locale.Select.Search}
|
||||
className={styles["filter-item"] + " " + styles["search-bar"]}
|
||||
value={searchInput}
|
||||
onInput={(e) => {
|
||||
setSearchInput(e.currentTarget.value);
|
||||
doSearch(e.currentTarget.value);
|
||||
}}
|
||||
></input>
|
||||
|
||||
<div className={styles["actions"]}>
|
||||
<IconButton
|
||||
text={Locale.Select.All}
|
||||
bordered
|
||||
className={styles["filter-item"]}
|
||||
onClick={selectAll}
|
||||
/>
|
||||
<IconButton
|
||||
text={Locale.Select.Latest}
|
||||
bordered
|
||||
className={styles["filter-item"]}
|
||||
onClick={() =>
|
||||
props.updateSelection((selection) => {
|
||||
selection.clear();
|
||||
messages
|
||||
.slice(messageCount - LATEST_COUNT)
|
||||
.forEach((m) => selection.add(m.id!));
|
||||
})
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
text={Locale.Select.Clear}
|
||||
bordered
|
||||
className={styles["filter-item"]}
|
||||
onClick={() =>
|
||||
props.updateSelection((selection) => selection.clear())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["messages"]}>
|
||||
{messages.map((m, i) => {
|
||||
if (!isInSearchResult(m.id!)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles["message"]} ${
|
||||
props.selection.has(m.id!) && styles["message-selected"]
|
||||
}`}
|
||||
key={i}
|
||||
onClick={() => {
|
||||
props.updateSelection((selection) => {
|
||||
const id = m.id ?? i;
|
||||
selection.has(id) ? selection.delete(id) : selection.add(id);
|
||||
});
|
||||
onClickIndex(i);
|
||||
}}
|
||||
>
|
||||
<div className={styles["avatar"]}>
|
||||
{m.role === "user" ? (
|
||||
<Avatar avatar={config.avatar}></Avatar>
|
||||
) : (
|
||||
<MaskAvatar mask={session.mask} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles["body"]}>
|
||||
<div className={styles["date"]}>
|
||||
{new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
<div className={`${styles["content"]} one-line`}>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
140
app/components/model-config.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ALL_MODELS, ModalConfigValidator, ModelConfig } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { InputRange } from "./input-range";
|
||||
import { List, ListItem, Select } from "./ui-lib";
|
||||
|
||||
export function ModelConfigList(props: {
|
||||
modelConfig: ModelConfig;
|
||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Model}>
|
||||
<Select
|
||||
value={props.modelConfig.model}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.model = ModalConfigValidator.model(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{ALL_MODELS.map((v) => (
|
||||
<option value={v.name} key={v.name} disabled={!v.available}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Temperature.Title}
|
||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
value={props.modelConfig.temperature?.toFixed(1)}
|
||||
min="0"
|
||||
max="1" // lets limit it to 0-1
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.temperature = ModalConfigValidator.temperature(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.MaxTokens.Title}
|
||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
max={32000}
|
||||
value={props.modelConfig.max_tokens}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.max_tokens = ModalConfigValidator.max_tokens(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.PresencePenalty.Title}
|
||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.presence_penalty =
|
||||
ModalConfigValidator.presence_penalty(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.HistoryCount.Title}
|
||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
title={props.modelConfig.historyMessageCount.toString()}
|
||||
value={props.modelConfig.historyMessageCount}
|
||||
min="0"
|
||||
max="32"
|
||||
step="1"
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.historyMessageCount = e.target.valueAsNumber),
|
||||
)
|
||||
}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.CompressThreshold.Title}
|
||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={500}
|
||||
max={4000}
|
||||
value={props.modelConfig.compressMessageLengthThreshold}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.compressMessageLengthThreshold =
|
||||
e.currentTarget.valueAsNumber),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.modelConfig.sendMemory}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.sendMemory = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
125
app/components/new-chat.module.scss
Normal file
@@ -0,0 +1,125 @@
|
||||
@import "../styles/animation.scss";
|
||||
|
||||
.new-chat {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
.mask-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
animation: slide-in-from-top ease 0.3s;
|
||||
}
|
||||
|
||||
.mask-cards {
|
||||
display: flex;
|
||||
margin-top: 5vh;
|
||||
margin-bottom: 20px;
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
.mask-card {
|
||||
padding: 20px 10px;
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 14px;
|
||||
background-color: var(--white);
|
||||
transform: scale(1);
|
||||
|
||||
&:first-child {
|
||||
transform: rotate(-15deg) translateY(5px);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
transform: rotate(15deg) translateY(5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: bolder;
|
||||
margin-bottom: 1vh;
|
||||
animation: slide-in ease 0.35s;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
animation: slide-in ease 0.4s;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 5vh;
|
||||
margin-bottom: 2vh;
|
||||
animation: slide-in ease 0.45s;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
|
||||
.skip {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.masks {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
|
||||
$linear: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
|
||||
-webkit-mask-image: $linear;
|
||||
mask-image: $linear;
|
||||
|
||||
animation: slide-in ease 0.5s;
|
||||
|
||||
.mask-row {
|
||||
display: flex;
|
||||
// justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@for $i from 1 to 10 {
|
||||
&:nth-child(#{$i * 2}) {
|
||||
margin-left: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.mask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
background-color: var(--white);
|
||||
border-radius: 10px;
|
||||
margin-right: 10px;
|
||||
max-width: 8em;
|
||||
transform: scale(1);
|
||||
cursor: pointer;
|
||||
transition: all ease 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px) scale(1.1);
|
||||
z-index: 999;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.mask-name {
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
188
app/components/new-chat.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Path, SlotID } from "../constant";
|
||||
import { IconButton } from "./button";
|
||||
import { EmojiAvatar } from "./emoji";
|
||||
import styles from "./new-chat.module.scss";
|
||||
|
||||
import LeftIcon from "../icons/left.svg";
|
||||
import LightningIcon from "../icons/lightning.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Mask, useMaskStore } from "../store/mask";
|
||||
import Locale from "../locales";
|
||||
import { useAppConfig, useChatStore } from "../store";
|
||||
import { MaskAvatar } from "./mask";
|
||||
import { useCommand } from "../command";
|
||||
|
||||
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
|
||||
const xmin = Math.max(aRect.x, bRect.x);
|
||||
const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
|
||||
const ymin = Math.max(aRect.y, bRect.y);
|
||||
const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
|
||||
const width = xmax - xmin;
|
||||
const height = ymax - ymin;
|
||||
const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
|
||||
return intersectionArea;
|
||||
}
|
||||
|
||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||
return (
|
||||
<div className={styles["mask"]} onClick={props.onClick}>
|
||||
<MaskAvatar mask={props.mask} />
|
||||
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useMaskGroup(masks: Mask[]) {
|
||||
const [groups, setGroups] = useState<Mask[][]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const computeGroup = () => {
|
||||
const appBody = document.getElementById(SlotID.AppBody);
|
||||
if (!appBody || masks.length === 0) return;
|
||||
|
||||
const rect = appBody.getBoundingClientRect();
|
||||
const maxWidth = rect.width;
|
||||
const maxHeight = rect.height * 0.6;
|
||||
const maskItemWidth = 120;
|
||||
const maskItemHeight = 50;
|
||||
|
||||
const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
|
||||
let maskIndex = 0;
|
||||
const nextMask = () => masks[maskIndex++ % masks.length];
|
||||
|
||||
const rows = Math.ceil(maxHeight / maskItemHeight);
|
||||
const cols = Math.ceil(maxWidth / maskItemWidth);
|
||||
|
||||
const newGroups = new Array(rows)
|
||||
.fill(0)
|
||||
.map((_, _i) =>
|
||||
new Array(cols)
|
||||
.fill(0)
|
||||
.map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
|
||||
);
|
||||
|
||||
setGroups(newGroups);
|
||||
};
|
||||
|
||||
computeGroup();
|
||||
|
||||
window.addEventListener("resize", computeGroup);
|
||||
return () => window.removeEventListener("resize", computeGroup);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function NewChat() {
|
||||
const chatStore = useChatStore();
|
||||
const maskStore = useMaskStore();
|
||||
|
||||
const masks = maskStore.getAll();
|
||||
const groups = useMaskGroup(masks);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
|
||||
const maskRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { state } = useLocation();
|
||||
|
||||
const startChat = (mask?: Mask) => {
|
||||
chatStore.newSession(mask);
|
||||
setTimeout(() => navigate(Path.Chat), 1);
|
||||
};
|
||||
|
||||
useCommand({
|
||||
mask: (id) => {
|
||||
try {
|
||||
const mask = maskStore.get(parseInt(id));
|
||||
startChat(mask ?? undefined);
|
||||
} catch {
|
||||
console.error("[New Chat] failed to create chat from mask id=", id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (maskRef.current) {
|
||||
maskRef.current.scrollLeft =
|
||||
(maskRef.current.scrollWidth - maskRef.current.clientWidth) / 2;
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
return (
|
||||
<div className={styles["new-chat"]}>
|
||||
<div className={styles["mask-header"]}>
|
||||
<IconButton
|
||||
icon={<LeftIcon />}
|
||||
text={Locale.NewChat.Return}
|
||||
onClick={() => navigate(Path.Home)}
|
||||
></IconButton>
|
||||
{!state?.fromHome && (
|
||||
<IconButton
|
||||
text={Locale.NewChat.NotShow}
|
||||
onClick={() => {
|
||||
if (confirm(Locale.NewChat.ConfirmNoShow)) {
|
||||
startChat();
|
||||
config.update(
|
||||
(config) => (config.dontShowMaskSplashScreen = true),
|
||||
);
|
||||
}
|
||||
}}
|
||||
></IconButton>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles["mask-cards"]}>
|
||||
<div className={styles["mask-card"]}>
|
||||
<EmojiAvatar avatar="1f606" size={24} />
|
||||
</div>
|
||||
<div className={styles["mask-card"]}>
|
||||
<EmojiAvatar avatar="1f916" size={24} />
|
||||
</div>
|
||||
<div className={styles["mask-card"]}>
|
||||
<EmojiAvatar avatar="1f479" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["title"]}>{Locale.NewChat.Title}</div>
|
||||
<div className={styles["sub-title"]}>{Locale.NewChat.SubTitle}</div>
|
||||
|
||||
<div className={styles["actions"]}>
|
||||
<IconButton
|
||||
text={Locale.NewChat.More}
|
||||
onClick={() => navigate(Path.Masks)}
|
||||
icon={<EyeIcon />}
|
||||
bordered
|
||||
shadow
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
text={Locale.NewChat.Skip}
|
||||
onClick={() => startChat()}
|
||||
icon={<LightningIcon />}
|
||||
type="primary"
|
||||
shadow
|
||||
className={styles["skip"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles["masks"]} ref={maskRef}>
|
||||
{groups.map((masks, i) => (
|
||||
<div key={i} className={styles["mask-row"]}>
|
||||
{masks.map((mask, index) => (
|
||||
<MaskItem
|
||||
key={index}
|
||||
mask={mask}
|
||||
onClick={() => startChat(mask)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,20 +1,72 @@
|
||||
@import "./window.scss";
|
||||
|
||||
.settings {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 14px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.settings-sub-title {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-prompt-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.edit-prompt-title {
|
||||
max-width: unset;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.edit-prompt-content {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.user-prompt-modal {
|
||||
min-height: 40vh;
|
||||
|
||||
.user-prompt-search {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--gray);
|
||||
}
|
||||
|
||||
.user-prompt-list {
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
|
||||
.user-prompt-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.user-prompt-header {
|
||||
max-width: calc(100% - 100px);
|
||||
|
||||
.user-prompt-title {
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
font-weight: bold;
|
||||
}
|
||||
.user-prompt-content {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-prompt-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 2px;
|
||||
|
||||
.user-prompt-button {
|
||||
//height: 100%;
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,115 +1,331 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
|
||||
import styles from "./settings.module.scss";
|
||||
|
||||
import ResetIcon from "../icons/reload.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
import ClearIcon from "../icons/clear.svg";
|
||||
|
||||
import { List, ListItem, Popover } from "./ui-lib";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import EditIcon from "../icons/edit.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import {
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Popover,
|
||||
Select,
|
||||
} from "./ui-lib";
|
||||
import { ModelConfigList } from "./model-config";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import {
|
||||
SubmitKey,
|
||||
useChatStore,
|
||||
Theme,
|
||||
ALL_MODELS,
|
||||
useUpdateStore,
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
} from "../store";
|
||||
import { Avatar } from "./home";
|
||||
|
||||
import Locale, { changeLang, getLang } from "../locales";
|
||||
import { getCurrentCommitId } from "../utils";
|
||||
import Locale, {
|
||||
AllLangs,
|
||||
ALL_LANG_OPTIONS,
|
||||
changeLang,
|
||||
getLang,
|
||||
} from "../locales";
|
||||
import { copyToClipboard } from "../utils";
|
||||
import Link from "next/link";
|
||||
import { UPDATE_URL } from "../constant";
|
||||
import { Path, UPDATE_URL } from "../constant";
|
||||
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
|
||||
import { ErrorBoundary } from "./error";
|
||||
import { InputRange } from "./input-range";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Avatar, AvatarPicker } from "./emoji";
|
||||
import { getClientConfig } from "../config/client";
|
||||
|
||||
function EditPromptModal(props: { id: number; onClose: () => void }) {
|
||||
const promptStore = usePromptStore();
|
||||
const prompt = promptStore.get(props.id);
|
||||
|
||||
return prompt ? (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Prompt.EditModal.Title}
|
||||
onClose={props.onClose}
|
||||
actions={[
|
||||
<IconButton
|
||||
key=""
|
||||
onClick={props.onClose}
|
||||
text={Locale.UI.Confirm}
|
||||
bordered
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className={styles["edit-prompt-modal"]}>
|
||||
<input
|
||||
type="text"
|
||||
value={prompt.title}
|
||||
readOnly={!prompt.isUser}
|
||||
className={styles["edit-prompt-title"]}
|
||||
onInput={(e) =>
|
||||
promptStore.update(
|
||||
props.id,
|
||||
(prompt) => (prompt.title = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
<Input
|
||||
value={prompt.content}
|
||||
readOnly={!prompt.isUser}
|
||||
className={styles["edit-prompt-content"]}
|
||||
rows={10}
|
||||
onInput={(e) =>
|
||||
promptStore.update(
|
||||
props.id,
|
||||
(prompt) => (prompt.content = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function UserPromptModal(props: { onClose?: () => void }) {
|
||||
const promptStore = usePromptStore();
|
||||
const userPrompts = promptStore.getUserPrompts();
|
||||
const builtinPrompts = SearchService.builtinPrompts;
|
||||
const allPrompts = userPrompts.concat(builtinPrompts);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
|
||||
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
|
||||
|
||||
const [editingPromptId, setEditingPromptId] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchInput.length > 0) {
|
||||
const searchResult = SearchService.search(searchInput);
|
||||
setSearchPrompts(searchResult);
|
||||
} else {
|
||||
setSearchPrompts([]);
|
||||
}
|
||||
}, [searchInput]);
|
||||
|
||||
function SettingItem(props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
children: JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<ListItem>
|
||||
<div className={styles["settings-title"]}>
|
||||
<div>{props.title}</div>
|
||||
{props.subTitle && (
|
||||
<div className={styles["settings-sub-title"]}>{props.subTitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{props.children}
|
||||
</ListItem>
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Prompt.Modal.Title}
|
||||
onClose={() => props.onClose?.()}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="add"
|
||||
onClick={() =>
|
||||
promptStore.add({
|
||||
title: "Empty Prompt",
|
||||
content: "Empty Prompt Content",
|
||||
})
|
||||
}
|
||||
icon={<AddIcon />}
|
||||
bordered
|
||||
text={Locale.Settings.Prompt.Modal.Add}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className={styles["user-prompt-modal"]}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles["user-prompt-search"]}
|
||||
placeholder={Locale.Settings.Prompt.Modal.Search}
|
||||
value={searchInput}
|
||||
onInput={(e) => setSearchInput(e.currentTarget.value)}
|
||||
></input>
|
||||
|
||||
<div className={styles["user-prompt-list"]}>
|
||||
{prompts.map((v, _) => (
|
||||
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
|
||||
<div className={styles["user-prompt-header"]}>
|
||||
<div className={styles["user-prompt-title"]}>{v.title}</div>
|
||||
<div className={styles["user-prompt-content"] + " one-line"}>
|
||||
{v.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["user-prompt-buttons"]}>
|
||||
{v.isUser && (
|
||||
<IconButton
|
||||
icon={<ClearIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => promptStore.remove(v.id!)}
|
||||
/>
|
||||
)}
|
||||
{v.isUser ? (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => setEditingPromptId(v.id)}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => setEditingPromptId(v.id)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={<CopyIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => copyToClipboard(v.content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{editingPromptId !== undefined && (
|
||||
<EditPromptModal
|
||||
id={editingPromptId!}
|
||||
onClose={() => setEditingPromptId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Settings(props: { closeSettings: () => void }) {
|
||||
function formatVersionDate(t: string) {
|
||||
const d = new Date(+t);
|
||||
const year = d.getUTCFullYear();
|
||||
const month = d.getUTCMonth() + 1;
|
||||
const day = d.getUTCDate();
|
||||
|
||||
return [
|
||||
year.toString(),
|
||||
month.toString().padStart(2, "0"),
|
||||
day.toString().padStart(2, "0"),
|
||||
].join("");
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [config, updateConfig, resetConfig, clearAllData] = useChatStore(
|
||||
(state) => [
|
||||
state.config,
|
||||
state.updateConfig,
|
||||
state.resetConfig,
|
||||
state.clearAllData,
|
||||
]
|
||||
);
|
||||
const config = useAppConfig();
|
||||
const updateConfig = config.update;
|
||||
const resetConfig = config.reset;
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const updateStore = useUpdateStore();
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
const currentId = getCurrentCommitId();
|
||||
const remoteId = updateStore.remoteId;
|
||||
const hasNewVersion = currentId !== remoteId;
|
||||
const currentVersion = formatVersionDate(updateStore.version);
|
||||
const remoteId = formatVersionDate(updateStore.remoteVersion);
|
||||
const hasNewVersion = currentVersion !== remoteId;
|
||||
|
||||
function checkUpdate(force = false) {
|
||||
setCheckingUpdate(true);
|
||||
updateStore.getLatestCommitId(force).then(() => {
|
||||
updateStore.getLatestVersion(force).then(() => {
|
||||
setCheckingUpdate(false);
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[Update] local version ",
|
||||
new Date(+updateStore.version).toLocaleString(),
|
||||
);
|
||||
console.log(
|
||||
"[Update] remote version ",
|
||||
new Date(+updateStore.remoteVersion).toLocaleString(),
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkUpdate();
|
||||
}, []);
|
||||
const usage = {
|
||||
used: updateStore.used,
|
||||
subscription: updateStore.subscription,
|
||||
};
|
||||
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||
function checkUsage(force = false) {
|
||||
setLoadingUsage(true);
|
||||
updateStore.updateUsage(force).finally(() => {
|
||||
setLoadingUsage(false);
|
||||
});
|
||||
}
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const enabledAccessControl = useMemo(
|
||||
() => accessStore.enabledAccessControl(),
|
||||
[]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const promptStore = usePromptStore();
|
||||
const builtinCount = SearchService.count.builtin;
|
||||
const customCount = promptStore.getUserPrompts().length ?? 0;
|
||||
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const showUsage = accessStore.isAuthorized();
|
||||
useEffect(() => {
|
||||
// checks per minutes
|
||||
checkUpdate();
|
||||
showUsage && checkUsage();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const keydownEvent = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
navigate(Path.Home);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", keydownEvent);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", keydownEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles["window-header"]}>
|
||||
<div className={styles["window-header-title"]}>
|
||||
<div className={styles["window-header-main-title"]}>
|
||||
<ErrorBoundary>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.Settings.Title}
|
||||
</div>
|
||||
<div className={styles["window-header-sub-title"]}>
|
||||
<div className="window-header-sub-title">
|
||||
{Locale.Settings.SubTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["window-actions"]}>
|
||||
<div className={styles["window-action-button"]}>
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<ClearIcon />}
|
||||
onClick={clearAllData}
|
||||
onClick={() => {
|
||||
if (confirm(Locale.Settings.Actions.ConfirmClearAll)) {
|
||||
chatStore.clearAllData();
|
||||
}
|
||||
}}
|
||||
bordered
|
||||
title={Locale.Settings.Actions.ClearAll}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["window-action-button"]}>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<ResetIcon />}
|
||||
onClick={resetConfig}
|
||||
onClick={() => {
|
||||
if (confirm(Locale.Settings.Actions.ConfirmResetAll)) {
|
||||
resetConfig();
|
||||
}
|
||||
}}
|
||||
bordered
|
||||
title={Locale.Settings.Actions.ResetAll}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["window-action-button"]}>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
onClick={props.closeSettings}
|
||||
onClick={() => navigate(Path.Home)}
|
||||
bordered
|
||||
title={Locale.Settings.Actions.Close}
|
||||
/>
|
||||
@@ -118,15 +334,13 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
</div>
|
||||
<div className={styles["settings"]}>
|
||||
<List>
|
||||
<SettingItem title={Locale.Settings.Avatar}>
|
||||
<ListItem title={Locale.Settings.Avatar}>
|
||||
<Popover
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
content={
|
||||
<EmojiPicker
|
||||
lazyLoadEmojis
|
||||
theme={EmojiTheme.AUTO}
|
||||
onEmojiClick={(e) => {
|
||||
updateConfig((config) => (config.avatar = e.unified));
|
||||
<AvatarPicker
|
||||
onEmojiClick={(avatar: string) => {
|
||||
updateConfig((config) => (config.avatar = avatar));
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
/>
|
||||
@@ -137,13 +351,13 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
className={styles.avatar}
|
||||
onClick={() => setShowEmojiPicker(true)}
|
||||
>
|
||||
<Avatar role="user" />
|
||||
<Avatar avatar={config.avatar} />
|
||||
</div>
|
||||
</Popover>
|
||||
</SettingItem>
|
||||
</ListItem>
|
||||
|
||||
<SettingItem
|
||||
title={Locale.Settings.Update.Version(currentId)}
|
||||
<ListItem
|
||||
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
|
||||
subTitle={
|
||||
checkingUpdate
|
||||
? Locale.Settings.Update.IsChecking
|
||||
@@ -153,7 +367,7 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
}
|
||||
>
|
||||
{checkingUpdate ? (
|
||||
<div />
|
||||
<LoadingIcon />
|
||||
) : hasNewVersion ? (
|
||||
<Link href={UPDATE_URL} target="_blank" className="link">
|
||||
{Locale.Settings.Update.GoToUpdate}
|
||||
@@ -165,15 +379,15 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
onClick={() => checkUpdate(true)}
|
||||
/>
|
||||
)}
|
||||
</SettingItem>
|
||||
</ListItem>
|
||||
|
||||
<SettingItem title={Locale.Settings.SendKey}>
|
||||
<select
|
||||
<ListItem title={Locale.Settings.SendKey}>
|
||||
<Select
|
||||
value={config.submitKey}
|
||||
onChange={(e) => {
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.submitKey = e.target.value as any as SubmitKey)
|
||||
(config.submitKey = e.target.value as any as SubmitKey),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -182,18 +396,15 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
</Select>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<div className={styles["settings-title"]}>
|
||||
{Locale.Settings.Theme}
|
||||
</div>
|
||||
<select
|
||||
<ListItem title={Locale.Settings.Theme}>
|
||||
<Select
|
||||
value={config.theme}
|
||||
onChange={(e) => {
|
||||
updateConfig(
|
||||
(config) => (config.theme = e.target.value as any as Theme)
|
||||
(config) => (config.theme = e.target.value as any as Theme),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -202,190 +413,199 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</ListItem>
|
||||
|
||||
<SettingItem title={Locale.Settings.Lang.Name}>
|
||||
<div className="">
|
||||
<select
|
||||
value={getLang()}
|
||||
onChange={(e) => {
|
||||
changeLang(e.target.value as any);
|
||||
}}
|
||||
>
|
||||
<option value="en" key="en">
|
||||
{Locale.Settings.Lang.Options.en}
|
||||
<ListItem title={Locale.Settings.Lang.Name}>
|
||||
<Select
|
||||
value={getLang()}
|
||||
onChange={(e) => {
|
||||
changeLang(e.target.value as any);
|
||||
}}
|
||||
>
|
||||
{AllLangs.map((lang) => (
|
||||
<option value={lang} key={lang}>
|
||||
{ALL_LANG_OPTIONS[lang]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
|
||||
<option value="cn" key="cn">
|
||||
{Locale.Settings.Lang.Options.cn}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.FontSize.Title}
|
||||
subTitle={Locale.Settings.FontSize.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
title={`${config.fontSize ?? 14}px`}
|
||||
value={config.fontSize}
|
||||
min="12"
|
||||
max="18"
|
||||
step="1"
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.fontSize = Number.parseInt(e.currentTarget.value)),
|
||||
)
|
||||
}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
|
||||
<div className="no-mobile">
|
||||
<SettingItem title={Locale.Settings.TightBorder}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.tightBorder}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) => (config.tightBorder = e.currentTarget.checked)
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</SettingItem>
|
||||
</div>
|
||||
<ListItem
|
||||
title={Locale.Settings.SendPreviewBubble.Title}
|
||||
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.sendPreviewBubble}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.sendPreviewBubble = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Mask.Title}
|
||||
subTitle={Locale.Settings.Mask.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!config.dontShowMaskSplashScreen}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.dontShowMaskSplashScreen =
|
||||
!e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<List>
|
||||
{enabledAccessControl ? (
|
||||
<SettingItem
|
||||
<ListItem
|
||||
title={Locale.Settings.AccessCode.Title}
|
||||
subTitle={Locale.Settings.AccessCode.SubTitle}
|
||||
>
|
||||
<input
|
||||
<PasswordInput
|
||||
value={accessStore.accessCode}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.AccessCode.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.updateCode(e.currentTarget.value);
|
||||
}}
|
||||
></input>
|
||||
</SettingItem>
|
||||
/>
|
||||
</ListItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
title={Locale.Settings.Token.Title}
|
||||
subTitle={Locale.Settings.Token.SubTitle}
|
||||
>
|
||||
<input
|
||||
value={accessStore.token}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Token.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.updateToken(e.currentTarget.value);
|
||||
}}
|
||||
></input>
|
||||
</SettingItem>
|
||||
{!accessStore.hideUserApiKey ? (
|
||||
<ListItem
|
||||
title={Locale.Settings.Token.Title}
|
||||
subTitle={Locale.Settings.Token.SubTitle}
|
||||
>
|
||||
<PasswordInput
|
||||
value={accessStore.token}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Token.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.updateToken(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
<SettingItem
|
||||
title={Locale.Settings.HistoryCount.Title}
|
||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||
<ListItem
|
||||
title={Locale.Settings.Usage.Title}
|
||||
subTitle={
|
||||
showUsage
|
||||
? loadingUsage
|
||||
? Locale.Settings.Usage.IsChecking
|
||||
: Locale.Settings.Usage.SubTitle(
|
||||
usage?.used ?? "[?]",
|
||||
usage?.subscription ?? "[?]",
|
||||
)
|
||||
: Locale.Settings.Usage.NoAccess
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
title={config.historyMessageCount.toString()}
|
||||
value={config.historyMessageCount}
|
||||
min="2"
|
||||
max="25"
|
||||
step="2"
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.historyMessageCount = e.target.valueAsNumber)
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</SettingItem>
|
||||
{!showUsage || loadingUsage ? (
|
||||
<div />
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<ResetIcon></ResetIcon>}
|
||||
text={Locale.Settings.Usage.Check}
|
||||
onClick={() => checkUsage(true)}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
<SettingItem
|
||||
title={Locale.Settings.CompressThreshold.Title}
|
||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={500}
|
||||
max={4000}
|
||||
value={config.compressMessageLengthThreshold}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.compressMessageLengthThreshold =
|
||||
e.currentTarget.valueAsNumber)
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</SettingItem>
|
||||
{!accessStore.hideUserApiKey ? (
|
||||
<ListItem
|
||||
title={Locale.Settings.Endpoint.Title}
|
||||
subTitle={Locale.Settings.Endpoint.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={accessStore.openaiUrl}
|
||||
onChange={(e) =>
|
||||
accessStore.updateOpenAiUrl(e.currentTarget.value)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<SettingItem title={Locale.Settings.Model}>
|
||||
<select
|
||||
value={config.modelConfig.model}
|
||||
onChange={(e) => {
|
||||
updateConfig(
|
||||
(config) => (config.modelConfig.model = e.currentTarget.value)
|
||||
);
|
||||
}}
|
||||
>
|
||||
{ALL_MODELS.map((v) => (
|
||||
<option value={v.name} key={v.name} disabled={!v.available}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title={Locale.Settings.Temperature.Title}
|
||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||
<ListItem
|
||||
title={Locale.Settings.Prompt.Disable.Title}
|
||||
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
value={config.modelConfig.temperature.toFixed(1)}
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.modelConfig.temperature =
|
||||
e.currentTarget.valueAsNumber)
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title={Locale.Settings.MaxTokens.Title}
|
||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
max={4096}
|
||||
value={config.modelConfig.max_tokens}
|
||||
type="checkbox"
|
||||
checked={config.disablePromptHint}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.modelConfig.max_tokens =
|
||||
e.currentTarget.valueAsNumber)
|
||||
(config.disablePromptHint = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title={Locale.Settings.PresencePenlty.Title}
|
||||
subTitle={Locale.Settings.PresencePenlty.SubTitle}
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Prompt.List}
|
||||
subTitle={Locale.Settings.Prompt.ListCount(
|
||||
builtinCount,
|
||||
customCount,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
value={config.modelConfig.presence_penalty.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.5"
|
||||
onChange={(e) => {
|
||||
updateConfig(
|
||||
(config) =>
|
||||
(config.modelConfig.presence_penalty =
|
||||
e.currentTarget.valueAsNumber)
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</SettingItem>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text={Locale.Settings.Prompt.Edit}
|
||||
onClick={() => setShowPromptModal(true)}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ModelConfigList
|
||||
modelConfig={config.modelConfig}
|
||||
updateConfig={(updater) => {
|
||||
const modelConfig = { ...config.modelConfig };
|
||||
updater(modelConfig);
|
||||
config.update((config) => (config.modelConfig = modelConfig));
|
||||
}}
|
||||
/>
|
||||
</List>
|
||||
|
||||
{shouldShowPromptModal && (
|
||||
<UserPromptModal onClose={() => setShowPromptModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
205
app/components/sidebar.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import SettingsIcon from "../icons/settings.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import ChatGptIcon from "../icons/chatgpt.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import MaskIcon from "../icons/mask.svg";
|
||||
import PluginIcon from "../icons/plugin.svg";
|
||||
|
||||
import Locale from "../locales";
|
||||
|
||||
import { useAppConfig, useChatStore } from "../store";
|
||||
|
||||
import {
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
NARROW_SIDEBAR_WIDTH,
|
||||
Path,
|
||||
REPO_URL,
|
||||
} from "../constant";
|
||||
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useMobileScreen } from "../utils";
|
||||
import dynamic from "next/dynamic";
|
||||
import { showToast } from "./ui-lib";
|
||||
|
||||
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
function useHotKey() {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.altKey || e.ctrlKey) {
|
||||
const n = chatStore.sessions.length;
|
||||
const limit = (x: number) => (x + n) % n;
|
||||
const i = chatStore.currentSessionIndex;
|
||||
if (e.key === "ArrowUp") {
|
||||
chatStore.selectSession(limit(i - 1));
|
||||
} else if (e.key === "ArrowDown") {
|
||||
chatStore.selectSession(limit(i + 1));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
});
|
||||
}
|
||||
|
||||
function useDragSideBar() {
|
||||
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
||||
|
||||
const config = useAppConfig();
|
||||
const startX = useRef(0);
|
||||
const startDragWidth = useRef(config.sidebarWidth ?? 300);
|
||||
const lastUpdateTime = useRef(Date.now());
|
||||
|
||||
const handleMouseMove = useRef((e: MouseEvent) => {
|
||||
if (Date.now() < lastUpdateTime.current + 50) {
|
||||
return;
|
||||
}
|
||||
lastUpdateTime.current = Date.now();
|
||||
const d = e.clientX - startX.current;
|
||||
const nextWidth = limit(startDragWidth.current + d);
|
||||
config.update((config) => (config.sidebarWidth = nextWidth));
|
||||
});
|
||||
|
||||
const handleMouseUp = useRef(() => {
|
||||
startDragWidth.current = config.sidebarWidth ?? 300;
|
||||
window.removeEventListener("mousemove", handleMouseMove.current);
|
||||
window.removeEventListener("mouseup", handleMouseUp.current);
|
||||
});
|
||||
|
||||
const onDragMouseDown = (e: MouseEvent) => {
|
||||
startX.current = e.clientX;
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove.current);
|
||||
window.addEventListener("mouseup", handleMouseUp.current);
|
||||
};
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const shouldNarrow =
|
||||
!isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
|
||||
|
||||
useEffect(() => {
|
||||
const barWidth = shouldNarrow
|
||||
? NARROW_SIDEBAR_WIDTH
|
||||
: limit(config.sidebarWidth ?? 300);
|
||||
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
|
||||
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
|
||||
}, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
|
||||
|
||||
return {
|
||||
onDragMouseDown,
|
||||
shouldNarrow,
|
||||
};
|
||||
}
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// drag side bar
|
||||
const { onDragMouseDown, shouldNarrow } = useDragSideBar();
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
|
||||
useHotKey();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sidebar} ${props.className} ${
|
||||
shouldNarrow && styles["narrow-sidebar"]
|
||||
}`}
|
||||
>
|
||||
<div className={styles["sidebar-header"]}>
|
||||
<div className={styles["sidebar-title"]}>ChatGPT Next</div>
|
||||
<div className={styles["sidebar-sub-title"]}>
|
||||
Build your own AI assistant.
|
||||
</div>
|
||||
<div className={styles["sidebar-logo"] + " no-dark"}>
|
||||
<ChatGptIcon />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["sidebar-header-bar"]}>
|
||||
<IconButton
|
||||
icon={<MaskIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => navigate(Path.NewChat, { state: { fromHome: true } })}
|
||||
shadow
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PluginIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Plugin.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => showToast(Locale.WIP)}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles["sidebar-body"]}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
navigate(Path.Home);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatList narrow={shouldNarrow} />
|
||||
</div>
|
||||
|
||||
<div className={styles["sidebar-tail"]}>
|
||||
<div className={styles["sidebar-actions"]}>
|
||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
onClick={() => {
|
||||
if (confirm(Locale.Home.DeleteChat)) {
|
||||
chatStore.deleteSession(chatStore.currentSessionIndex);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<Link to={Path.Settings}>
|
||||
<IconButton icon={<SettingsIcon />} shadow />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<a href={REPO_URL} target="_blank">
|
||||
<IconButton icon={<GithubIcon />} shadow />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
||||
onClick={() => {
|
||||
if (config.dontShowMaskSplashScreen) {
|
||||
chatStore.newSession();
|
||||
navigate(Path.Chat);
|
||||
} else {
|
||||
navigate(Path.NewChat);
|
||||
}
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles["sidebar-drag"]}
|
||||
onMouseDown={(e) => onDragMouseDown(e as any)}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,3 +1,5 @@
|
||||
@import "../styles/animation.scss";
|
||||
|
||||
.card {
|
||||
background-color: var(--white);
|
||||
border-radius: 10px;
|
||||
@@ -7,6 +9,7 @@
|
||||
|
||||
.popover {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
@@ -24,18 +27,6 @@
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -44,6 +35,25 @@
|
||||
border-bottom: var(--border-in-light);
|
||||
padding: 10px 20px;
|
||||
animation: slide-in ease 0.6s;
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.list-icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.list-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.list-item-sub-title {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -62,7 +72,7 @@
|
||||
box-shadow: var(--card-shadow);
|
||||
background-color: var(--white);
|
||||
border-radius: 12px;
|
||||
width: 50vw;
|
||||
width: 60vw;
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
--modal-padding: 20px;
|
||||
@@ -98,6 +108,8 @@
|
||||
padding: var(--modal-padding);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: var(--border-in-light);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
@@ -112,6 +124,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.modal-container {
|
||||
width: 100vw;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
.modal-content {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.show {
|
||||
opacity: 1;
|
||||
transition: all ease 0.3s;
|
||||
@@ -131,30 +155,76 @@
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
bottom: 5vh;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
.toast-content {
|
||||
max-width: 80vw;
|
||||
word-break: break-all;
|
||||
font-size: 14px;
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--card-shadow);
|
||||
border: var(--border-in-light);
|
||||
color: var(--black);
|
||||
padding: 10px 30px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 50px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.modal-container {
|
||||
width: 90vw;
|
||||
.toast-action {
|
||||
padding-left: 20px;
|
||||
color: var(--primary);
|
||||
opacity: 0.8;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
.modal-content {
|
||||
max-height: 50vh;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
resize: none;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.select-with-icon {
|
||||
position: relative;
|
||||
max-width: fit-content;
|
||||
|
||||
.select-with-icon-select {
|
||||
height: 100%;
|
||||
border: var(--border-in-light);
|
||||
padding: 10px 25px 10px 10px;
|
||||
border-radius: 10px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.select-with-icon-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
@@ -1,7 +1,13 @@
|
||||
import styles from "./ui-lib.module.scss";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import EyeOffIcon from "../icons/eye-off.svg";
|
||||
import DownIcon from "../icons/down.svg";
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import React, { HTMLProps, useEffect, useState } from "react";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
export function Popover(props: {
|
||||
children: JSX.Element;
|
||||
@@ -28,15 +34,38 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItem(props: { children: JSX.Element[] }) {
|
||||
if (props.children.length > 2) {
|
||||
throw Error("Only Support Two Children");
|
||||
}
|
||||
|
||||
return <div className={styles["list-item"]}>{props.children}</div>;
|
||||
export function ListItem(props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
icon?: JSX.Element;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles["list-item"] + ` ${props.className || ""}`}>
|
||||
<div className={styles["list-header"]}>
|
||||
{props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
|
||||
<div className={styles["list-item-title"]}>
|
||||
<div>{props.title}</div>
|
||||
{props.subTitle && (
|
||||
<div className={styles["list-item-sub-title"]}>
|
||||
{props.subTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function List(props: { children: JSX.Element[] }) {
|
||||
export function List(props: {
|
||||
children:
|
||||
| Array<JSX.Element | null | undefined>
|
||||
| JSX.Element
|
||||
| null
|
||||
| undefined;
|
||||
}) {
|
||||
return <div className={styles.list}>{props.children}</div>;
|
||||
}
|
||||
|
||||
@@ -58,11 +87,26 @@ export function Loading() {
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
children?: JSX.Element;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
actions?: JSX.Element[];
|
||||
onClose?: () => void;
|
||||
}
|
||||
export function Modal(props: ModalProps) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
props.onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles["modal-container"]}>
|
||||
<div className={styles["modal-header"]}>
|
||||
@@ -109,17 +153,41 @@ export function showModal(props: ModalProps) {
|
||||
root.render(<Modal {...props} onClose={closeModal}></Modal>);
|
||||
}
|
||||
|
||||
export type ToastProps = { content: string };
|
||||
export type ToastProps = {
|
||||
content: string;
|
||||
action?: {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function Toast(props: ToastProps) {
|
||||
return (
|
||||
<div className={styles["toast-container"]}>
|
||||
<div className={styles["toast-content"]}>{props.content}</div>
|
||||
<div className={styles["toast-content"]}>
|
||||
<span>{props.content}</span>
|
||||
{props.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
props.action?.onClick?.();
|
||||
props.onClose?.();
|
||||
}}
|
||||
className={styles["toast-action"]}
|
||||
>
|
||||
{props.action.text}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function showToast(content: string, delay = 3000) {
|
||||
export function showToast(
|
||||
content: string,
|
||||
action?: ToastProps["action"],
|
||||
delay = 3000,
|
||||
) {
|
||||
const div = document.createElement("div");
|
||||
div.className = styles.show;
|
||||
document.body.appendChild(div);
|
||||
@@ -138,5 +206,59 @@ export function showToast(content: string, delay = 3000) {
|
||||
close();
|
||||
}, delay);
|
||||
|
||||
root.render(<Toast content={content} />);
|
||||
root.render(<Toast content={content} action={action} onClose={close} />);
|
||||
}
|
||||
|
||||
export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
|
||||
autoHeight?: boolean;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
export function Input(props: InputProps) {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={`${styles["input"]} ${props.className}`}
|
||||
></textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
function changeVisibility() {
|
||||
setVisible(!visible);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"password-input-container"}>
|
||||
<IconButton
|
||||
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
||||
onClick={changeVisibility}
|
||||
className={"password-eye"}
|
||||
/>
|
||||
<input
|
||||
{...props}
|
||||
type={visible ? "text" : "password"}
|
||||
className={"password-input"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select(
|
||||
props: React.DetailedHTMLProps<
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||
HTMLSelectElement
|
||||
>,
|
||||
) {
|
||||
const { className, children, ...otherProps } = props;
|
||||
return (
|
||||
<div className={`${styles["select-with-icon"]} ${className}`}>
|
||||
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
||||
{children}
|
||||
</select>
|
||||
<DownIcon className={styles["select-with-icon-icon"]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
27
app/config/build.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const getBuildConfig = () => {
|
||||
if (typeof process === "undefined") {
|
||||
throw Error(
|
||||
"[Server Config] you are importing a nodejs-only module outside of nodejs",
|
||||
);
|
||||
}
|
||||
|
||||
const COMMIT_ID: string = (() => {
|
||||
try {
|
||||
const childProcess = require("child_process");
|
||||
return childProcess
|
||||
.execSync('git log -1 --format="%at000" --date=unix')
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (e) {
|
||||
console.error("[Build Config] No git or not from git repo.");
|
||||
return "unknown";
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
commitId: COMMIT_ID,
|
||||
buildMode: process.env.BUILD_MODE ?? "standalone",
|
||||
};
|
||||
};
|
||||
|
||||
export type BuildConfig = ReturnType<typeof getBuildConfig>;
|
27
app/config/client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { BuildConfig, getBuildConfig } from "./build";
|
||||
|
||||
export function getClientConfig() {
|
||||
if (typeof document !== "undefined") {
|
||||
// client side
|
||||
return JSON.parse(queryMeta("config")) as BuildConfig;
|
||||
}
|
||||
|
||||
if (typeof process !== "undefined") {
|
||||
// server side
|
||||
return getBuildConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function queryMeta(key: string, defaultValue?: string): string {
|
||||
let ret: string;
|
||||
if (document) {
|
||||
const meta = document.head.querySelector(
|
||||
`meta[name='${key}']`,
|
||||
) as HTMLMetaElement;
|
||||
ret = meta?.content ?? "";
|
||||
} else {
|
||||
ret = defaultValue ?? "";
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
49
app/config/server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import md5 from "spark-md5";
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
OPENAI_API_KEY?: string;
|
||||
CODE?: string;
|
||||
BASE_URL?: string;
|
||||
PROXY_URL?: string;
|
||||
VERCEL?: string;
|
||||
HIDE_USER_API_KEY?: string; // disable user's api key input
|
||||
DISABLE_GPT4?: string; // allow user to use gpt-4 or not
|
||||
BUILD_MODE?: "standalone" | "export";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ACCESS_CODES = (function getAccessCodes(): Set<string> {
|
||||
const code = process.env.CODE;
|
||||
|
||||
try {
|
||||
const codes = (code?.split(",") ?? [])
|
||||
.filter((v) => !!v)
|
||||
.map((v) => md5.hash(v.trim()));
|
||||
return new Set(codes);
|
||||
} catch (e) {
|
||||
return new Set();
|
||||
}
|
||||
})();
|
||||
|
||||
export const getServerSideConfig = () => {
|
||||
if (typeof process === "undefined") {
|
||||
throw Error(
|
||||
"[Server Config] you are importing a nodejs-only module outside of nodejs",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
code: process.env.CODE,
|
||||
codes: ACCESS_CODES,
|
||||
needCode: ACCESS_CODES.size > 0,
|
||||
baseUrl: process.env.BASE_URL,
|
||||
proxyUrl: process.env.PROXY_URL,
|
||||
isVercel: !!process.env.VERCEL,
|
||||
hideUserApiKey: !!process.env.HIDE_USER_API_KEY,
|
||||
enableGPT4: !process.env.DISABLE_GPT4,
|
||||
};
|
||||
};
|
@@ -1,5 +1,54 @@
|
||||
export const OWNER = "Yidadaa";
|
||||
export const REPO = "ChatGPT-Next-Web";
|
||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
||||
export const UPDATE_URL = `${REPO_URL}#%E4%BF%9D%E6%8C%81%E6%9B%B4%E6%96%B0-keep-updated`;
|
||||
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
||||
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
|
||||
export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
|
||||
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
|
||||
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
|
||||
export const DEFAULT_API_HOST = "https://chatgpt.nextweb.fun/api/proxy";
|
||||
|
||||
export enum Path {
|
||||
Home = "/",
|
||||
Chat = "/chat",
|
||||
Settings = "/settings",
|
||||
NewChat = "/new-chat",
|
||||
Masks = "/masks",
|
||||
Auth = "/auth",
|
||||
}
|
||||
|
||||
export enum SlotID {
|
||||
AppBody = "app-body",
|
||||
}
|
||||
|
||||
export enum FileName {
|
||||
Masks = "masks.json",
|
||||
Prompts = "prompts.json",
|
||||
}
|
||||
|
||||
export enum StoreKey {
|
||||
Chat = "chat-next-web-store",
|
||||
Access = "access-control",
|
||||
Config = "app-config",
|
||||
Mask = "mask-store",
|
||||
Prompt = "prompt-store",
|
||||
Update = "chat-update",
|
||||
}
|
||||
|
||||
export const MAX_SIDEBAR_WIDTH = 500;
|
||||
export const MIN_SIDEBAR_WIDTH = 230;
|
||||
export const NARROW_SIDEBAR_WIDTH = 100;
|
||||
|
||||
export const ACCESS_CODE_PREFIX = "ak-";
|
||||
|
||||
export const LAST_INPUT_KEY = "last-input";
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = 60000;
|
||||
|
||||
export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";
|
||||
|
||||
export const OpenaiPath = {
|
||||
ChatPath: "v1/chat/completions",
|
||||
UsagePath: "dashboard/billing/usage",
|
||||
SubsPath: "dashboard/billing/subscription",
|
||||
};
|
||||
|
11
app/global.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
declare module "*.jpg";
|
||||
declare module "*.png";
|
||||
declare module "*.woff2";
|
||||
declare module "*.woff";
|
||||
declare module "*.ttf";
|
||||
declare module "*.scss" {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.svg";
|
@@ -1,23 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
|
||||
height="16" viewBox="0 0 16 16" fill="none">
|
||||
<defs>
|
||||
<rect id="path_0" x="0" y="0" width="16" height="16" />
|
||||
</defs>
|
||||
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
|
||||
<mask id="bg-mask-0" fill="white">
|
||||
<use xlink:href="#path_0"></use>
|
||||
</mask>
|
||||
<g mask="url(#bg-mask-0)">
|
||||
<path id="路径 1"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)"
|
||||
d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z " />
|
||||
<path id="路径 2"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(8 5.333333333333333) rotate(0 0 2.6666666666666665)" d="M0,0L0,5.33 " />
|
||||
<path id="路径 3"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(5.333333333333333 8) rotate(0 2.6666666666666665 0)" d="M0,0L5.33,0 " />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,5.33" transform="translate(8 5.333333333333333) rotate(0 0 2.6666666666666665)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L5.33,0" transform="translate(5.333333333333333 8) rotate(0 2.6666666666666665 0)"/></g></g></svg>
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
1
app/icons/auto.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="分组 1" style="stroke:#333333; stroke-width:1; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.333333333333333) rotate(0 2.333750009536743 2.6666666666666665)" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.533316666666666 2.6666666666666665)" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67 " /><path id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 9.333333333333332) rotate(0 6.533316666666666 2.6666666666666665)" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67 " /></g></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
app/icons/black-bot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="30" height="30" fill="none" viewBox="0 0 30 30"><defs><rect id="path_0" width="30" height="30" x="0" y="0"/><rect id="path_1" width="20.455" height="20.455" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 14.999999999999998 14.999999999999998)"><rect width="30" height="30" x="0" y="0" fill="#E7F8FF" opacity="1" rx="10" transform="translate(0 0) rotate(0 14.999999999999998 14.999999999999998)"/><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><g opacity="1" transform="translate(4.772727272727272 4.772727272727273) rotate(0 10.227272727272725 10.227272727272725)"><mask id="bg-mask-1" fill="#fff"><use xlink:href="#path_1"/></mask><g mask="url(#bg-mask-1)"><path id="分组 1" fill-rule="evenodd" style="fill:#333" d="M19.11 8.37L19.11 8.37C19.28 7.85 19.37 7.31 19.37 6.76C19.37 5.86 19.13 4.97 18.66 4.19C17.73 2.59 16 1.6 14.13 1.6C13.76 1.6 13.4 1.64 13.04 1.71C12.06 0.62 10.65 0 9.17 0L9.14 0L9.13 0C6.86 0 4.86 1.44 4.16 3.57C2.7 3.86 1.44 4.76 0.71 6.04C0.24 6.83 0 7.72 0 8.63C0 9.9 0.48 11.14 1.35 12.08C1.17 12.6 1.08 13.15 1.08 13.69C1.08 14.6 1.33 15.49 1.79 16.27C2.92 18.21 5.2 19.21 7.42 18.74C8.4 19.83 9.8 20.45 11.28 20.45L11.31 20.45L11.33 20.45C13.59 20.45 15.6 19.01 16.3 16.88C17.76 16.59 19.01 15.69 19.75 14.41C20.21 13.63 20.45 12.74 20.45 11.83C20.45 10.55 19.97 9.32 19.11 8.37Z M8.94734 18.1579C8.90734 18.1879 8.86734 18.2079 8.82734 18.2279C9.52734 18.8079 10.3973 19.1179 11.3073 19.1179L11.3173 19.1179C13.4573 19.1179 15.1973 17.3979 15.1973 15.2879L15.1973 10.5279C15.1973 10.5079 15.1773 10.4879 15.1573 10.4779L13.4173 9.48792L13.4173 15.2379C13.4173 15.4679 13.2873 15.6879 13.0773 15.8079L8.94734 18.1579Z M8.27654 17.0048L12.4465 14.6248C12.4665 14.6148 12.4765 14.5948 12.4765 14.5748L12.4765 14.5748L12.4765 12.5848L7.43654 15.4548C7.22654 15.5748 6.96654 15.5748 6.75654 15.4548L2.62654 13.1048C2.58654 13.0848 2.53654 13.0448 2.50654 13.0348C2.46654 13.2448 2.44654 13.4648 2.44654 13.6848C2.44654 14.3548 2.62654 15.0148 2.96654 15.6048L2.96654 15.5948C3.66654 16.7848 4.94654 17.5148 6.33654 17.5148C7.01654 17.5148 7.68654 17.3348 8.27654 17.0048Z M3.90324 5.16818C3.90324 5.12818 3.90324 5.06818 3.90324 5.02818C3.05324 5.33818 2.33324 5.92818 1.88324 6.70818L1.88324 6.70818C1.54324 7.28818 1.36324 7.94818 1.36324 8.61818C1.36324 9.98818 2.10324 11.2582 3.30324 11.9482L7.47324 14.3182C7.49324 14.3282 7.51324 14.3282 7.53324 14.3182L9.28324 13.3182L4.24324 10.4482C4.03324 10.3382 3.90324 10.1182 3.90324 9.87818L3.90324 9.87818L3.90324 5.16818Z M17.1561 8.50521L12.9761 6.1252C12.9561 6.1252 12.9361 6.1252 12.9161 6.1352L11.1761 7.1252L16.2161 9.9952C16.4261 10.1152 16.5561 10.3352 16.5561 10.5752C16.5561 10.5752 16.5561 10.5752 16.5561 10.5752L16.5561 15.4252C18.0761 14.8652 19.0961 13.4352 19.0961 11.8252C19.0961 10.4552 18.3561 9.1952 17.1561 8.50521Z M8.01418 5.82927C7.99418 5.83927 7.98418 5.85927 7.98418 5.87927L7.98418 5.87927L7.98418 7.86927L13.0242 4.99927C13.1242 4.93927 13.2442 4.90927 13.3642 4.90927C13.4842 4.90927 13.5942 4.93927 13.7042 4.99927L17.8342 7.34927C17.8742 7.36927 17.9142 7.39927 17.9542 7.41927L17.9542 7.41927C17.9842 7.20927 18.0042 6.98927 18.0042 6.76927C18.0042 4.65927 16.2642 2.93927 14.1242 2.93927C13.4442 2.93927 12.7742 3.11927 12.1842 3.44927L8.01418 5.82927Z M9.14676 1.33731C6.99676 1.33731 5.25676 3.05731 5.25676 5.16731L5.25676 9.92731C5.25676 9.94731 5.27676 9.95731 5.28676 9.96731L7.03676 10.9673L7.03676 5.22731L7.03676 5.21731C7.03676 4.98731 7.16676 4.76731 7.37676 4.64731L11.5068 2.29731C11.5468 2.26731 11.5968 2.23731 11.6268 2.22731C10.9268 1.64731 10.0468 1.33731 9.14676 1.33731Z M7.98345 11.5093L10.2235 12.7793L12.4735 11.5093L12.4735 8.9493L10.2235 7.6693L7.98345 8.9493L7.98345 11.5093Z" opacity="1" transform="translate(0 0) rotate(0 10.227272727272725 10.227272727272725)"/></g></g></g></g></svg>
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/icons/bot.png
Normal file
After Width: | Height: | Size: 5.0 KiB |
1
app/icons/bottom.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8,0L4,4L0,0" transform="translate(4 4) rotate(0 4 2)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8,0L4,4L0,0" transform="translate(4 8) rotate(0 4 2)"/></g></g></svg>
|
After Width: | Height: | Size: 706 B |
@@ -1,25 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
|
||||
height="16" viewBox="0 0 16 16" fill="none">
|
||||
<defs>
|
||||
<rect id="path_0" x="0" y="0" width="16" height="16" />
|
||||
</defs>
|
||||
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
|
||||
<mask id="bg-mask-0" fill="white">
|
||||
<use xlink:href="#path_0"></use>
|
||||
</mask>
|
||||
<g mask="url(#bg-mask-0)">
|
||||
<path id="路径 1"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(1.3333323286384866 1.3334133333333331) rotate(0 6.66666716901409 6.66666)"
|
||||
d="M5.01,13.33C4.69,12.27 4.19,11.47 3.53,10.95C2.55,10.17 0.97,10.65 0.39,9.84C-0.19,9.04 0.8,7.55 1.15,6.67C1.49,5.79 -0.18,5.48 0.02,5.23C0.15,5.07 0.99,4.59 2.55,3.79C3,1.26 4.63,0 7.47,0C11.71,0 13.33,3.6 13.33,5.89C13.33,8.18 11.37,10.65 8.58,11.18C8.33,11.55 8.69,12.26 9.66,13.33 " />
|
||||
<path id="路径 2"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(6.374029736345404 3.9567867125879106) rotate(0 2.8215982497276006 2.4327734241007346)"
|
||||
d="M2.1,3.33C1.91,4.42 2.14,4.93 2.79,4.86C3.44,4.79 3.84,4.52 3.97,4.05C4.99,4.33 5.54,4.09 5.63,3.33C5.75,2.18 5.13,1.26 4.88,1.26C4.63,1.26 3.97,1.23 3.97,0.88C3.97,0.52 3.2,0.33 2.5,0.33C1.81,0.33 2.23,-0.14 1.27,0.04C0.64,0.17 0.26,0.44 0.13,0.88C-0.09,1.72 -0.03,2.31 0.32,2.66C0.67,3 1.26,3.22 2.1,3.33Z " />
|
||||
<path id="路径 3"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(8.193033333333332 8.500066666666665) rotate(0 0.9868499999999998 1.1846833333333333)"
|
||||
d="M1.97,0C1.63,0.21 1.17,0.56 0.97,0.83C0.48,1.52 0.09,1.93 0,2.37 " />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M5.01,13.33C4.69,12.27 4.19,11.47 3.53,10.95C2.55,10.17 0.97,10.65 0.39,9.84C-0.19,9.04 0.8,7.55 1.15,6.67C1.49,5.79 -0.18,5.48 0.02,5.23C0.15,5.07 0.99,4.59 2.55,3.79C3,1.26 4.63,0 7.47,0C11.71,0 13.33,3.6 13.33,5.89C13.33,8.18 11.37,10.65 8.58,11.18C8.33,11.55 8.69,12.26 9.66,13.33" transform="translate(1.3333323286384866 1.3334133333333331) rotate(0 6.66666716901409 6.66666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M2.1,3.33C1.91,4.42 2.14,4.93 2.79,4.86C3.44,4.79 3.84,4.52 3.97,4.05C4.99,4.33 5.54,4.09 5.63,3.33C5.75,2.18 5.13,1.26 4.88,1.26C4.63,1.26 3.97,1.23 3.97,0.88C3.97,0.52 3.2,0.33 2.5,0.33C1.81,0.33 2.23,-0.14 1.27,0.04C0.64,0.17 0.26,0.44 0.13,0.88C-0.09,1.72 -0.03,2.31 0.32,2.66C0.67,3 1.26,3.22 2.1,3.33Z" transform="translate(6.374029736345404 3.9567867125879106) rotate(0 2.8215982497276006 2.4327734241007346)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1.97,0C1.63,0.21 1.17,0.56 0.97,0.83C0.48,1.52 0.09,1.93 0,2.37" transform="translate(8.193033333333332 8.500066666666665) rotate(0 0.9868499999999998 1.1846833333333333)"/></g></g></svg>
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.6 KiB |
1
app/icons/break.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><g opacity="1" transform="translate(0 0) rotate(0)"><g opacity="1" transform="translate(1.0001220703125 2) rotate(0)"><path id="路径 1" style="fill:#333333; opacity:1;" d="M13.275,-0.27515c0.261,0.26101 0.3915,0.57606 0.3915,0.94515v10.66c0,0.36907 -0.1305,0.68413 -0.3915,0.9452c-0.261,0.261 -0.57603,0.3915 -0.9451,0.3915h-10.66002c-0.36909,0 -0.68415,-0.1305 -0.94516,-0.3915c-0.26101,-0.26107 -0.39151,-0.57613 -0.39151,-0.9452v-10.66c0,-0.3691 0.1305,-0.68415 0.39151,-0.94515c0.26101,-0.26101 0.57606,-0.39151 0.94516,-0.39151h10.66002c0.36907,0 0.6841,0.1305 0.9451,0.39151zM1.66655,11.33c0,0.0022 0.00111,0.0033 0.00333,0.0033h10.66002c0.0022,0 0.0033,-0.0011 0.0033,-0.0033v-10.66c0,-0.00222 -0.0011,-0.00333 -0.0033,-0.00333l-10.66002,0c-0.00222,0 -0.00333,0.00111 -0.00333,0.00333z"></path><path id="路径 2" style="fill:#333333; opacity:1;" d="M9.76327,7.50715c-0.02999,0.02563 -0.06201,0.04842 -0.09604,0.06837c-0.03403,0.01995 -0.06956,0.03674 -0.10658,0.05039c-0.03702,0.01364 -0.07495,0.02391 -0.11379,0.03082c-0.03885,0.00691 -0.07799,0.01035 -0.11744,0.0103c-0.03945,-0.00004 -0.07859,-0.00356 -0.11742,-0.01055c-0.03883,-0.00699 -0.07674,-0.01734 -0.11373,-0.03106c-0.03699,-0.01372 -0.07248,-0.03059 -0.10647,-0.05061c-0.03399,-0.02002 -0.06596,-0.04288 -0.0959,-0.06858l-1.89578,-1.62728l-1.89578,1.62728c-0.02993,0.0257 -0.0619,0.04856 -0.09589,0.06858c-0.03399,0.02002 -0.06949,0.03689 -0.10648,0.05061c-0.03699,0.01372 -0.07489,0.02407 -0.11372,0.03106c-0.03883,0.00699 -0.07797,0.01051 -0.11742,0.01055c-0.03945,0.00005 -0.0786,-0.00339 -0.11744,-0.0103c-0.03885,-0.00691 -0.07678,-0.01718 -0.11379,-0.03082c-0.03702,-0.01365 -0.07255,-0.03044 -0.10658,-0.05039c-0.03404,-0.01995 -0.06605,-0.04274 -0.09604,-0.06837l-1.90593,-1.629l-1.89671,1.62808c-0.06708,0.05758 -0.14263,0.10013 -0.22664,0.12766c-0.08401,0.02753 -0.17009,0.03793 -0.25824,0.03121c-0.08815,-0.00671 -0.17166,-0.03004 -0.25053,-0.06998c-0.07887,-0.03994 -0.14709,-0.09345 -0.20467,-0.16054c-0.02851,-0.03321 -0.05351,-0.06889 -0.07499,-0.10703c-0.02148,-0.03814 -0.03904,-0.07801 -0.05267,-0.11961c-0.01363,-0.04159 -0.02307,-0.08412 -0.02832,-0.12758c-0.00525,-0.04346 -0.00622,-0.08701 -0.00289,-0.13066c0.00333,-0.04365 0.01088,-0.08655 0.02266,-0.12871c0.01178,-0.04216 0.02755,-0.08277 0.04733,-0.12182c0.01978,-0.03905 0.04317,-0.07579 0.07019,-0.11024c0.02701,-0.03444 0.05713,-0.06592 0.09035,-0.09443l2.32999,-2c0.02994,-0.02569 0.06191,-0.04855 0.0959,-0.06857c0.03399,-0.02003 0.06948,-0.0369 0.10647,-0.05062c0.03699,-0.01372 0.0749,-0.02407 0.11373,-0.03106c0.03883,-0.00699 0.07797,-0.01051 0.11742,-0.01055c0.03945,-0.00004 0.0786,0.00339 0.11744,0.0103c0.03884,0.00691 0.07677,0.01718 0.11379,0.03082c0.03702,0.01365 0.07255,0.03044 0.10658,0.05039c0.03404,0.01995 0.06605,0.04274 0.09604,0.06837l1.90592,1.629l1.89671,-1.62808c0.02998,-0.02573 0.062,-0.04862 0.09605,-0.06866c0.03405,-0.02005 0.0696,-0.03693 0.10665,-0.05065c0.03705,-0.01372 0.07503,-0.02407 0.11392,-0.03104c0.03889,-0.00697 0.07809,-0.01045 0.1176,-0.01045c0.03951,0 0.07872,0.00348 0.11761,0.01045c0.03889,0.00697 0.07686,0.01732 0.11391,0.03104c0.03705,0.01372 0.0726,0.0306 0.10665,0.05065c0.03405,0.02004 0.06607,0.04293 0.09605,0.06866l1.89671,1.62808l1.90595,-1.629c0.03,-0.02563 0.062,-0.04842 0.096,-0.06837c0.03407,-0.01995 0.0696,-0.03674 0.1066,-0.05038c0.037,-0.01365 0.07493,-0.02392 0.1138,-0.03083c0.03887,-0.00691 0.078,-0.01034 0.1174,-0.0103c0.03947,0.00004 0.0786,0.00356 0.1174,0.01055c0.03887,0.00699 0.0768,0.01734 0.1138,0.03106c0.037,0.01372 0.07247,0.03059 0.1064,0.05062c0.034,0.02002 0.06597,0.04288 0.0959,0.06857l2.33,2c0.06713,0.05758 0.12067,0.12581 0.1606,0.20468c0.03993,0.07887 0.06327,0.16237 0.07,0.25052c0.00667,0.08815 -0.00377,0.17424 -0.0313,0.25825c-0.02747,0.08401 -0.07,0.15955 -0.1276,0.22663c-0.02853,0.03322 -0.06,0.06334 -0.0944,0.09035c-0.03447,0.02701 -0.07123,0.05041 -0.1103,0.07019c-0.03907,0.01977 -0.07967,0.03555 -0.1218,0.04733c-0.04213,0.01177 -0.08503,0.01932 -0.1287,0.02265c-0.04367,0.00333 -0.08723,0.00236 -0.1307,-0.00289c-0.04347,-0.00525 -0.086,-0.01469 -0.1276,-0.02832c-0.0416,-0.01363 -0.08147,-0.03118 -0.1196,-0.05267c-0.03813,-0.02148 -0.0738,-0.04648 -0.107,-0.07499l-1.8967,-1.62808z"></path></g><g opacity="1" transform="translate(0 0) rotate(0)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ></g></g></g><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs></svg>
|
After Width: | Height: | Size: 4.5 KiB |
1
app/icons/chat-settings.svg
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
app/icons/chatgpt.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4.333333333333333 1.6666666666666665) rotate(0 5 5)" d="M0,2.48L0,0.94C0,0.42 0.42,0 0.94,0L9.06,0C9.58,0 10,0.42 10,0.94L10,9.06C10,9.58 9.58,10 9.06,10L7.51,10 " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.6666666666666665 4.333333333333333) rotate(0 5 5)" d="M0.94,0C0.42,0 0,0.42 0,0.94L0,9.06C0,9.58 0.42,10 0.94,10L9.06,10C9.58,10 10,9.58 10,9.06L10,0.94C10,0.42 9.58,0 9.06,0L0.94,0Z " /></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,2.48L0,0.94C0,0.42 0.42,0 0.94,0L9.06,0C9.58,0 10,0.42 10,0.94L10,9.06C10,9.58 9.58,10 9.06,10L7.51,10" transform="translate(4.333333333333333 1.6666666666666665) rotate(0 5 5)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0.94,0C0.42,0 0,0.42 0,0.94L0,9.06C0,9.58 0.42,10 0.94,10L9.06,10C9.58,10 10,9.58 10,9.06L10,0.94C10,0.42 9.58,0 9.06,0L0.94,0Z" transform="translate(1.6666666666666665 4.333333333333333) rotate(0 5 5)"/></g></g></svg>
|
Before Width: | Height: | Size: 1010 B After Width: | Height: | Size: 981 B |
1
app/icons/dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)" d="M6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67C13.33,6.2 13.29,5.75 13.2,5.32C12.72,7.14 11.06,8.48 9.09,8.48C6.75,8.48 4.85,6.59 4.85,4.24C4.85,2.27 6.19,0.61 8.02,0.14C7.58,0.05 7.13,0 6.67,0Z " /></g></g></svg>
|
After Width: | Height: | Size: 852 B |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
1
app/icons/down.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(-90 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4,8L0,4L4,0" transform="translate(6.333333333333333 4) rotate(0 2 4)"/></g></g></svg>
|
After Width: | Height: | Size: 555 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2 2) rotate(0 6 6)" d="M1,12L11,12C11.55,12 12,11.55 12,11L12,1C12,0.45 11.55,0 11,0L1,0C0.45,0 0,0.45 0,1L0,11C0,11.55 0.45,12 1,12Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 10.333333333333332) rotate(0 6.666666666666666 0.6666666666666666)" d="M0,0L3.67,0L4.33,1.33L9,1.33L9.67,0L13.33,0 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(14 8.666666666666666) rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /><path id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6 7.333333333333333) rotate(0 2 1)" d="M0,0L2,2L4,0 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 4) rotate(0 0 2.6666666666666665)" d="M0,5.33L0,0 " /><path id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2 8.666666666666666) rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1,12L11,12C11.55,12 12,11.55 12,11L12,1C12,0.45 11.55,0 11,0L1,0C0.45,0 0,0.45 0,1L0,11C0,11.55 0.45,12 1,12Z" transform="translate(2 2) rotate(0 6 6)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3.67,0L4.33,1.33L9,1.33L9.67,0L13.33,0" transform="translate(1.3333333333333333 10.333333333333332) rotate(0 6.666666666666666 0.6666666666666666)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.33L0,0" transform="translate(14 8.666666666666666) rotate(0 0 1.6666666666666665)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L2,2L4,0" transform="translate(6 7.333333333333333) rotate(0 2 1)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,5.33L0,0" transform="translate(8 4) rotate(0 0 2.6666666666666665)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.33L0,0" transform="translate(2 8.666666666666666) rotate(0 0 1.6666666666666665)"/></g></g></svg>
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
1
app/icons/edit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(10.5 11) rotate(0 1.4166666666666665 1.8333333333333333)" d="M2.83,0L2.83,3C2.83,3.37 2.53,3.67 2.17,3.67L0,3.67 " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2.6666666666666665 1.3333333333333333) rotate(0 5.333333333333333 6.666666666666666)" d="M10.67,4L10.67,0.67C10.67,0.3 10.37,0 10,0L0.67,0C0.3,0 0,0.3 0,0.67L0,12.67C0,13.03 0.3,13.33 0.67,13.33L2.67,13.33 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 5.333333333333333) rotate(0 2.333333333333333 0)" d="M0,0L4.67,0 " /><path id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(7.666666666666666 7.666666666666666) rotate(0 2.833333333333333 3.5)" d="M0,7L5.67,0 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 8) rotate(0 1.3333333333333333 0)" d="M0,0L2.67,0 " /></g></g></svg>
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.2400716519614834 2.3333321805983163) rotate(0 6.785117896431597 4.552683909700841)" d="M12.27,9.11C13.36,8.34 13.83,6.94 13.43,5.67C13.02,4.39 11.78,3.69 10.44,3.69L9.67,3.69C9.16,1.72 7.5,0.27 5.47,0.03C3.45,-0.2 1.5,0.84 0.56,2.64C-0.38,4.45 -0.11,6.64 1.23,8.17 " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 7.666666666666666) rotate(0 0.00140000000000029 3)" d="M0,6L0,0 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.8786 11.5454) rotate(0 2.1213333333333333 1.0606666666666662)" d="M4.24,0L2.12,2.12L0,0 " /></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M12.27,9.11C13.36,8.34 13.83,6.94 13.43,5.67C13.02,4.39 11.78,3.69 10.44,3.69L9.67,3.69C9.16,1.72 7.5,0.27 5.47,0.03C3.45,-0.2 1.5,0.84 0.56,2.64C-0.38,4.45 -0.11,6.64 1.23,8.17" transform="translate(1.2400716519614834 2.3333321805983163) rotate(0 6.785117896431597 4.552683909700841)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,6L0,0" transform="translate(8 7.666666666666666) rotate(0 0.00140000000000029 3)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4.24,0L2.12,2.12L0,0" transform="translate(5.8786 11.5454) rotate(0 2.1213333333333333 1.0606666666666662)"/></g></g></svg>
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
4
app/icons/eye-off.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7071 5.70711C20.0976 5.31658 20.0976 4.68342 19.7071 4.29289C19.3166 3.90237 18.6834 3.90237 18.2929 4.29289L14.032 8.55382C13.4365 8.20193 12.7418 8 12 8C9.79086 8 8 9.79086 8 12C8 12.7418 8.20193 13.4365 8.55382 14.032L4.29289 18.2929C3.90237 18.6834 3.90237 19.3166 4.29289 19.7071C4.68342 20.0976 5.31658 20.0976 5.70711 19.7071L9.96803 15.4462C10.5635 15.7981 11.2582 16 12 16C14.2091 16 16 14.2091 16 12C16 11.2582 15.7981 10.5635 15.4462 9.96803L19.7071 5.70711ZM12.518 10.0677C12.3528 10.0236 12.1792 10 12 10C10.8954 10 10 10.8954 10 12C10 12.1792 10.0236 12.3528 10.0677 12.518L12.518 10.0677ZM11.482 13.9323L13.9323 11.482C13.9764 11.6472 14 11.8208 14 12C14 13.1046 13.1046 14 12 14C11.8208 14 11.6472 13.9764 11.482 13.9323ZM15.7651 4.8207C14.6287 4.32049 13.3675 4 12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C1.92276 13.7326 2.86706 15.0637 4.21194 16.3739L5.62626 14.9596C4.4555 13.8229 3.61144 12.6531 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C12.7719 6 13.5135 6.13385 14.2193 6.36658L15.7651 4.8207ZM12 18C11.2282 18 10.4866 17.8661 9.78083 17.6334L8.23496 19.1793C9.37136 19.6795 10.6326 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C22.0773 10.2674 21.133 8.93627 19.7881 7.62611L18.3738 9.04043C19.5446 10.1771 20.3887 11.3469 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18Z" fill="#000000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
4
app/icons/eye.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.30147 15.5771C4.77832 14.2684 3.6904 12.7726 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C14.1843 6 16.1261 7.07185 17.6986 8.42294C19.2218 9.73158 20.3097 11.2274 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18C9.81574 18 7.87402 16.9282 6.30147 15.5771ZM12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C2.00757 13.8624 3.23268 15.5772 4.99812 17.0941C6.75717 18.6054 9.14754 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C21.9925 10.1376 20.7674 8.42276 19.002 6.90595C17.2429 5.39462 14.8525 4 12 4ZM10 12C10 10.8954 10.8955 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8955 14 10 13.1046 10 12ZM12 8C9.7909 8 8.00004 9.79086 8.00004 12C8.00004 14.2091 9.7909 16 12 16C14.2092 16 16 14.2091 16 12C16 9.79086 14.2092 8 12 8Z" fill="#000000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,29 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
|
||||
height="16" viewBox="0 0 16 16" fill="none">
|
||||
<defs>
|
||||
<rect id="path_0" x="0" y="0" width="16" height="16" />
|
||||
</defs>
|
||||
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
|
||||
<mask id="bg-mask-0" fill="white">
|
||||
<use xlink:href="#path_0"></use>
|
||||
</mask>
|
||||
<g mask="url(#bg-mask-0)">
|
||||
<path id="路径 1"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(2.6666666666666665 1.644694921083138) rotate(0 5.333333333333333 4.287969206125098)"
|
||||
d="M7.11,8.51C7.92,8.35 8.64,8.06 9.21,7.64C10.17,6.91 10.67,5.79 10.67,4.69C10.67,3.91 10.37,3.19 9.86,2.58C9.58,2.24 10.41,-0.31 9.67,0.03C8.94,0.37 7.86,1.13 7.29,0.97C6.68,0.79 6.02,0.69 5.33,0.69C4.73,0.69 4.16,0.76 3.62,0.9C2.83,1.1 2.09,0.36 1.33,0.03C0.58,-0.29 0.99,2.34 0.77,2.62C0.28,3.22 0,3.93 0,4.69C0,5.79 0.6,6.91 1.56,7.64C2.21,8.12 3.01,8.42 3.91,8.58 " />
|
||||
<path id="路径 2"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(6.000666666666666 10.220633333333332) rotate(0 0.2896166666666667 2.058116666666666)"
|
||||
d="M0.58,0C0.19,0.43 0,0.83 0,1.21C0,1.59 0,2.56 0,4.12 " />
|
||||
<path id="路径 3"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(9.781533333333332 10.158866666666666) rotate(0 0.2744333333333332 2.0890166666666663)"
|
||||
d="M0,0C0.37,0.48 0.55,0.91 0.55,1.29C0.55,1.68 0.55,2.64 0.55,4.18 " />
|
||||
<path id="路径 4"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(2 10.405166666666666) rotate(0 2.0004 1.050416666666667)"
|
||||
d="M0,0C0.3,0.04 0.52,0.17 0.67,0.41C0.88,0.77 1.69,2.1 2.61,2.1C3.22,2.1 3.68,2.1 4,2.1 " />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M7.11,8.51C7.92,8.35 8.64,8.06 9.21,7.64C10.17,6.91 10.67,5.79 10.67,4.69C10.67,3.91 10.37,3.19 9.86,2.58C9.58,2.24 10.41,-0.31 9.67,0.03C8.94,0.37 7.86,1.13 7.29,0.97C6.68,0.79 6.02,0.69 5.33,0.69C4.73,0.69 4.16,0.76 3.62,0.9C2.83,1.1 2.09,0.36 1.33,0.03C0.58,-0.29 0.99,2.34 0.77,2.62C0.28,3.22 0,3.93 0,4.69C0,5.79 0.6,6.91 1.56,7.64C2.21,8.12 3.01,8.42 3.91,8.58" transform="translate(2.6666666666666665 1.644694921083138) rotate(0 5.333333333333333 4.287969206125098)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0.58,0C0.19,0.43 0,0.83 0,1.21C0,1.59 0,2.56 0,4.12" transform="translate(6.000666666666666 10.220633333333332) rotate(0 0.2896166666666667 2.058116666666666)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0C0.37,0.48 0.55,0.91 0.55,1.29C0.55,1.68 0.55,2.64 0.55,4.18" transform="translate(9.781533333333332 10.158866666666666) rotate(0 0.2744333333333332 2.0890166666666663)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0C0.3,0.04 0.52,0.17 0.67,0.41C0.88,0.77 1.69,2.1 2.61,2.1C3.22,2.1 3.68,2.1 4,2.1" transform="translate(2 10.405166666666666) rotate(0 2.0004 1.050416666666667)"/></g></g></svg>
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.8 KiB |
1
app/icons/left.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4,8L0,4L4,0" transform="translate(6.333333333333333 4) rotate(0 2 4)"/></g></g></svg>
|
After Width: | Height: | Size: 553 B |
1
app/icons/light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(3.6666666666666665 3.6666666666666665) rotate(0 4.333333333333333 4.333333333333333)" d="M8.67,4.33C8.67,1.94 6.73,0 4.33,0C1.94,0 0,1.94 0,4.33C0,6.73 1.94,8.67 4.33,8.67C6.73,8.67 8.67,6.73 8.67,4.33Z " /><path id="路径 2" fill-rule="evenodd" style="fill:#333333" transform="translate(7.166666666666666 0.3333333333333333) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /><path id="路径 3" fill-rule="evenodd" style="fill:#333333" transform="translate(12 2.333333333333333) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /><path id="路径 4" fill-rule="evenodd" style="fill:#333333" transform="translate(14 7.166666666666666) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /><path id="路径 5" fill-rule="evenodd" style="fill:#333333" transform="translate(12 12) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /><path id="路径 6" fill-rule="evenodd" style="fill:#333333" transform="translate(7.166666666666666 14) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /><path id="路径 7" fill-rule="evenodd" style="fill:#333333" transform="translate(2.333333333333333 12) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /><path id="路径 8" fill-rule="evenodd" style="fill:#333333" transform="translate(0.3333333333333333 7.166666666666666) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /><path id="路径 9" fill-rule="evenodd" style="fill:#333333" transform="translate(2.333333333333333 2.333333333333333) rotate(0 0.8333333333333333 0.8333333333333333)" opacity="1" d="M1.67,0.83C1.67,0.37 1.29,0 0.83,0C0.37,0 0,0.37 0,0.83C0,1.29 0.37,1.67 0.83,1.67C1.29,1.67 1.67,1.29 1.67,0.83Z " /></g></g></svg>
|
After Width: | Height: | Size: 3.0 KiB |
1
app/icons/lightning.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><g opacity="1" transform="translate(0 0) rotate(0)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="fill:#333333; opacity:1;" d="M2.24783,8.85208c-0.03406,-0.0275 -0.06511,-0.05806 -0.09315,-0.09167c-0.02804,-0.03362 -0.05253,-0.06965 -0.07347,-0.10809c-0.02095,-0.03843 -0.03794,-0.07854 -0.05098,-0.12033c-0.01304,-0.04179 -0.02188,-0.08445 -0.02652,-0.12798c-0.00464,-0.04353 -0.00499,-0.08709 -0.00104,-0.13068c0.00394,-0.04359 0.0121,-0.08639 0.02447,-0.12838c0.01237,-0.04199 0.02872,-0.08236 0.04905,-0.12113l3.67,-7c0.02824,-0.05386 0.06319,-0.10294 0.10486,-0.14724c0.04167,-0.0443 0.08852,-0.08218 0.14056,-0.11365c0.05204,-0.03147 0.10735,-0.05538 0.16593,-0.07172c0.05857,-0.01634 0.11827,-0.02451 0.17909,-0.02451h5.99997c0.07473,0 0.1474,0.01221 0.218,0.03663c0.0706,0.02442 0.13527,0.05971 0.194,0.10586c0.0344,0.02705 0.06583,0.05719 0.0943,0.09044c0.02847,0.03324 0.05343,0.06894 0.0749,0.1071c0.02147,0.03816 0.03897,0.07805 0.0525,0.11966c0.0136,0.04161 0.02303,0.08414 0.0283,0.12761c0.0052,0.04346 0.0061,0.08701 0.0027,0.13066c-0.00333,0.04364 -0.01093,0.08654 -0.0228,0.12869c-0.0118,0.04215 -0.0276,0.08274 -0.0474,0.12177c-0.0198,0.03903 -0.04323,0.07576 -0.0703,0.11017l-2.8224,3.59141h3.6282c0.08387,0 0.16483,0.01527 0.2429,0.04581c0.07807,0.03055 0.1479,0.07427 0.2095,0.13116c0.03213,0.0297 0.06107,0.06225 0.0868,0.09766c0.0258,0.03541 0.04787,0.07298 0.0662,0.11273c0.01833,0.03975 0.0326,0.0809 0.0428,0.12346c0.01027,0.04256 0.01627,0.08571 0.018,0.12945c0.00173,0.04374 -0.00083,0.08723 -0.0077,0.13047c-0.0068,0.04324 -0.01777,0.08539 -0.0329,0.12646c-0.01513,0.04108 -0.03413,0.08028 -0.057,0.11761c-0.02287,0.03733 -0.04917,0.07208 -0.0789,0.10423l-7.99998,8.65995c-0.04107,0.04447 -0.08735,0.08263 -0.13882,0.1145c-0.05147,0.03187 -0.10626,0.05627 -0.16438,0.0732c-0.05812,0.01693 -0.11744,0.0258 -0.17797,0.0266c-0.06053,0.00073 -0.12006,-0.0066 -0.17859,-0.022c-0.08548,-0.02253 -0.16339,-0.0606 -0.23373,-0.1142c-0.07035,-0.05353 -0.12777,-0.1185 -0.17227,-0.1949c-0.04449,-0.0764 -0.07268,-0.1584 -0.08456,-0.246c-0.01187,-0.0876 -0.00653,-0.17413 0.01602,-0.2596l1.44925,-5.49326h-3.80464c-0.10945,0 -0.21263,-0.02541 -0.30956,-0.07623c-0.03877,-0.02033 -0.07519,-0.04424 -0.10924,-0.07173zM3.76889,7.66671h3.56774c0.04377,0 0.08713,0.00427 0.13006,0.01281c0.04293,0.00854 0.08461,0.02118 0.12506,0.03793c0.04044,0.01675 0.07886,0.03729 0.11526,0.06161c0.03639,0.02432 0.07007,0.05196 0.10102,0.08291c0.03095,0.03095 0.05859,0.06463 0.08291,0.10102c0.02432,0.0364 0.04485,0.07482 0.0616,0.11526c0.01675,0.04044 0.0294,0.08213 0.03794,0.12506c0.00854,0.04293 0.01281,0.08629 0.01281,0.13006c0,0.05765 -0.00735,0.11434 -0.02205,0.17007l-0.9867,3.73996l5.14866,-5.57336h-3.47657c-0.04378,0 -0.08713,-0.00427 -0.13006,-0.01281c-0.04293,-0.00854 -0.08462,-0.02119 -0.12507,-0.03794c-0.04044,-0.01675 -0.07886,-0.03729 -0.11525,-0.0616c-0.0364,-0.02432 -0.07008,-0.05196 -0.10103,-0.08291c-0.03095,-0.03095 -0.05859,-0.06463 -0.08291,-0.10103c-0.02432,-0.03639 -0.04485,-0.07481 -0.0616,-0.11525c-0.01675,-0.04045 -0.0294,-0.08214 -0.03794,-0.12507c-0.00854,-0.04293 -0.01281,-0.08629 -0.01281,-0.13006c0,-0.07469 0.01221,-0.14734 0.03663,-0.21794c0.02442,-0.07059 0.05971,-0.13526 0.10586,-0.19399l2.82235,-3.5914h-4.22496z"></path></g></g><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs></svg>
|
After Width: | Height: | Size: 3.5 KiB |
1
app/icons/mask.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M6,0C2.69,0 0,2.54 0,5.67C0,8.8 2.69,11.33 6,11.33C9.31,11.33 12,8.8 12,5.67C12,2.54 9.31,0 6,0Z" transform="translate(2 3.333333333333333) rotate(0 6 5.666666666666666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1,0C0.45,0 0,0.6 0,1.33C0,2.07 0.45,2.67 1,2.67C1.55,2.67 2,2.07 2,1.33C2,0.6 1.55,0 1,0Z" transform="translate(4.64 6.715000010822796) rotate(14.999999999999998 1 1.3333333333333335)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M1,0C0.45,0 0,0.6 0,1.33C0,2.07 0.45,2.67 1,2.67C1.55,2.67 2,2.07 2,1.33C2,0.6 1.55,0 1,0Z" transform="translate(9.31 6.714999802665079) rotate(165.00000507213028 1.000000156118488 1.3333335414913166)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M4,4.51C5.04,3.47 5.15,1.77 4.1,0.73C3.06,-0.32 1.04,-0.2 0,0.84" transform="translate(9.666599999999999 2.492504620561264) rotate(0 2.4176172657482775 2.2535810230527007)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0.84,4.51C-0.2,3.47 -0.32,1.77 0.73,0.73C1.77,-0.32 3.8,-0.2 4.84,0.84" transform="translate(1.492667974925419 2.4926141635940393) rotate(0 2.4203326792039572 2.253609584869647)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0C0.17,0.43 0.73,1.09 1.67,0.29C2.6,1.09 3.17,0.43 3.33,0" transform="translate(6.5 11.67) rotate(0 1.6666666666666665 0.33333029691911636)"/></g></g></svg>
|
After Width: | Height: | Size: 2.1 KiB |
1
app/icons/max.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3.33,3.3" transform="translate(2 2) rotate(0 1.6666666666666665 1.6499166666666665)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.3L3.33,0" transform="translate(2 10.666666666666666) rotate(0 1.6666666666666665 1.6499166666666671)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3.3,3.3L0,0" transform="translate(10.700199999999999 10.666666666666666) rotate(0 1.6499166666666671 1.6499166666666671)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3.3,0L0,3.3" transform="translate(10.666666666666666 2) rotate(0 1.6499166666666671 1.6499166666666665)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3,0L3,3" transform="translate(11 2) rotate(0 1.5 1.5)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3,0L3,3L0,3" transform="translate(11 11) rotate(0 1.5 1.5)"/><path id="路径 7" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3,3L0,3L0,0" transform="translate(2 11) rotate(0 1.5 1.5)"/><path id="路径 8" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3L0,0L3,0" transform="translate(2 2) rotate(0 1.5 1.5)"/></g></g></svg>
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,25 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
|
||||
height="16" viewBox="0 0 16 16" fill="none">
|
||||
<defs>
|
||||
<rect id="path_0" x="0" y="0" width="16" height="16" />
|
||||
</defs>
|
||||
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
|
||||
<mask id="bg-mask-0" fill="white">
|
||||
<use xlink:href="#path_0"></use>
|
||||
</mask>
|
||||
<g mask="url(#bg-mask-0)">
|
||||
<path id="路径 1"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(2.649903333333333 3.983233333333333) rotate(0 5.333331666666666 0)"
|
||||
d="M0,0L10.67,0 " />
|
||||
<path id="路径 2"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(2.649903333333333 7.983233333333333) rotate(0 5.333331666666666 0)"
|
||||
d="M0,0L10.67,0 " />
|
||||
<path id="路径 3"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(2.649903333333333 11.983233333333333) rotate(0 5.333331666666666 0)"
|
||||
d="M0,0L10.67,0 " />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L10.67,0" transform="translate(2.649903333333333 3.983233333333333) rotate(0 5.333331666666666 0)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L10.67,0" transform="translate(2.649903333333333 7.983233333333333) rotate(0 5.333331666666666 0)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L10.67,0" transform="translate(2.649903333333333 11.983233333333333) rotate(0 5.333331666666666 0)"/></g></g></svg>
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1020 B |
1
app/icons/min.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L3.33,3.3" transform="translate(2 2) rotate(0 1.6666666666666665 1.6499166666666665)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3.3L3.33,0" transform="translate(2 10.666666666666666) rotate(0 1.6666666666666665 1.6499166666666671)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3.3,3.3L0,0" transform="translate(10.700199999999999 10.666666666666666) rotate(0 1.6499166666666671 1.6499166666666671)"/><path id="路径 4" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3.3,0L0,3.3" transform="translate(10.666666666666666 2) rotate(0 1.6499166666666671 1.6499166666666665)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,3L3,3" transform="translate(10.666666666666666 2.333333333333333) rotate(0 1.5 1.5)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3,0L3,3L0,3" transform="translate(2.333333333333333 2.333333333333333) rotate(0 1.5 1.5)"/><path id="路径 7" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M3,3L3,0L0,0" transform="translate(2.333333333333333 10.666666666666666) rotate(0 1.5 1.5)"/><path id="路径 8" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,3L0,0L2.97,0" transform="translate(10.666666666666666 10.666666666666666) rotate(0 1.4832500000000004 1.5)"/></g></g></svg>
|
After Width: | Height: | Size: 2.0 KiB |
1
app/icons/pause.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)" d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6.333333333333333 6) rotate(0 0 2)" d="M0,0L0,4 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(9.666666666666666 6) rotate(0 0 2)" d="M0,0L0,4 " /></g></g></svg>
|
After Width: | Height: | Size: 1.1 KiB |
1
app/icons/plugin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><g opacity="1" transform="translate(0 0) rotate(0)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="fill:#333;opacity:1" d="M6.94515,1.72485c0.26101,0.26101 0.39152,0.57606 0.39152,0.94515v3.33c0,0.36909 -0.13051,0.68414 -0.39152,0.94515c-0.26101,0.26101 -0.57606,0.39152 -0.94515,0.39152h-3.33c-0.36909,0 -0.68414,-0.13051 -0.94515,-0.39152c-0.26101,-0.26101 -0.39152,-0.57606 -0.39152,-0.94515v-3.33c0,-0.36909 0.13051,-0.68414 0.39152,-0.94515c0.26101,-0.26101 0.57606,-0.39152 0.94515,-0.39152h3.33c0.36909,0 0.68414,0.13051 0.94515,0.39152zM2.66667,6c0,0.00222 0.00111,0.00333 0.00333,0.00333h3.33c0.00222,0 0.00333,-0.00111 0.00333,-0.00333v-3.33c0,-0.00222 -0.00111,-0.00333 -0.00333,-0.00333h-3.33c-0.00222,0 -0.00333,0.00111 -0.00333,0.00333z"/><path id="路径 2" style="fill:#333;opacity:1" d="M6.94515,9.05822c0.26101,0.26101 0.39152,0.57607 0.39152,0.94518v3.33c0,0.36907 -0.13051,0.6841 -0.39152,0.9451c-0.26101,0.261 -0.57606,0.3915 -0.94515,0.3915h-3.33c-0.36909,0 -0.68414,-0.1305 -0.94515,-0.3915c-0.26101,-0.261 -0.39152,-0.57603 -0.39152,-0.9451v-3.33c0,-0.36911 0.13051,-0.68417 0.39152,-0.94518c0.26101,-0.26101 0.57606,-0.39151 0.94515,-0.39151h3.33c0.36909,0 0.68414,0.1305 0.94515,0.39151zM2.66667,13.3334c0,0.0022 0.00111,0.0033 0.00333,0.0033h3.33c0.00222,0 0.00333,-0.0011 0.00333,-0.0033v-3.33c0,-0.00227 -0.00111,-0.0034 -0.00333,-0.0034h-3.33c-0.00222,0 -0.00333,0.00113 -0.00333,0.0034z"/><path id="路径 3" style="fill:#333;opacity:1" d="M13.7885,6.45515c-0.27727,0.27728 -0.59567,0.4921 -0.9552,0.64445c-0.37287,0.15805 -0.76283,0.23707 -1.1699,0.23707c-0.40727,0 -0.797,-0.07913 -1.1692,-0.23738c-0.35875,-0.15255 -0.67621,-0.36773 -0.95237,-0.64553c-0.27567,-0.2773 -0.48912,-0.59581 -0.64036,-0.95553c-0.15651,-0.37226 -0.23476,-0.76167 -0.23476,-1.16823c0,-0.40677 0.07836,-0.7959 0.23507,-1.1674c0.15143,-0.35898 0.36525,-0.67656 0.64144,-0.95275c0.27619,-0.27619 0.59379,-0.49001 0.95278,-0.64144c0.37147,-0.15672 0.7606,-0.23508 1.1674,-0.23508c0.40653,0 0.79593,0.07826 1.1682,0.23477c0.35973,0.15124 0.67823,0.36469 0.9555,0.64035c0.2778,0.27617 0.493,0.59363 0.6456,0.9524c0.1582,0.37217 0.2373,0.76189 0.2373,1.16915c0,0.40705 -0.079,0.79704 -0.237,1.16997c-0.1524,0.35951 -0.36723,0.67791 -0.6445,0.95518zM11.6634,2.66667c-0.46093,0 -0.8534,0.16199 -1.1774,0.48598c-0.324,0.32399 -0.486,0.71644 -0.486,1.17735c0,0.46231 0.16247,0.85689 0.4874,1.18374c0.32447,0.32639 0.71647,0.48959 1.176,0.48959c0.46087,0 0.85497,-0.16366 1.1823,-0.49098c0.32733,-0.32733 0.491,-0.72144 0.491,-1.18235c0,-0.4595 -0.1632,-0.85148 -0.4896,-1.17595c-0.32687,-0.32492 -0.72143,-0.48738 -1.1837,-0.48738z"/><path id="路径 4" style="fill:#333;opacity:1" d="M14.2785,9.05822c0.261,0.26101 0.3915,0.57607 0.3915,0.94518v3.33c0,0.36907 -0.1305,0.6841 -0.3915,0.9451c-0.261,0.261 -0.57603,0.3915 -0.9451,0.3915h-3.33c-0.36911,0 -0.68417,-0.1305 -0.94518,-0.3915c-0.26101,-0.261 -0.39151,-0.57603 -0.39151,-0.9451v-3.33c0,-0.36911 0.1305,-0.68417 0.39151,-0.94518c0.26101,-0.26101 0.57607,-0.39151 0.94518,-0.39151h3.33c0.36907,0 0.6841,0.1305 0.9451,0.39151zM10,13.3334c0,0.0022 0.00113,0.0033 0.0034,0.0033h3.33c0.0022,0 0.0033,-0.0011 0.0033,-0.0033v-3.33c0,-0.00227 -0.0011,-0.0034 -0.0033,-0.0034h-3.33c-0.00227,0 -0.0034,0.00113 -0.0034,0.0034z"/></g></g><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs></svg>
|
After Width: | Height: | Size: 3.5 KiB |
1
app/icons/prompt.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="分组 1" style="stroke:#333;stroke-width:1.3;stroke-opacity:1;stroke-dasharray:0 0" d="M1.36683 1.36683L2.77683 2.77683 M4.66667 0L4.66667 2 M4.66667 2L4.66667 0 M7.9623 1.36683L6.5523 2.77683 M6.5523 2.77683L7.9623 1.36683 M9.33333 4.66667L7.33333 4.66667 M7.33333 4.66667L9.33333 4.66667 M7.9623 7.9623L6.5523 6.5523 M6.5523 6.5523L7.9623 7.9623 M4.66667 9.33333L4.66667 7.33333 M4.66667 7.33333L4.66667 9.33333 M1.36683 7.9623L2.77683 6.5523 M2.77683 6.5523L1.36683 7.9623 M0 4.66667L2 4.66667 M2 4.66667L0 4.66667" transform="translate(5.333333333333333 1.3333333333333333) rotate(0 4.666666666666666 4.666666666666666)"/><path id="路径 9" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M8.01,0L0,8.01" transform="translate(1.847983333333333 6.1381) rotate(0 4.006941666666666 4.006933333333333)"/></g></g></svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
app/icons/rename.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.774903333333333 1.3006199999999999) rotate(0 6.599664999999999 6.599656666666666)" d="M2.83,13.2L13.2,2.83L10.37,0L0,10.37L0,13.2L2.83,13.2Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(9.317366666666667 4.129066666666667) rotate(0 1.4142166666666658 1.4142166666666665)" d="M0,0L2.83,2.83 " /></g></g></svg>
|
After Width: | Height: | Size: 911 B |
21
app/icons/return.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
|
||||
height="16" viewBox="0 0 16 16" fill="none">
|
||||
<defs>
|
||||
<rect id="path_0" x="0" y="0" width="16" height="16" />
|
||||
</defs>
|
||||
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
|
||||
<mask id="bg-mask-0" fill="white">
|
||||
<use xlink:href="#path_0"></use>
|
||||
</mask>
|
||||
<g mask="url(#bg-mask-0)">
|
||||
<path id="路径 1"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(2 2.6666666666666665) rotate(0 1.1666333333333334 2.1666666666666665)"
|
||||
d="M2.33,0L0,2L2.33,4.33 " />
|
||||
<path id="路径 2"
|
||||
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
|
||||
transform="translate(2 4.666666666666666) rotate(0 6.000006859869576 4.333333333333333)"
|
||||
d="M0,0L7.66,0C9.96,0 11.91,1.87 12,4.17C12.09,6.59 10.09,8.67 7.66,8.67L2,8.67 " />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1013 B |
1
app/icons/share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M6.67,3.67C1.67,3.67 0,7.33 0,13C0,13 2,8 6.67,8L6.67,11.67L12.67,6L6.67,0L6.67,3.67Z" transform="translate(2 1.3333333333333333) rotate(0 6.333333333333333 6.5)"/></g></g></svg>
|
After Width: | Height: | Size: 645 B |
@@ -1,33 +1 @@
|
||||
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||
<svg width="30" height="14" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#fff">
|
||||
<circle cx="15" cy="15" r="15" fill="var(--primary, red)">
|
||||
<animate attributeName="r" from="15" to="15"
|
||||
begin="0s" dur="0.8s"
|
||||
values="15;9;15" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="fill-opacity" from="1" to="1"
|
||||
begin="0s" dur="0.8s"
|
||||
values="1;.5;1" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="60" cy="15" r="9" fill-opacity="0.3" fill="var(--primary, red)">
|
||||
<animate attributeName="r" from="9" to="9"
|
||||
begin="0s" dur="0.8s"
|
||||
values="9;15;9" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="fill-opacity" from="0.5" to="0.5"
|
||||
begin="0s" dur="0.8s"
|
||||
values=".5;1;.5" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="105" cy="15" r="15" fill="var(--primary, red)">
|
||||
<animate attributeName="r" from="15" to="15"
|
||||
begin="0s" dur="0.8s"
|
||||
values="15;9;15" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="fill-opacity" from="1" to="1"
|
||||
begin="0s" dur="0.8s"
|
||||
values="1;.5;1" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="14" fill="#fff" viewBox="0 0 120 30"><circle cx="15" cy="15" r="15" fill="var(--primary, red)"><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="15" repeatCount="indefinite" to="15" values="15;9;15"/><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1"/></circle><circle cx="60" cy="15" r="9" fill="var(--primary, red)" fill-opacity=".3"><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="9" repeatCount="indefinite" to="9" values="9;15;9"/><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from=".5" repeatCount="indefinite" to=".5" values=".5;1;.5"/></circle><circle cx="105" cy="15" r="15" fill="var(--primary, red)"><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="15" repeatCount="indefinite" to="15" values="15;9;15"/><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1"/></circle></svg>
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
1
app/icons/upload.svg
Normal file
After Width: | Height: | Size: 11 KiB |
@@ -1,34 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="38"
|
||||
height="38" viewBox="0 0 38 38" fill="none">
|
||||
<defs>
|
||||
<filter id="filter_0" x="-4" y="-4" width="38" height="38" filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<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" />
|
||||
<feOffset dx="0" dy="2" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_Shadow" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_Shadow" result="shape" />
|
||||
</filter>
|
||||
<rect id="path_0" x="0" y="0" width="30" height="30" />
|
||||
</defs>
|
||||
<g opacity="1" transform="translate(4 2) rotate(0 15 15)">
|
||||
<g id="undefined" filter="url(#filter_0)">
|
||||
<rect stroke="#000000" stroke-width="1" stroke-opacity="0.05" />
|
||||
<rect x="0" y="0" width="30" height="30" rx="10" />
|
||||
</g>
|
||||
<mask id="bg-mask-0" fill="white">
|
||||
<use xlink:href="#path_0"></use>
|
||||
</mask>
|
||||
<g mask="url(#bg-mask-0)">
|
||||
<g opacity="1" transform="translate(6 4.5) rotate(0 9 11)">
|
||||
<text>
|
||||
<tspan x="0" y="16.240000000000002" font-size="14" line-height="0" fill="#000000"
|
||||
opacity="1" font-family="SourceHanSansCN-Regular" letter-spacing="0">🤣</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,45 +1,28 @@
|
||||
/* eslint-disable @next/next/no-page-custom-font */
|
||||
import "./styles/globals.scss";
|
||||
import "./styles/markdown.scss";
|
||||
import "./styles/prism.scss";
|
||||
import process from "child_process";
|
||||
import { ACCESS_CODES } from "./api/access";
|
||||
|
||||
let COMMIT_ID: string | undefined;
|
||||
try {
|
||||
COMMIT_ID = process
|
||||
.execSync("git rev-parse --short HEAD")
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (e) {
|
||||
console.error("No git or not from git repo.")
|
||||
}
|
||||
import "./styles/highlight.scss";
|
||||
import { getBuildConfig } from "./config/build";
|
||||
import { getClientConfig } from "./config/client";
|
||||
|
||||
export const metadata = {
|
||||
title: "ChatGPT Next Web",
|
||||
description: "Your personal ChatGPT Chat Bot.",
|
||||
viewport: {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
},
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#fafafa" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#151515" },
|
||||
],
|
||||
appleWebApp: {
|
||||
title: "ChatGPT Next Web",
|
||||
statusBarStyle: "black-translucent",
|
||||
statusBarStyle: "default",
|
||||
},
|
||||
themeColor: "#fafafa"
|
||||
};
|
||||
|
||||
function Meta() {
|
||||
const metas = {
|
||||
version: COMMIT_ID ?? "unknown",
|
||||
access: ACCESS_CODES.size > 0 ? "enabled" : "disabled",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(metas).map(([k, v]) => (
|
||||
<meta name={k} content={v} key={k} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -48,18 +31,8 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
|
||||
/>
|
||||
<Meta />
|
||||
<meta name="config" content={JSON.stringify(getClientConfig())} />
|
||||
<link rel="manifest" href="/site.webmanifest"></link>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"></link>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com"></link>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
></link>
|
||||
<script src="/serviceWorkerRegister.js" defer></script>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
|