mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-24 10:53:42 +08:00
Compare commits
738 Commits
v2.9.2
...
feat-redes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fcf0513d2 | ||
|
|
8de8acdce8 | ||
|
|
77e321c7cb | ||
|
|
8093d1ffba | ||
|
|
74a6e1260e | ||
|
|
f55f04ab4f | ||
|
|
0aa807df19 | ||
|
|
48d44ece58 | ||
|
|
bffd9d9173 | ||
|
|
8688842984 | ||
|
|
a0e4a468d6 | ||
|
|
1e00c89988 | ||
|
|
0eccb547b5 | ||
|
|
4789a7f6a9 | ||
|
|
0bf758afd4 | ||
|
|
6612550c06 | ||
|
|
cf635a5e6f | ||
|
|
3a007e4f3d | ||
|
|
9faab960f6 | ||
|
|
5df8b1d183 | ||
|
|
ef5f910f19 | ||
|
|
fffbee80e8 | ||
|
|
6b30e167e1 | ||
|
|
8ec721259a | ||
|
|
9d7ce207b6 | ||
|
|
2d1f0c9f57 | ||
|
|
d3131d2f55 | ||
|
|
c10447df79 | ||
|
|
212ae76d76 | ||
|
|
cd48f7eff4 | ||
|
|
00b1a9781d | ||
|
|
3513c6801e | ||
|
|
240d330001 | ||
|
|
4e4431339f | ||
|
|
fa2f8c66d1 | ||
|
|
864529cbf6 | ||
|
|
58c0d3e12d | ||
|
|
a1493bfb4e | ||
|
|
b3e856df1d | ||
|
|
32f62d70af | ||
|
|
8ef2617eec | ||
|
|
1da7d81122 | ||
|
|
a103582346 | ||
|
|
7b61d05e88 | ||
|
|
6fc7c50f19 | ||
|
|
9d728ec3c5 | ||
|
|
9cd3358e4e | ||
|
|
4cd94370e8 | ||
|
|
68f0fa917f | ||
|
|
8a14cb19a9 | ||
|
|
3d99965a8f | ||
|
|
4d5a9476b6 | ||
|
|
15d6ed252f | ||
|
|
ecf6cc27d6 | ||
|
|
cadd2558fd | ||
|
|
52312dbd23 | ||
|
|
b2e8a1eaa2 | ||
|
|
c3d91bf0cd | ||
|
|
996537d262 | ||
|
|
5ea6206319 | ||
|
|
8c28c408d8 | ||
|
|
c34b8ab919 | ||
|
|
9f4813326c | ||
|
|
9569888b0e | ||
|
|
1a636b0f50 | ||
|
|
48e8c0a194 | ||
|
|
59583e53bd | ||
|
|
506c17a093 | ||
|
|
69642fba52 | ||
|
|
7d647c981f | ||
|
|
bb7422c526 | ||
|
|
9aec3b714e | ||
|
|
dd4648ed9a | ||
|
|
c99086447e | ||
|
|
1cd0beb231 | ||
|
|
f7074bba8c | ||
|
|
4400392c0c | ||
|
|
4a5465f884 | ||
|
|
37cc87531c | ||
|
|
c96e4b7966 | ||
|
|
1074fffe79 | ||
|
|
3d0a98d5d2 | ||
|
|
b3559f99a2 | ||
|
|
b7aab3c102 | ||
|
|
fcb1a657e3 | ||
|
|
9b2cb1e1c3 | ||
|
|
fb8b8d28da | ||
|
|
51a1d9f92a | ||
|
|
ad80153bbb | ||
|
|
9564b261d5 | ||
|
|
1e2a662fa6 | ||
|
|
51f7daaeaf | ||
|
|
f742a7ec4e | ||
|
|
e2c0d2a07b | ||
|
|
d112dc41b2 | ||
|
|
2322851ac4 | ||
|
|
aa084ea09a | ||
|
|
6520f9b7eb | ||
|
|
fd8d0a1746 | ||
|
|
af3ebacee6 | ||
|
|
55d7014301 | ||
|
|
b72d7fbeda | ||
|
|
ee15c14049 | ||
|
|
3fc9b91bf1 | ||
|
|
0a8e5d6734 | ||
|
|
1756bdd033 | ||
|
|
0cffaf8dc5 | ||
|
|
55a93e7b47 | ||
|
|
5dc5bfb797 | ||
|
|
f101ee3c4f | ||
|
|
6319f41b2c | ||
|
|
6c718ada1b | ||
|
|
67acc38a1f | ||
|
|
dd1d8509f0 | ||
|
|
79f342439a | ||
|
|
13db64f0ec | ||
|
|
908ce3bbd9 | ||
|
|
df3313971d | ||
|
|
b175132854 | ||
|
|
4cb0655192 | ||
|
|
8b191bd2f7 | ||
|
|
f3106e3bbb | ||
|
|
7fcfbc3729 | ||
|
|
598468c2b7 | ||
|
|
84681d3878 | ||
|
|
c7b14cba4d | ||
|
|
d508127452 | ||
|
|
984c79e2d2 | ||
|
|
6cb296f952 | ||
|
|
db533fc166 | ||
|
|
02b0e79ba3 | ||
|
|
1b83dd0a8a | ||
|
|
9b982b408d | ||
|
|
9b03ab830d | ||
|
|
264da6798c | ||
|
|
f68b8afa8d | ||
|
|
63f9063255 | ||
|
|
6dad353e1c | ||
|
|
5446d8d4a2 | ||
|
|
ef7617d545 | ||
|
|
0fbb560e90 | ||
|
|
86b5c55855 | ||
|
|
768decde93 | ||
|
|
3cb4315193 | ||
|
|
69b079c86e | ||
|
|
9f3fc5eb9f | ||
|
|
15e595837b | ||
|
|
17e57bb28e | ||
|
|
4d0c77b973 | ||
|
|
f8b180ac44 | ||
|
|
cd30368da9 | ||
|
|
27ed57a648 | ||
|
|
e38b527ac2 | ||
|
|
113d9612db | ||
|
|
6b3daec23f | ||
|
|
e056a1d46d | ||
|
|
57026f6262 | ||
|
|
8ef77f50c3 | ||
|
|
93e21515e5 | ||
|
|
24caa3b97b | ||
|
|
c93b36fe79 | ||
|
|
0de9242a26 | ||
|
|
53fb52c6c0 | ||
|
|
afaa529ba6 | ||
|
|
43824bd621 | ||
|
|
3c97a4f5a1 | ||
|
|
711bf190d4 | ||
|
|
1049006cf9 | ||
|
|
76603d108d | ||
|
|
5bc3930230 | ||
|
|
e5edd851b3 | ||
|
|
dcad400758 | ||
|
|
a1aaea9c55 | ||
|
|
a4e4286e04 | ||
|
|
6dd7a6a171 | ||
|
|
8e554a87b0 | ||
|
|
f1b4c083a4 | ||
|
|
90af4e3b77 | ||
|
|
e8d76a513d | ||
|
|
29e03b88c7 | ||
|
|
ebbd870150 | ||
|
|
c0c54e5709 | ||
|
|
3ba984d09e | ||
|
|
f274683d46 | ||
|
|
e20ce8e335 | ||
|
|
9fd750511c | ||
|
|
028957fcdc | ||
|
|
a4c54cae60 | ||
|
|
cc0eae7153 | ||
|
|
066ca9e552 | ||
|
|
7c04a90d77 | ||
|
|
a8a65ac769 | ||
|
|
aec3c5d6cc | ||
|
|
a22141c2eb | ||
|
|
99aa064319 | ||
|
|
6aaf83f3c2 | ||
|
|
133ce39a13 | ||
|
|
8645214654 | ||
|
|
eebc334e02 | ||
|
|
038fa3b301 | ||
|
|
9a8497299d | ||
|
|
61ce3868b5 | ||
|
|
844c2a26bc | ||
|
|
a15c4d9c20 | ||
|
|
ff9f0e60ac | ||
|
|
2bf6111bf5 | ||
|
|
ad10a11903 | ||
|
|
c22153a4eb | ||
|
|
5348d57057 | ||
|
|
052524dabd | ||
|
|
e33d05cfe5 | ||
|
|
5529ece220 | ||
|
|
e71094d4a8 | ||
|
|
98aa023d70 | ||
|
|
e1066434d0 | ||
|
|
86ae4b2a75 | ||
|
|
ed8099bf1e | ||
|
|
524c9beee4 | ||
|
|
99fb9dcf11 | ||
|
|
1294817103 | ||
|
|
9775660da7 | ||
|
|
e7051353eb | ||
|
|
bd19e97cf8 | ||
|
|
8b821ac0c9 | ||
|
|
43e5dc2292 | ||
|
|
08fa22749a | ||
|
|
c197962851 | ||
|
|
44a51273be | ||
|
|
e3b3ae97bc | ||
|
|
410a22dc63 | ||
|
|
069766d581 | ||
|
|
f22e36e52f | ||
|
|
bc1794fb4a | ||
|
|
aacd26c7db | ||
|
|
ff166f7b4c | ||
|
|
bf1b5c3951 | ||
|
|
22baebaf8c | ||
|
|
e756506c18 | ||
|
|
fd67f980a5 | ||
|
|
e2da3406d2 | ||
|
|
05b6d989b6 | ||
|
|
1d6ee64e1d | ||
|
|
bfefb99192 | ||
|
|
47ae874e4d | ||
|
|
d74f636558 | ||
|
|
b8f0822214 | ||
|
|
0869455612 | ||
|
|
bca74241e6 | ||
|
|
9d5801fb5f | ||
|
|
462a88ae82 | ||
|
|
887bec019a | ||
|
|
d0463b2089 | ||
|
|
deea4320ad | ||
|
|
bb26c03141 | ||
|
|
a5517a1a51 | ||
|
|
4511aa4d21 | ||
|
|
b25a0545f5 | ||
|
|
1a97bd55c7 | ||
|
|
bf711f2ad7 | ||
|
|
e1b065c74a | ||
|
|
eacc911cdc | ||
|
|
91a5f4af9a | ||
|
|
f6cdda9029 | ||
|
|
981a3629b6 | ||
|
|
3554872d9a | ||
|
|
118e8f541c | ||
|
|
3fa55f9022 | ||
|
|
1c73c6cc70 | ||
|
|
148c32a383 | ||
|
|
ca08062bf3 | ||
|
|
ecbab75a25 | ||
|
|
15d25df245 | ||
|
|
b2f7bdca34 | ||
|
|
32bcdb8982 | ||
|
|
ba3e7e7974 | ||
|
|
fae82a30e7 | ||
|
|
5ce1a55a4f | ||
|
|
1132816902 | ||
|
|
a4c1f1ac47 | ||
|
|
f619e9df24 | ||
|
|
43631a3271 | ||
|
|
04855de495 | ||
|
|
6206ceb49b | ||
|
|
628f3ba05f | ||
|
|
94f534f36f | ||
|
|
2f11e7c087 | ||
|
|
0739ced407 | ||
|
|
8fb34465cd | ||
|
|
04fe9fadb1 | ||
|
|
998c95da51 | ||
|
|
3bc771a0d9 | ||
|
|
bdc25adb41 | ||
|
|
7ec29c2eea | ||
|
|
b9995e7f70 | ||
|
|
31d9d2efcd | ||
|
|
86f42d56f2 | ||
|
|
f05bf0a6f6 | ||
|
|
a06a2989d2 | ||
|
|
0cf6614186 | ||
|
|
ddc16cc2be | ||
|
|
616f0b3e73 | ||
|
|
bd1056b13f | ||
|
|
5a7b9abe33 | ||
|
|
cc86923fd5 | ||
|
|
916d764477 | ||
|
|
4a21a8fdae | ||
|
|
49df5bd590 | ||
|
|
9eaf492d5b | ||
|
|
a80502f7db | ||
|
|
eade013138 | ||
|
|
912254751a | ||
|
|
c3c6f7f7ae | ||
|
|
5cf58d9446 | ||
|
|
3ba598633c | ||
|
|
406530ca69 | ||
|
|
f8b963df6d | ||
|
|
d17000975f | ||
|
|
64a8ba6212 | ||
|
|
350ddd2af1 | ||
|
|
c03abdac42 | ||
|
|
bb3cc2c821 | ||
|
|
0b814eff83 | ||
|
|
a91ac91977 | ||
|
|
422d70d928 | ||
|
|
a4cb859472 | ||
|
|
19137b79bc | ||
|
|
bd1e311674 | ||
|
|
c127db335a | ||
|
|
f5ed1604aa | ||
|
|
771cc9e583 | ||
|
|
7ad1e246a0 | ||
|
|
5c638251f8 | ||
|
|
0d189dc78e | ||
|
|
a9d7253143 | ||
|
|
199f29e63c | ||
|
|
c82efceae0 | ||
|
|
cad461b121 | ||
|
|
5af68ac545 | ||
|
|
c0ce70ccc7 | ||
|
|
753c518d33 | ||
|
|
b9ca6694f7 | ||
|
|
0c116251b1 | ||
|
|
e9def2cdc5 | ||
|
|
8ca525dc7a | ||
|
|
281fe6927a | ||
|
|
35471a41c8 | ||
|
|
bda3098a31 | ||
|
|
1e05eb1d60 | ||
|
|
6369b902bf | ||
|
|
aea794e522 | ||
|
|
7c3dfb7bae | ||
|
|
75057f9a91 | ||
|
|
7026bd926a | ||
|
|
ae19a0dc5f | ||
|
|
7d9a2132cb | ||
|
|
20f2f61349 | ||
|
|
4169431f2c | ||
|
|
45798f993d | ||
|
|
ae0d68c27e | ||
|
|
070b191ffe | ||
|
|
778e88cb56 | ||
|
|
d1fcfa05db | ||
|
|
f4754dc5fc | ||
|
|
0f885e73c3 | ||
|
|
75acd4c1aa | ||
|
|
3ef0621eb0 | ||
|
|
b1db9ead0d | ||
|
|
9cc6000d89 | ||
|
|
42515899d9 | ||
|
|
1d096eeb17 | ||
|
|
bc5b8f0ff9 | ||
|
|
bf412fd309 | ||
|
|
6e1f4246b8 | ||
|
|
ed88619623 | ||
|
|
37b8922348 | ||
|
|
4a23a0064b | ||
|
|
e012b1e5d1 | ||
|
|
2bc84ec908 | ||
|
|
8efe26ed57 | ||
|
|
24d7dc9bf9 | ||
|
|
4b7139d9ae | ||
|
|
1f35624121 | ||
|
|
860c5b8f20 | ||
|
|
a454c39ca0 | ||
|
|
6938910f08 | ||
|
|
2c63dde6c5 | ||
|
|
e69d20a209 | ||
|
|
0b731edd21 | ||
|
|
efdd61595e | ||
|
|
d676f88fed | ||
|
|
07f2cf7ad0 | ||
|
|
bcb520ed3b | ||
|
|
943a2707d2 | ||
|
|
93cee18300 | ||
|
|
1442337e3c | ||
|
|
cae4655785 | ||
|
|
28c12606a4 | ||
|
|
54df355014 | ||
|
|
8dc8682078 | ||
|
|
36e9c6ac4d | ||
|
|
10ea9bf1e3 | ||
|
|
cf50299b14 | ||
|
|
2c12be62c4 | ||
|
|
4636a75b5e | ||
|
|
03756e364a | ||
|
|
ce1715c79e | ||
|
|
a62ab3c649 | ||
|
|
bfb7b988f4 | ||
|
|
84f41262f5 | ||
|
|
dda40e29f4 | ||
|
|
7df868e22a | ||
|
|
bf5e7aaa48 | ||
|
|
d76e744eab | ||
|
|
6f5699fe09 | ||
|
|
f9d916925e | ||
|
|
f9258878db | ||
|
|
ef9e86b50d | ||
|
|
b21931c667 | ||
|
|
06de3f5e69 | ||
|
|
261a8fd83b | ||
|
|
6527074cde | ||
|
|
fe0f078353 | ||
|
|
f2485931d9 | ||
|
|
4f8a0b7711 | ||
|
|
2dde55050e | ||
|
|
6aade62ce2 | ||
|
|
45b88ebb2a | ||
|
|
dc7159a450 | ||
|
|
536ace8e10 | ||
|
|
16b2a3e66e | ||
|
|
6f135a0cce | ||
|
|
cf220dd2eb | ||
|
|
914f4fb862 | ||
|
|
7bdb68eecf | ||
|
|
3c510cfaf0 | ||
|
|
401fa198c9 | ||
|
|
600df4f63a | ||
|
|
74eb42c111 | ||
|
|
39f3afd52c | ||
|
|
9876a1aeca | ||
|
|
d898ffce23 | ||
|
|
f1772f4625 | ||
|
|
9da455a7ea | ||
|
|
2bd6342309 | ||
|
|
5e73577088 | ||
|
|
5156a80738 | ||
|
|
a9d605ed30 | ||
|
|
7d1fae32cd | ||
|
|
9f17b45bc4 | ||
|
|
a64c9dae42 | ||
|
|
5fbf4c394c | ||
|
|
1e5153173c | ||
|
|
011b52d07d | ||
|
|
d033168d80 | ||
|
|
fdca9e59de | ||
|
|
549a2fd206 | ||
|
|
a0cd939bfd | ||
|
|
4f52679ec6 | ||
|
|
b52e237044 | ||
|
|
17b329bb63 | ||
|
|
3a654ba199 | ||
|
|
5ba3fc9321 | ||
|
|
a46f08154e | ||
|
|
0f6ed9c293 | ||
|
|
295864d36b | ||
|
|
be6d45e49f | ||
|
|
22b6987249 | ||
|
|
64647b0bb3 | ||
|
|
a5a1f2e8ad | ||
|
|
8bd39f33e0 | ||
|
|
be9774943b | ||
|
|
dd6e79922a | ||
|
|
bf84269520 | ||
|
|
943214c6a7 | ||
|
|
ca792669fc | ||
|
|
78d68a9949 | ||
|
|
6b2db97347 | ||
|
|
2bfb362832 | ||
|
|
cb140e482f | ||
|
|
e6b72ac1ff | ||
|
|
8032e6d68d | ||
|
|
c7e0a6f37f | ||
|
|
1141cd2e6e | ||
|
|
c9dd953817 | ||
|
|
b7ffca031e | ||
|
|
544bab0fe2 | ||
|
|
fd2f441e02 | ||
|
|
3b3ebda34b | ||
|
|
87e3d663a2 | ||
|
|
d0a1d910d4 | ||
|
|
33b97082fa | ||
|
|
d93f05f511 | ||
|
|
3dc29197e1 | ||
|
|
fbc0236748 | ||
|
|
1304a3943a | ||
|
|
8c0ba1aee2 | ||
|
|
47154773f2 | ||
|
|
deca9bf06d | ||
|
|
6ab4c9be2e | ||
|
|
3a519612a0 | ||
|
|
d1ec26ae83 | ||
|
|
9cb889c34f | ||
|
|
77f27b7948 | ||
|
|
dbee5bc515 | ||
|
|
54a5332834 | ||
|
|
8af164c99f | ||
|
|
c7321fddfb | ||
|
|
c5ca278253 | ||
|
|
638fdd8c3e | ||
|
|
3407b57a51 | ||
|
|
836bf836d3 | ||
|
|
6e52d14180 | ||
|
|
cdf0311d27 | ||
|
|
1096fe55e1 | ||
|
|
d2d615c84a | ||
|
|
9f26c8cecb | ||
|
|
cd1f082c52 | ||
|
|
5610f423d0 | ||
|
|
b90dfb48ee | ||
|
|
70e67a686b | ||
|
|
d1c3d900ab | ||
|
|
e8a4ad1001 | ||
|
|
ccac85b079 | ||
|
|
f92fefb6cd | ||
|
|
b799213a14 | ||
|
|
cc565cf57e | ||
|
|
4a56e9c884 | ||
|
|
d11d906fa2 | ||
|
|
65c4a0c319 | ||
|
|
55bcf78efe | ||
|
|
f5a2ce52aa | ||
|
|
0dcbfd746e | ||
|
|
06e043d3f1 | ||
|
|
986d34fb3e | ||
|
|
5296ab1b09 | ||
|
|
b5d3348d99 | ||
|
|
1e77df381a | ||
|
|
ec33281ff5 | ||
|
|
ba67f13ad5 | ||
|
|
1604ed91fb | ||
|
|
74fcaab5e9 | ||
|
|
adee435ea6 | ||
|
|
ea59ab5176 | ||
|
|
f78008c1b2 | ||
|
|
f54db695af | ||
|
|
f21f922160 | ||
|
|
fd413c7b52 | ||
|
|
3e2c5af4b5 | ||
|
|
bdb49b1171 | ||
|
|
5933b3d7eb | ||
|
|
4c8d606fae | ||
|
|
13c1d2fd2b | ||
|
|
e35c807216 | ||
|
|
5a2cc6f154 | ||
|
|
88f8c43472 | ||
|
|
ef3e8e6fac | ||
|
|
1505372e20 | ||
|
|
ad5093ce05 | ||
|
|
b558d1afc6 | ||
|
|
ddfd05b008 | ||
|
|
d2ad01a9ff | ||
|
|
64a17abfe2 | ||
|
|
04b638aa06 | ||
|
|
31e30906d0 | ||
|
|
bc00be9065 | ||
|
|
4a599e986f | ||
|
|
f1ca03e378 | ||
|
|
f3d5fc7a84 | ||
|
|
3bfcdf9c41 | ||
|
|
144200e315 | ||
|
|
398e229c77 | ||
|
|
6a61fe5776 | ||
|
|
9835206452 | ||
|
|
70b0580fb7 | ||
|
|
23eb7732d7 | ||
|
|
26e50cefea | ||
|
|
588e907181 | ||
|
|
eae7d6260f | ||
|
|
175b4e7f92 | ||
|
|
b050417ab1 | ||
|
|
37b49400db | ||
|
|
ebcb2e7837 | ||
|
|
f1e7db6a88 | ||
|
|
2ba0929458 | ||
|
|
83fed42997 | ||
|
|
2c4626709c | ||
|
|
59fbadd9eb | ||
|
|
adb860b464 | ||
|
|
8d8790586d | ||
|
|
61ca60c550 | ||
|
|
d713d01600 | ||
|
|
372ea0f845 | ||
|
|
61888708f5 | ||
|
|
c900459f73 | ||
|
|
2c92f75c86 | ||
|
|
3e1514239c | ||
|
|
0707a1d49a | ||
|
|
9521f19507 | ||
|
|
bd69116df2 | ||
|
|
6535986484 | ||
|
|
e03db9c2d5 | ||
|
|
038790370c | ||
|
|
261bf0b298 | ||
|
|
5a7bdcfe59 | ||
|
|
4f3261b262 | ||
|
|
48e6087b1b | ||
|
|
28103c901d | ||
|
|
368701610f | ||
|
|
b589f48aa9 | ||
|
|
74e32efa7d | ||
|
|
dc555b2206 | ||
|
|
859cf6930f | ||
|
|
6f83fbd212 | ||
|
|
a7be4780ba | ||
|
|
b5e89d4440 | ||
|
|
89bc98d26b | ||
|
|
31a73273ac | ||
|
|
2a820ea6e7 | ||
|
|
6484d4f0bd | ||
|
|
461e48be32 | ||
|
|
ff60ffca3e | ||
|
|
1bbf310c46 | ||
|
|
8469f448b5 | ||
|
|
e36abc3ac6 | ||
|
|
605dd72354 | ||
|
|
9770b65146 | ||
|
|
57158890c3 | ||
|
|
6f8f490fdd | ||
|
|
415e9dc913 | ||
|
|
1487762925 | ||
|
|
c73a91a0f5 | ||
|
|
5dced28088 | ||
|
|
38f6956e71 | ||
|
|
93da9dd1f4 | ||
|
|
505c8cde81 | ||
|
|
f7a6fa9873 | ||
|
|
70a2d11b8c | ||
|
|
2c077aca5a | ||
|
|
efef6b098d | ||
|
|
885f2a3226 | ||
|
|
1f94ae7888 | ||
|
|
05aa520669 | ||
|
|
b0c32159e7 | ||
|
|
4a182517da | ||
|
|
107b584085 | ||
|
|
6d8416f838 | ||
|
|
876a0cb6f7 | ||
|
|
4904612523 | ||
|
|
3bd76b9156 | ||
|
|
dd757e7d04 | ||
|
|
71d06647d0 | ||
|
|
0ae57589a9 | ||
|
|
22a6819f7b | ||
|
|
5e23ad2db1 | ||
|
|
e009ea57b9 | ||
|
|
63c93a42b5 | ||
|
|
e6cc1625b5 | ||
|
|
f65b0128e7 | ||
|
|
507b7fee56 | ||
|
|
925d28495a | ||
|
|
19dd71eb05 | ||
|
|
b3fd56c2c1 | ||
|
|
d8b6ebf6cb | ||
|
|
bdaac17886 | ||
|
|
8cac51abbe | ||
|
|
0113d4499b | ||
|
|
7562ab3c1c | ||
|
|
d3de07ecf3 | ||
|
|
b146cd889f | ||
|
|
bc0f184098 | ||
|
|
1debde3046 | ||
|
|
4d3fdbdd80 | ||
|
|
90c201d438 | ||
|
|
1e22abab80 | ||
|
|
792c37735e | ||
|
|
6d8ece530f | ||
|
|
3499dfb285 | ||
|
|
30c656ceda | ||
|
|
a1c7f86ff3 | ||
|
|
4952e41132 | ||
|
|
e1142216ec | ||
|
|
a4040fc1ee | ||
|
|
f84572443f | ||
|
|
fbf6bc1979 | ||
|
|
3aeb2c1230 | ||
|
|
50eb7a5f98 | ||
|
|
484d1e7396 | ||
|
|
a632e13334 | ||
|
|
aa3f96f89c | ||
|
|
16685ddb6c | ||
|
|
e78b15b9f0 | ||
|
|
35b0bd76f8 | ||
|
|
1d7286c161 | ||
|
|
5a7ec38ecd | ||
|
|
ada4e3cdcd | ||
|
|
ed62c87156 | ||
|
|
840277f584 | ||
|
|
ffddb7fb2b | ||
|
|
b380421fd5 | ||
|
|
20882a7598 | ||
|
|
9d3dff47d2 | ||
|
|
db5c7aba78 | ||
|
|
e8e01aa60d | ||
|
|
ae8226907f | ||
|
|
803b66ae9d | ||
|
|
20a508e2d6 | ||
|
|
25ada1eae9 | ||
|
|
808e4b38a3 | ||
|
|
a496bc5a63 | ||
|
|
c94713475f | ||
|
|
d45c61109f | ||
|
|
99220d72da | ||
|
|
887eaef1aa | ||
|
|
836a00e104 | ||
|
|
8ee5061046 | ||
|
|
61852b4021 | ||
|
|
0b7de6f7b2 | ||
|
|
9834a67cbd | ||
|
|
d85a4a0c9f | ||
|
|
67c8ec6d7e | ||
|
|
2a2dd7ea19 | ||
|
|
153e7ac7e4 | ||
|
|
9420fd4946 | ||
|
|
b14c5cd89c | ||
|
|
4ab9141429 | ||
|
|
769c2f9f49 | ||
|
|
c41c498a9c | ||
|
|
d1096582a5 | ||
|
|
543989151f | ||
|
|
7da83987e4 | ||
|
|
523d553dac | ||
|
|
ff6f0e9546 | ||
|
|
3e63f6ba34 | ||
|
|
811b92d24e | ||
|
|
bc5ddc4541 | ||
|
|
44e43729bf | ||
|
|
203067c936 | ||
|
|
f3b508c088 |
97
.dockerignore
Normal file
97
.dockerignore
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Node.js dependencies
|
||||||
|
/node_modules
|
||||||
|
/jspm_packages
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
|
||||||
|
# Vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp
|
||||||
|
temp
|
||||||
|
|
||||||
|
# IDE and editor directories
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# secret key
|
||||||
|
*.key
|
||||||
|
*.key.pub
|
||||||
@@ -2,12 +2,22 @@
|
|||||||
# Your openai api key. (required)
|
# Your openai api key. (required)
|
||||||
OPENAI_API_KEY=sk-xxxx
|
OPENAI_API_KEY=sk-xxxx
|
||||||
|
|
||||||
# Access passsword, separated by comma. (optional)
|
# Access password, separated by comma. (optional)
|
||||||
CODE=your-password
|
CODE=your-password
|
||||||
|
|
||||||
# You can start service behind a proxy
|
# You can start service behind a proxy
|
||||||
PROXY_URL=http://localhost:7890
|
PROXY_URL=http://localhost:7890
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: Empty
|
||||||
|
# Googel Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: https://generativelanguage.googleapis.com/
|
||||||
|
# Googel Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url.
|
||||||
|
GOOGLE_URL=
|
||||||
|
|
||||||
# Override openai api request base url. (optional)
|
# Override openai api request base url. (optional)
|
||||||
# Default: https://api.openai.com
|
# Default: https://api.openai.com
|
||||||
# Examples: http://your-openai-proxy.com
|
# Examples: http://your-openai-proxy.com
|
||||||
@@ -15,14 +25,8 @@ BASE_URL=
|
|||||||
|
|
||||||
# Specify OpenAI organization ID.(optional)
|
# Specify OpenAI organization ID.(optional)
|
||||||
# Default: Empty
|
# Default: Empty
|
||||||
# If you do not want users to input their own API key, set this value to 1.
|
|
||||||
OPENAI_ORG_ID=
|
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)
|
# (optional)
|
||||||
# Default: Empty
|
# Default: Empty
|
||||||
# If you do not want users to use GPT-4, set this value to 1.
|
# If you do not want users to use GPT-4, set this value to 1.
|
||||||
@@ -30,5 +34,30 @@ DISABLE_GPT4=
|
|||||||
|
|
||||||
# (optional)
|
# (optional)
|
||||||
# Default: Empty
|
# Default: Empty
|
||||||
# If you do not want users to query balance, set this value to 1.
|
# If you do not want users to input their own API key, set this value to 1.
|
||||||
HIDE_BALANCE_QUERY=
|
HIDE_USER_API_KEY=
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: Empty
|
||||||
|
# If you do want users to query balance, set this value to 1.
|
||||||
|
ENABLE_BALANCE_QUERY=
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: Empty
|
||||||
|
# If you want to disable parse settings from url, set this value to 1.
|
||||||
|
DISABLE_FAST_LINK=
|
||||||
|
|
||||||
|
|
||||||
|
# anthropic claude Api Key.(optional)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
### anthropic claude Api version. (optional)
|
||||||
|
ANTHROPIC_API_VERSION=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### anthropic claude Api url (optional)
|
||||||
|
ANTHROPIC_URL=
|
||||||
|
|
||||||
|
### (optional)
|
||||||
|
WHITE_WEBDEV_ENDPOINTS=
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"plugins": ["prettier"]
|
"plugins": [
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"legacyDecorators": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["globals.css"]
|
||||||
}
|
}
|
||||||
|
|||||||
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
146
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
146
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Describe the bug"
|
||||||
|
- type: textarea
|
||||||
|
id: bug-description
|
||||||
|
attributes:
|
||||||
|
label: "Bug Description"
|
||||||
|
description: "A clear and concise description of what the bug is."
|
||||||
|
placeholder: "Explain the bug..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## To Reproduce"
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: "Steps to Reproduce"
|
||||||
|
description: "Steps to reproduce the behavior:"
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Expected behavior"
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: "Expected Behavior"
|
||||||
|
description: "A clear and concise description of what you expected to happen."
|
||||||
|
placeholder: "Describe what you expected to happen..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Screenshots"
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: "Screenshots"
|
||||||
|
description: "If applicable, add screenshots to help explain your problem."
|
||||||
|
placeholder: "Paste your screenshots here or write 'N/A' if not applicable..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Deployment"
|
||||||
|
- type: checkboxes
|
||||||
|
id: deployment
|
||||||
|
attributes:
|
||||||
|
label: "Deployment Method"
|
||||||
|
description: "Please select the deployment method you are using."
|
||||||
|
options:
|
||||||
|
- label: "Docker"
|
||||||
|
- label: "Vercel"
|
||||||
|
- label: "Server"
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Desktop (please complete the following information):"
|
||||||
|
- type: input
|
||||||
|
id: desktop-os
|
||||||
|
attributes:
|
||||||
|
label: "Desktop OS"
|
||||||
|
description: "Your desktop operating system."
|
||||||
|
placeholder: "e.g., Windows 10"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: desktop-browser
|
||||||
|
attributes:
|
||||||
|
label: "Desktop Browser"
|
||||||
|
description: "Your desktop browser."
|
||||||
|
placeholder: "e.g., Chrome, Safari"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: desktop-version
|
||||||
|
attributes:
|
||||||
|
label: "Desktop Browser Version"
|
||||||
|
description: "Version of your desktop browser."
|
||||||
|
placeholder: "e.g., 89.0"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Smartphone (please complete the following information):"
|
||||||
|
- type: input
|
||||||
|
id: smartphone-device
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone Device"
|
||||||
|
description: "Your smartphone device."
|
||||||
|
placeholder: "e.g., iPhone X"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: smartphone-os
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone OS"
|
||||||
|
description: "Your smartphone operating system."
|
||||||
|
placeholder: "e.g., iOS 14.4"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: smartphone-browser
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone Browser"
|
||||||
|
description: "Your smartphone browser."
|
||||||
|
placeholder: "e.g., Safari"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: smartphone-version
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone Browser Version"
|
||||||
|
description: "Version of your smartphone browser."
|
||||||
|
placeholder: "e.g., 14"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Additional Logs"
|
||||||
|
- type: textarea
|
||||||
|
id: additional-logs
|
||||||
|
attributes:
|
||||||
|
label: "Additional Logs"
|
||||||
|
description: "Add any logs about the problem here."
|
||||||
|
placeholder: "Paste any relevant logs here..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "[Feature Request]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Is your feature request related to a problem? Please describe."
|
||||||
|
- type: textarea
|
||||||
|
id: problem-description
|
||||||
|
attributes:
|
||||||
|
label: Problem Description
|
||||||
|
description: "A clear and concise description of what the problem is. Example: I'm always frustrated when [...]"
|
||||||
|
placeholder: "Explain the problem you are facing..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Describe the solution you'd like"
|
||||||
|
- type: textarea
|
||||||
|
id: desired-solution
|
||||||
|
attributes:
|
||||||
|
label: Solution Description
|
||||||
|
description: A clear and concise description of what you want to happen.
|
||||||
|
placeholder: "Describe the solution you'd like..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Describe alternatives you've considered"
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives-considered
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
placeholder: "Describe any alternative solutions or features you've considered..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Additional context"
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
|
placeholder: "Add any other context or screenshots about the feature request here..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
24
.github/ISSUE_TEMPLATE/功能建议.md
vendored
24
.github/ISSUE_TEMPLATE/功能建议.md
vendored
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
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
36
.github/ISSUE_TEMPLATE/反馈问题.md
vendored
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
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 或者服务器部署]
|
|
||||||
19
.github/workflows/app.yml
vendored
19
.github/workflows/app.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- name: setup node
|
- name: setup node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
- name: get version
|
- name: get version
|
||||||
run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV
|
run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV
|
||||||
- name: create release
|
- name: create release
|
||||||
@@ -43,12 +43,9 @@ jobs:
|
|||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
rust_target: x86_64-unknown-linux-gnu
|
rust_target: x86_64-unknown-linux-gnu
|
||||||
- os: macos-latest
|
|
||||||
arch: x86_64
|
|
||||||
rust_target: x86_64-apple-darwin
|
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
arch: aarch64
|
arch: aarch64
|
||||||
rust_target: aarch64-apple-darwin
|
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
rust_target: x86_64-pc-windows-msvc
|
rust_target: x86_64-pc-windows-msvc
|
||||||
@@ -59,14 +56,15 @@ jobs:
|
|||||||
- name: setup node
|
- name: setup node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
|
cache: 'yarn'
|
||||||
- name: install Rust stable
|
- name: install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.config.rust_target }}
|
targets: ${{ matrix.config.rust_target }}
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
key: ${{ matrix.config.rust_target }}
|
key: ${{ matrix.config.os }}
|
||||||
- name: install dependencies (ubuntu only)
|
- name: install dependencies (ubuntu only)
|
||||||
if: matrix.config.os == 'ubuntu-latest'
|
if: matrix.config.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
@@ -79,8 +77,15 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
with:
|
with:
|
||||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
84
.github/workflows/deploy_preview.yml
vendored
Normal file
84
.github/workflows/deploy_preview.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: VercelPreviewDeployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }}
|
||||||
|
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
VERCEL_PR_DOMAIN_SUFFIX: ${{ secrets.VERCEL_PR_DOMAIN_SUFFIX }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
statuses: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-preview:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Extract branch name
|
||||||
|
shell: bash
|
||||||
|
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> "$GITHUB_OUTPUT"
|
||||||
|
id: extract_branch
|
||||||
|
|
||||||
|
- name: Hash branch name
|
||||||
|
uses: pplanel/hash-calculator-action@v1.3.1
|
||||||
|
id: hash_branch
|
||||||
|
with:
|
||||||
|
input: ${{ steps.extract_branch.outputs.branch }}
|
||||||
|
method: MD5
|
||||||
|
|
||||||
|
- name: Set Environment Variables
|
||||||
|
id: set_env
|
||||||
|
if: github.event_name == 'pull_request_target'
|
||||||
|
run: |
|
||||||
|
echo "VERCEL_ALIAS_DOMAIN=${{ github.event.pull_request.number }}-${{ github.workflow }}.${VERCEL_PR_DOMAIN_SUFFIX}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Install Vercel CLI
|
||||||
|
run: npm install --global vercel@latest
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v2
|
||||||
|
id: cache-npm
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: npm-
|
||||||
|
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: vercel pull --yes --environment=preview --token=${VERCEL_TOKEN}
|
||||||
|
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
id: vercel
|
||||||
|
env:
|
||||||
|
META_TAG: ${{ steps.hash_branch.outputs.digest }}-${{ github.run_number }}-${{ github.run_attempt}}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
vercel pull --yes --environment=preview --token=${VERCEL_TOKEN}
|
||||||
|
vercel build --token=${VERCEL_TOKEN}
|
||||||
|
vercel deploy --prebuilt --archive=tgz --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }}
|
||||||
|
|
||||||
|
DEFAULT_URL=$(vercel ls --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }})
|
||||||
|
ALIAS_URL=$(vercel alias set ${DEFAULT_URL} ${{ steps.set_env.outputs.VERCEL_ALIAS_DOMAIN }} --token=${VERCEL_TOKEN} --scope ${VERCEL_TEAM}| awk '{print $3}')
|
||||||
|
|
||||||
|
echo "New preview URL: ${DEFAULT_URL}"
|
||||||
|
echo "New alias URL: ${ALIAS_URL}"
|
||||||
|
echo "VERCEL_URL=${ALIAS_URL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: mshick/add-pr-comment@v2
|
||||||
|
with:
|
||||||
|
message: |
|
||||||
|
Your build has completed!
|
||||||
|
|
||||||
|
[Preview deployment](${{ steps.vercel.outputs.VERCEL_URL }})
|
||||||
40
.github/workflows/remove_deploy_preview.yml
vendored
Normal file
40
.github/workflows/remove_deploy_preview.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Removedeploypreview
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
statuses: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
delete-deployments:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Extract branch name
|
||||||
|
shell: bash
|
||||||
|
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
|
||||||
|
id: extract_branch
|
||||||
|
|
||||||
|
- name: Hash branch name
|
||||||
|
uses: pplanel/hash-calculator-action@v1.3.1
|
||||||
|
id: hash_branch
|
||||||
|
with:
|
||||||
|
input: ${{ steps.extract_branch.outputs.branch }}
|
||||||
|
method: MD5
|
||||||
|
|
||||||
|
- name: Call the delete-deployment-preview.sh script
|
||||||
|
env:
|
||||||
|
META_TAG: ${{ steps.hash_branch.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
bash ./scripts/delete-deployment-preview.sh
|
||||||
2
.github/workflows/sync.yml
vendored
2
.github/workflows/sync.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
id: sync
|
id: sync
|
||||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||||
with:
|
with:
|
||||||
upstream_sync_repo: Yidadaa/ChatGPT-Next-Web
|
upstream_sync_repo: ChatGPTNextWeb/ChatGPT-Next-Web
|
||||||
upstream_sync_branch: main
|
upstream_sync_branch: main
|
||||||
target_sync_branch: main
|
target_sync_branch: main
|
||||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||||
|
|||||||
36
Dockerfile
36
Dockerfile
@@ -16,6 +16,7 @@ FROM base AS builder
|
|||||||
RUN apk update && apk add --no-cache git
|
RUN apk update && apk add --no-cache git
|
||||||
|
|
||||||
ENV OPENAI_API_KEY=""
|
ENV OPENAI_API_KEY=""
|
||||||
|
ENV GOOGLE_API_KEY=""
|
||||||
ENV CODE=""
|
ENV CODE=""
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -31,6 +32,7 @@ RUN apk add proxychains-ng
|
|||||||
|
|
||||||
ENV PROXY_URL=""
|
ENV PROXY_URL=""
|
||||||
ENV OPENAI_API_KEY=""
|
ENV OPENAI_API_KEY=""
|
||||||
|
ENV GOOGLE_API_KEY=""
|
||||||
ENV CODE=""
|
ENV CODE=""
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
@@ -41,22 +43,22 @@ COPY --from=builder /app/.next/server ./.next/server
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD if [ -n "$PROXY_URL" ]; then \
|
CMD if [ -n "$PROXY_URL" ]; then \
|
||||||
export HOSTNAME="127.0.0.1"; \
|
export HOSTNAME="127.0.0.1"; \
|
||||||
protocol=$(echo $PROXY_URL | cut -d: -f1); \
|
protocol=$(echo $PROXY_URL | cut -d: -f1); \
|
||||||
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
|
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
|
||||||
port=$(echo $PROXY_URL | cut -d: -f3); \
|
port=$(echo $PROXY_URL | cut -d: -f3); \
|
||||||
conf=/etc/proxychains.conf; \
|
conf=/etc/proxychains.conf; \
|
||||||
echo "strict_chain" > $conf; \
|
echo "strict_chain" > $conf; \
|
||||||
echo "proxy_dns" >> $conf; \
|
echo "proxy_dns" >> $conf; \
|
||||||
echo "remote_dns_subnet 224" >> $conf; \
|
echo "remote_dns_subnet 224" >> $conf; \
|
||||||
echo "tcp_read_time_out 15000" >> $conf; \
|
echo "tcp_read_time_out 15000" >> $conf; \
|
||||||
echo "tcp_connect_time_out 8000" >> $conf; \
|
echo "tcp_connect_time_out 8000" >> $conf; \
|
||||||
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
|
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
|
||||||
echo "localnet ::1/128" >> $conf; \
|
echo "localnet ::1/128" >> $conf; \
|
||||||
echo "[ProxyList]" >> $conf; \
|
echo "[ProxyList]" >> $conf; \
|
||||||
echo "$protocol $host $port" >> $conf; \
|
echo "$protocol $host $port" >> $conf; \
|
||||||
cat /etc/proxychains.conf; \
|
cat /etc/proxychains.conf; \
|
||||||
proxychains -f $conf node server.js; \
|
proxychains -f $conf node server.js; \
|
||||||
else \
|
else \
|
||||||
node server.js; \
|
node server.js; \
|
||||||
fi
|
fi
|
||||||
|
|||||||
142
README.md
142
README.md
@@ -1,22 +1,22 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/images/icon.svg" alt="icon"/>
|
<img src="./docs/images/head-cover.png" alt="icon"/>
|
||||||
|
|
||||||
<h1 align="center">ChatGPT Next Web</h1>
|
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
|
||||||
|
|
||||||
English / [简体中文](./README_CN.md)
|
English / [简体中文](./README_CN.md)
|
||||||
|
|
||||||
One-Click to get well-designed cross-platform ChatGPT web UI.
|
One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support.
|
||||||
|
|
||||||
一键免费部署你的跨平台私人 ChatGPT 应用。
|
一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||||
|
|
||||||
[![Web][Web-image]][web-url]
|
[![Web][Web-image]][web-url]
|
||||||
[![Windows][Windows-image]][download-url]
|
[![Windows][Windows-image]][download-url]
|
||||||
[![MacOS][MacOS-image]][download-url]
|
[![MacOS][MacOS-image]][download-url]
|
||||||
[![Linux][Linux-image]][download-url]
|
[![Linux][Linux-image]][download-url]
|
||||||
|
|
||||||
[Web App](https://chatgpt.nextweb.fun/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discord](https://discord.gg/YCkeafCafC) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
[Web App](https://app.nextchat.dev/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Twitter](https://twitter.com/NextChatDev)
|
||||||
|
|
||||||
[网页版](https://chatgpt.nextweb.fun/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](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://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
|
||||||
|
|
||||||
[web-url]: https://chatgpt.nextweb.fun
|
[web-url]: https://chatgpt.nextweb.fun
|
||||||
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
|
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
|
||||||
@@ -25,7 +25,9 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
|
|||||||
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
|
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
|
||||||
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
|
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
|
||||||
|
|
||||||
[](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://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat)
|
||||||
|
|
||||||
|
[](https://zeabur.com/templates/ZBUEFA)
|
||||||
|
|
||||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||
@@ -37,15 +39,15 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
|
|||||||
|
|
||||||
- **Deploy for free with one-click** on Vercel in under 1 minute
|
- **Deploy for free with one-click** on Vercel in under 1 minute
|
||||||
- Compact client (~5MB) on Linux/Windows/MacOS, [download it now](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
|
- Compact client (~5MB) on Linux/Windows/MacOS, [download it now](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
|
||||||
- Fully compatible with self-deployed llms, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI)
|
- Fully compatible with self-deployed LLMs, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI)
|
||||||
- Privacy first, all data stored locally in the browser
|
- Privacy first, all data is stored locally in the browser
|
||||||
- Markdown support: LaTex, mermaid, code highlight, etc.
|
- Markdown support: LaTex, mermaid, code highlight, etc.
|
||||||
- Responsive design, dark mode and PWA
|
- Responsive design, dark mode and PWA
|
||||||
- Fast first screen loading speed (~100kb), support streaming response
|
- Fast first screen loading speed (~100kb), support streaming response
|
||||||
- New in v2: create, share and debug your chat tools with prompt templates (mask)
|
- 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)
|
- 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
|
- Automatically compresses chat history to support long conversations while also saving your tokens
|
||||||
- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어
|
- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -59,9 +61,11 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
|
|||||||
|
|
||||||
## What's New
|
## 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.10.1 support Google Gemini Pro model.
|
||||||
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
|
- 🚀 v2.9.11 you can use azure endpoint now.
|
||||||
- 🚀 v2.8 now we have a client that runs across all platforms!
|
- 🚀 v2.8 now we have a client that runs across all platforms!
|
||||||
|
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
|
||||||
|
- 🚀 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/).
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
|
|||||||
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
|
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
|
||||||
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
||||||
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
||||||
- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština
|
- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
|
||||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
||||||
|
|
||||||
## 开发计划
|
## 开发计划
|
||||||
@@ -93,6 +97,7 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
|
|||||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
||||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||||
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
||||||
|
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
@@ -135,7 +140,7 @@ After forking the project, due to the limitations imposed by GitHub, you need to
|
|||||||
|
|
||||||
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.
|
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.
|
You can star or watch this project or follow author to get release notifications in time.
|
||||||
|
|
||||||
## Access Password
|
## Access Password
|
||||||
|
|
||||||
@@ -153,13 +158,13 @@ After adding or modifying this environment variable, please redeploy the project
|
|||||||
|
|
||||||
> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量)
|
> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量)
|
||||||
|
|
||||||
### `OPENAI_API_KEY` (required)
|
|
||||||
|
|
||||||
Your openai api key.
|
|
||||||
|
|
||||||
### `CODE` (optional)
|
### `CODE` (optional)
|
||||||
|
|
||||||
Access passsword, separated by comma.
|
Access password, separated by comma.
|
||||||
|
|
||||||
|
### `OPENAI_API_KEY` (required)
|
||||||
|
|
||||||
|
Your openai api key, join multiple api keys with comma.
|
||||||
|
|
||||||
### `BASE_URL` (optional)
|
### `BASE_URL` (optional)
|
||||||
|
|
||||||
@@ -173,6 +178,40 @@ Override openai api request base url.
|
|||||||
|
|
||||||
Specify OpenAI organization ID.
|
Specify OpenAI organization ID.
|
||||||
|
|
||||||
|
### `AZURE_URL` (optional)
|
||||||
|
|
||||||
|
> Example: https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||||
|
|
||||||
|
Azure deploy url.
|
||||||
|
|
||||||
|
### `AZURE_API_KEY` (optional)
|
||||||
|
|
||||||
|
Azure Api Key.
|
||||||
|
|
||||||
|
### `AZURE_API_VERSION` (optional)
|
||||||
|
|
||||||
|
Azure Api Version, find it at [Azure Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions).
|
||||||
|
|
||||||
|
### `GOOGLE_API_KEY` (optional)
|
||||||
|
|
||||||
|
Google Gemini Pro Api Key.
|
||||||
|
|
||||||
|
### `GOOGLE_URL` (optional)
|
||||||
|
|
||||||
|
Google Gemini Pro Api Url.
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_KEY` (optional)
|
||||||
|
|
||||||
|
anthropic claude Api Key.
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_VERSION` (optional)
|
||||||
|
|
||||||
|
anthropic claude Api version.
|
||||||
|
|
||||||
|
### `ANTHROPIC_URL` (optional)
|
||||||
|
|
||||||
|
anthropic claude Api Url.
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (optional)
|
### `HIDE_USER_API_KEY` (optional)
|
||||||
|
|
||||||
> Default: Empty
|
> Default: Empty
|
||||||
@@ -185,11 +224,37 @@ If you do not want users to input their own API key, set this value to 1.
|
|||||||
|
|
||||||
If you do not want users to use GPT-4, set this value to 1.
|
If you do not want users to use GPT-4, set this value to 1.
|
||||||
|
|
||||||
### `HIDE_BALANCE_QUERY` (optional)
|
### `ENABLE_BALANCE_QUERY` (optional)
|
||||||
|
|
||||||
> Default: Empty
|
> Default: Empty
|
||||||
|
|
||||||
If you do not want users to query balance, set this value to 1.
|
If you do want users to query balance, set this value to 1.
|
||||||
|
|
||||||
|
### `DISABLE_FAST_LINK` (optional)
|
||||||
|
|
||||||
|
> Default: Empty
|
||||||
|
|
||||||
|
If you want to disable parse settings from url, set this to 1.
|
||||||
|
|
||||||
|
### `CUSTOM_MODELS` (optional)
|
||||||
|
|
||||||
|
> Default: Empty
|
||||||
|
> Example: `+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` means add `llama, claude-2` to model list, and remove `gpt-3.5-turbo` from list, and display `gpt-4-1106-preview` as `gpt-4-turbo`.
|
||||||
|
|
||||||
|
To control custom models, use `+` to add a custom model, use `-` to hide a model, use `name=displayName` to customize model name, separated by comma.
|
||||||
|
|
||||||
|
User `-all` to disable all default models, `+all` to enable all default models.
|
||||||
|
|
||||||
|
### `WHITE_WEBDEV_ENDPOINTS` (optional)
|
||||||
|
|
||||||
|
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format:
|
||||||
|
- Each address must be a complete endpoint
|
||||||
|
> `https://xxxx/yyy`
|
||||||
|
- Multiple addresses are connected by ', '
|
||||||
|
|
||||||
|
### `DEFAULT_INPUT_TEMPLATE` (optional)
|
||||||
|
|
||||||
|
Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -230,8 +295,8 @@ yarn dev
|
|||||||
docker pull yidadaa/chatgpt-next-web
|
docker pull yidadaa/chatgpt-next-web
|
||||||
|
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 \
|
||||||
-e OPENAI_API_KEY="sk-xxxx" \
|
-e OPENAI_API_KEY=sk-xxxx \
|
||||||
-e CODE="your-password" \
|
-e CODE=your-password \
|
||||||
yidadaa/chatgpt-next-web
|
yidadaa/chatgpt-next-web
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -239,9 +304,9 @@ You can start service behind a proxy:
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 \
|
||||||
-e OPENAI_API_KEY="sk-xxxx" \
|
-e OPENAI_API_KEY=sk-xxxx \
|
||||||
-e CODE="your-password" \
|
-e CODE=your-password \
|
||||||
-e PROXY_URL="http://localhost:7890" \
|
-e PROXY_URL=http://localhost:7890 \
|
||||||
yidadaa/chatgpt-next-web
|
yidadaa/chatgpt-next-web
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -257,6 +322,20 @@ If your proxy needs password, use:
|
|||||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Synchronizing Chat Records (UpStash)
|
||||||
|
|
||||||
|
| [简体中文](./docs/synchronise-chat-logs-cn.md) | [English](./docs/synchronise-chat-logs-en.md) | [Italiano](./docs/synchronise-chat-logs-es.md) | [日本語](./docs/synchronise-chat-logs-ja.md) | [한국어](./docs/synchronise-chat-logs-ko.md)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
> Please go to the [docs][./docs] directory for more documentation instructions.
|
||||||
|
|
||||||
|
- [Deploy with cloudflare (Deprecated)](./docs/cloudflare-pages-en.md)
|
||||||
|
- [Frequent Ask Questions](./docs/faq-en.md)
|
||||||
|
- [How to add a new translation](./docs/translation.md)
|
||||||
|
- [How to use Vercel (No English)](./docs/vercel-cn.md)
|
||||||
|
- [User Manual (Only Chinese, WIP)](./docs/user-manual-cn.md)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
@@ -299,10 +378,17 @@ If you want to add a new translation, read this [document](./docs/translation.md
|
|||||||
[@AnsonHyq](https://github.com/AnsonHyq)
|
[@AnsonHyq](https://github.com/AnsonHyq)
|
||||||
[@synwith](https://github.com/synwith)
|
[@synwith](https://github.com/synwith)
|
||||||
[@piksonGit](https://github.com/piksonGit)
|
[@piksonGit](https://github.com/piksonGit)
|
||||||
|
[@ouyangzhiping](https://github.com/ouyangzhiping)
|
||||||
|
[@wenjiavv](https://github.com/wenjiavv)
|
||||||
|
[@LeXwDeX](https://github.com/LeXwDeX)
|
||||||
|
[@Licoy](https://github.com/Licoy)
|
||||||
|
[@shangmin2009](https://github.com/shangmin2009)
|
||||||
|
|
||||||
### Contributor
|
### Contributors
|
||||||
|
|
||||||
[Contributors](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
<a href="https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=ChatGPTNextWeb/ChatGPT-Next-Web" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## LICENSE
|
## LICENSE
|
||||||
|
|
||||||
|
|||||||
87
README_CN.md
87
README_CN.md
@@ -1,14 +1,16 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/images/icon.svg" alt="预览"/>
|
<img src="./docs/images/icon.svg" alt="预览"/>
|
||||||
|
|
||||||
<h1 align="center">ChatGPT Next Web</h1>
|
<h1 align="center">NextChat</h1>
|
||||||
|
|
||||||
一键免费部署你的私人 ChatGPT 网页应用。
|
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||||
|
|
||||||
[演示 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)
|
[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
|
||||||
|
|
||||||
[](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://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://zeabur.com/templates/ZBUEFA)
|
||||||
|
|
||||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||

|

|
||||||
@@ -19,7 +21,7 @@
|
|||||||
|
|
||||||
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||||
2. 点击右侧按钮开始部署:
|
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;
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE;
|
||||||
3. 部署完毕后,即可开始使用;
|
3. 部署完毕后,即可开始使用;
|
||||||
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ code1,code2,code3
|
|||||||
|
|
||||||
### `OPENAI_API_KEY` (必填项)
|
### `OPENAI_API_KEY` (必填项)
|
||||||
|
|
||||||
OpanAI 密钥,你在 openai 账户页面申请的 api key。
|
OpanAI 密钥,你在 openai 账户页面申请的 api key,使用英文逗号隔开多个 key,这样可以随机轮询这些 key。
|
||||||
|
|
||||||
### `CODE` (可选)
|
### `CODE` (可选)
|
||||||
|
|
||||||
@@ -90,6 +92,40 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
|
|||||||
|
|
||||||
指定 OpenAI 中的组织 ID。
|
指定 OpenAI 中的组织 ID。
|
||||||
|
|
||||||
|
### `AZURE_URL` (可选)
|
||||||
|
|
||||||
|
> 形如:https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||||
|
|
||||||
|
Azure 部署地址。
|
||||||
|
|
||||||
|
### `AZURE_API_KEY` (可选)
|
||||||
|
|
||||||
|
Azure 密钥。
|
||||||
|
|
||||||
|
### `AZURE_API_VERSION` (可选)
|
||||||
|
|
||||||
|
Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。
|
||||||
|
|
||||||
|
### `GOOGLE_API_KEY` (optional)
|
||||||
|
|
||||||
|
Google Gemini Pro 密钥.
|
||||||
|
|
||||||
|
### `GOOGLE_URL` (optional)
|
||||||
|
|
||||||
|
Google Gemini Pro Api Url.
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_KEY` (optional)
|
||||||
|
|
||||||
|
anthropic claude Api Key.
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_VERSION` (optional)
|
||||||
|
|
||||||
|
anthropic claude Api version.
|
||||||
|
|
||||||
|
### `ANTHROPIC_URL` (optional)
|
||||||
|
|
||||||
|
anthropic claude Api Url.
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (可选)
|
### `HIDE_USER_API_KEY` (可选)
|
||||||
|
|
||||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||||
@@ -98,9 +134,30 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
|
|||||||
|
|
||||||
如果你不想让用户使用 GPT-4,将此环境变量设置为 1 即可。
|
如果你不想让用户使用 GPT-4,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
### `HIDE_BALANCE_QUERY` (可选)
|
### `ENABLE_BALANCE_QUERY` (可选)
|
||||||
|
|
||||||
如果你不想让用户查询余额,将此环境变量设置为 1 即可。
|
如果你想启用余额查询功能,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
|
### `DISABLE_FAST_LINK` (可选)
|
||||||
|
|
||||||
|
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
|
### `WHITE_WEBDEV_ENDPOINTS` (可选)
|
||||||
|
|
||||||
|
如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求:
|
||||||
|
- 每一个地址必须是一个完整的 endpoint
|
||||||
|
> `https://xxxx/xxx`
|
||||||
|
- 多个地址以`,`相连
|
||||||
|
|
||||||
|
### `CUSTOM_MODELS` (可选)
|
||||||
|
|
||||||
|
> 示例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` 表示增加 `qwen-7b-chat` 和 `glm-6b` 到模型列表,而从列表中删除 `gpt-3.5-turbo`,并将 `gpt-4-1106-preview` 模型名字展示为 `gpt-4-turbo`。
|
||||||
|
> 如果你想先禁用所有模型,再启用指定模型,可以使用 `-all,+gpt-3.5-turbo`,则表示仅启用 `gpt-3.5-turbo`
|
||||||
|
|
||||||
|
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
||||||
|
|
||||||
|
### `DEFAULT_INPUT_TEMPLATE` (可选)
|
||||||
|
自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -114,7 +171,7 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
|
|||||||
OPENAI_API_KEY=<your api key here>
|
OPENAI_API_KEY=<your api key here>
|
||||||
|
|
||||||
# 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址
|
# 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址
|
||||||
BASE_URL=https://chatgpt1.nextweb.fun/api/proxy
|
BASE_URL=https://b.nextweb.fun/api/proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
### 本地开发
|
### 本地开发
|
||||||
@@ -135,8 +192,8 @@ BASE_URL=https://chatgpt1.nextweb.fun/api/proxy
|
|||||||
docker pull yidadaa/chatgpt-next-web
|
docker pull yidadaa/chatgpt-next-web
|
||||||
|
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 \
|
||||||
-e OPENAI_API_KEY="sk-xxxx" \
|
-e OPENAI_API_KEY=sk-xxxx \
|
||||||
-e CODE="页面访问密码" \
|
-e CODE=页面访问密码 \
|
||||||
yidadaa/chatgpt-next-web
|
yidadaa/chatgpt-next-web
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,10 +201,10 @@ docker run -d -p 3000:3000 \
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 \
|
||||||
-e OPENAI_API_KEY="sk-xxxx" \
|
-e OPENAI_API_KEY=sk-xxxx \
|
||||||
-e CODE="页面访问密码" \
|
-e CODE=页面访问密码 \
|
||||||
--net=host \
|
--net=host \
|
||||||
-e PROXY_URL="http://127.0.0.1:7890" \
|
-e PROXY_URL=http://127.0.0.1:7890 \
|
||||||
yidadaa/chatgpt-next-web
|
yidadaa/chatgpt-next-web
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,6 +236,10 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s
|
|||||||
|
|
||||||
[见项目贡献者列表](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
[见项目贡献者列表](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||||
|
|
||||||
|
### 相关项目
|
||||||
|
|
||||||
|
- [one-api](https://github.com/songquanpeng/one-api): 一站式大模型额度管理平台,支持市面上所有主流大语言模型
|
||||||
|
|
||||||
## 开源协议
|
## 开源协议
|
||||||
|
|
||||||
[MIT](https://opensource.org/license/mit/)
|
[MIT](https://opensource.org/license/mit/)
|
||||||
|
|||||||
173
README_ES.md
173
README_ES.md
@@ -1,173 +0,0 @@
|
|||||||
<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.
|
|
||||||
|
|
||||||
### `HIDE_BALANCE_QUERY` (Opcional)
|
|
||||||
|
|
||||||
Si no desea que los usuarios consulte el saldo, 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
|
|
||||||
|
|
||||||
[MIT](https://opensource.org/license/mit/)
|
|
||||||
189
app/api/anthropic/[...path]/route.ts
Normal file
189
app/api/anthropic/[...path]/route.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
ANTHROPIC_BASE_URL,
|
||||||
|
Anthropic,
|
||||||
|
ApiPath,
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
ModelProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "../../auth";
|
||||||
|
import { collectModelTable } from "@/app/utils/model";
|
||||||
|
|
||||||
|
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Anthropic Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subpath = params.path.join("/");
|
||||||
|
|
||||||
|
if (!ALLOWD_PATH.has(subpath)) {
|
||||||
|
console.log("[Anthropic Route] forbidden path ", subpath);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + subpath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Claude);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Anthropic] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = handle;
|
||||||
|
export const POST = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = [
|
||||||
|
"arn1",
|
||||||
|
"bom1",
|
||||||
|
"cdg1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"dub1",
|
||||||
|
"fra1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"lhr1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
async function request(req: NextRequest) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
let authHeaderName = "x-api-key";
|
||||||
|
let authValue =
|
||||||
|
req.headers.get(authHeaderName) ||
|
||||||
|
req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() ||
|
||||||
|
serverConfig.anthropicApiKey ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, "");
|
||||||
|
|
||||||
|
let baseUrl =
|
||||||
|
serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_BASE_URL;
|
||||||
|
|
||||||
|
if (!baseUrl.startsWith("http")) {
|
||||||
|
baseUrl = `https://${baseUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchUrl = `${baseUrl}${path}`;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
[authHeaderName]: authValue,
|
||||||
|
"anthropic-version":
|
||||||
|
req.headers.get("anthropic-version") ||
|
||||||
|
serverConfig.anthropicApiVersion ||
|
||||||
|
Anthropic.Vision,
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
// #1815 try to refuse some request to some models
|
||||||
|
if (serverConfig.customModels && req.body) {
|
||||||
|
try {
|
||||||
|
const modelTable = collectModelTable(
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
serverConfig.customModels,
|
||||||
|
);
|
||||||
|
const clonedBody = await req.text();
|
||||||
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (modelTable[jsonBody?.model ?? ""].available === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Anthropic] filter`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("[Anthropic request]", fetchOptions.headers, req.method);
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[Anthropic response]",
|
||||||
|
res.status,
|
||||||
|
" ",
|
||||||
|
res.headers,
|
||||||
|
res.url,
|
||||||
|
);
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to disable nginx buffering
|
||||||
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { getServerSideConfig } from "../config/server";
|
import { getServerSideConfig } from "../config/server";
|
||||||
import md5 from "spark-md5";
|
import md5 from "spark-md5";
|
||||||
import { ACCESS_CODE_PREFIX } from "../constant";
|
import { ACCESS_CODE_PREFIX, ModelProvider } from "../constant";
|
||||||
|
|
||||||
function getIP(req: NextRequest) {
|
function getIP(req: NextRequest) {
|
||||||
let ip = req.ip ?? req.headers.get("x-real-ip");
|
let ip = req.ip ?? req.headers.get("x-real-ip");
|
||||||
@@ -16,19 +16,19 @@ function getIP(req: NextRequest) {
|
|||||||
|
|
||||||
function parseApiKey(bearToken: string) {
|
function parseApiKey(bearToken: string) {
|
||||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||||
const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
const isApiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
|
accessCode: isApiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
|
||||||
apiKey: isOpenAiKey ? token : "",
|
apiKey: isApiKey ? token : "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function auth(req: NextRequest) {
|
export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
||||||
const authToken = req.headers.get("Authorization") ?? "";
|
const authToken = req.headers.get("Authorization") ?? "";
|
||||||
|
|
||||||
// check if it is openai api key or user token
|
// check if it is openai api key or user token
|
||||||
const { accessCode, apiKey: token } = parseApiKey(authToken);
|
const { accessCode, apiKey } = parseApiKey(authToken);
|
||||||
|
|
||||||
const hashedCode = md5.hash(accessCode ?? "").trim();
|
const hashedCode = md5.hash(accessCode ?? "").trim();
|
||||||
|
|
||||||
@@ -39,19 +39,52 @@ export function auth(req: NextRequest) {
|
|||||||
console.log("[User IP] ", getIP(req));
|
console.log("[User IP] ", getIP(req));
|
||||||
console.log("[Time] ", new Date().toLocaleString());
|
console.log("[Time] ", new Date().toLocaleString());
|
||||||
|
|
||||||
if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) {
|
if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !apiKey) {
|
||||||
return {
|
return {
|
||||||
error: true,
|
error: true,
|
||||||
msg: !accessCode ? "empty access code" : "wrong access code",
|
msg: !accessCode ? "empty access code" : "wrong access code",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (serverConfig.hideUserApiKey && !!apiKey) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to access with your own api key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// if user does not provide an api key, inject system api key
|
// if user does not provide an api key, inject system api key
|
||||||
if (!token) {
|
if (!apiKey) {
|
||||||
const apiKey = serverConfig.apiKey;
|
const serverConfig = getServerSideConfig();
|
||||||
if (apiKey) {
|
|
||||||
|
// const systemApiKey =
|
||||||
|
// modelProvider === ModelProvider.GeminiPro
|
||||||
|
// ? serverConfig.googleApiKey
|
||||||
|
// : serverConfig.isAzure
|
||||||
|
// ? serverConfig.azureApiKey
|
||||||
|
// : serverConfig.apiKey;
|
||||||
|
|
||||||
|
let systemApiKey: string | undefined;
|
||||||
|
|
||||||
|
switch (modelProvider) {
|
||||||
|
case ModelProvider.GeminiPro:
|
||||||
|
systemApiKey = serverConfig.googleApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Claude:
|
||||||
|
systemApiKey = serverConfig.anthropicApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.GPT:
|
||||||
|
default:
|
||||||
|
if (serverConfig.isAzure) {
|
||||||
|
systemApiKey = serverConfig.azureApiKey;
|
||||||
|
} else {
|
||||||
|
systemApiKey = serverConfig.apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemApiKey) {
|
||||||
console.log("[Auth] use system api key");
|
console.log("[Auth] use system api key");
|
||||||
req.headers.set("Authorization", `Bearer ${apiKey}`);
|
req.headers.set("Authorization", `Bearer ${systemApiKey}`);
|
||||||
} else {
|
} else {
|
||||||
console.log("[Auth] admin did not provide an api key");
|
console.log("[Auth] admin did not provide an api key");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,103 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSideConfig } from "../config/server";
|
||||||
|
import { DEFAULT_MODELS, OPENAI_BASE_URL, GEMINI_BASE_URL } from "../constant";
|
||||||
|
import { collectModelTable } from "../utils/model";
|
||||||
|
import { makeAzurePath } from "../azure";
|
||||||
|
|
||||||
export const OPENAI_URL = "api.openai.com";
|
const serverConfig = getServerSideConfig();
|
||||||
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) {
|
export async function requestOpenai(req: NextRequest) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const authValue = req.headers.get("Authorization") ?? "";
|
|
||||||
const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
var authValue,
|
||||||
|
authHeaderName = "";
|
||||||
|
if (serverConfig.isAzure) {
|
||||||
|
authValue =
|
||||||
|
req.headers
|
||||||
|
.get("Authorization")
|
||||||
|
?.trim()
|
||||||
|
.replaceAll("Bearer ", "")
|
||||||
|
.trim() ?? "";
|
||||||
|
|
||||||
|
authHeaderName = "api-key";
|
||||||
|
} else {
|
||||||
|
authValue = req.headers.get("Authorization") ?? "";
|
||||||
|
authHeaderName = "Authorization";
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||||
"/api/openai/",
|
"/api/openai/",
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
|
||||||
let baseUrl = BASE_URL;
|
let baseUrl =
|
||||||
|
serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL;
|
||||||
|
|
||||||
if (!baseUrl.startsWith("http")) {
|
if (!baseUrl.startsWith("http")) {
|
||||||
baseUrl = `${PROTOCOL}://${baseUrl}`;
|
baseUrl = `https://${baseUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[Proxy] ", openaiPath);
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
console.log("[Base Url]", baseUrl);
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
if (process.env.OPENAI_ORG_ID) {
|
const timeoutId = setTimeout(
|
||||||
console.log("[Org ID]", process.env.OPENAI_ORG_ID);
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (serverConfig.isAzure) {
|
||||||
|
if (!serverConfig.azureApiVersion) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: true,
|
||||||
|
message: `missing AZURE_API_VERSION in server env vars`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
path = makeAzurePath(path, serverConfig.azureApiVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const fetchUrl = `${baseUrl}/${path}`;
|
||||||
controller.abort();
|
|
||||||
}, 10 * 60 * 1000);
|
|
||||||
|
|
||||||
const fetchUrl = `${baseUrl}/${openaiPath}`;
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
Authorization: authValue,
|
[authHeaderName]: authValue,
|
||||||
...(process.env.OPENAI_ORG_ID && {
|
...(serverConfig.openaiOrgId && {
|
||||||
"OpenAI-Organization": process.env.OPENAI_ORG_ID,
|
"OpenAI-Organization": serverConfig.openaiOrgId,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
method: req.method,
|
method: req.method,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
|
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||||
|
redirect: "manual",
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
duplex: "half",
|
duplex: "half",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// #1815 try to refuse gpt4 request
|
// #1815 try to refuse gpt4 request
|
||||||
if (DISABLE_GPT4 && req.body) {
|
if (serverConfig.customModels && req.body) {
|
||||||
try {
|
try {
|
||||||
|
const modelTable = collectModelTable(
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
serverConfig.customModels,
|
||||||
|
);
|
||||||
const clonedBody = await req.text();
|
const clonedBody = await req.text();
|
||||||
fetchOptions.body = clonedBody;
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
const jsonBody = JSON.parse(clonedBody);
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
if ((jsonBody?.model ?? "").includes("gpt-4")) {
|
// not undefined and is false
|
||||||
|
if (modelTable[jsonBody?.model ?? ""].available === false) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
message: "you are not allowed to use gpt-4 model",
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
@@ -75,12 +112,37 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(fetchUrl, fetchOptions);
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// Extract the OpenAI-Organization header from the response
|
||||||
|
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
|
||||||
|
|
||||||
|
// Check if serverConfig.openaiOrgId is defined and not an empty string
|
||||||
|
if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") {
|
||||||
|
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
|
||||||
|
console.log("[Org ID]", openaiOrganizationHeader);
|
||||||
|
} else {
|
||||||
|
console.log("[Org ID] is not set up.");
|
||||||
|
}
|
||||||
|
|
||||||
// to prevent browser prompt for credentials
|
// to prevent browser prompt for credentials
|
||||||
const newHeaders = new Headers(res.headers);
|
const newHeaders = new Headers(res.headers);
|
||||||
newHeaders.delete("www-authenticate");
|
newHeaders.delete("www-authenticate");
|
||||||
// to disable nginx buffering
|
// to disable nginx buffering
|
||||||
newHeaders.set("X-Accel-Buffering", "no");
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
|
||||||
|
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
|
||||||
|
// Also, this is to prevent the header from being sent to the client
|
||||||
|
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
|
||||||
|
newHeaders.delete("OpenAI-Organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
|
||||||
|
// So if the streaming is disabled, we need to remove the content-encoding header
|
||||||
|
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
|
||||||
|
// The browser will try to decode the response with brotli and fail
|
||||||
|
newHeaders.delete("content-encoding");
|
||||||
|
|
||||||
|
|
||||||
return new Response(res.body, {
|
return new Response(res.body, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { getServerSideConfig } from "../../config/server";
|
|||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
// Danger! Don not write any secret value here!
|
// Danger! Do not hard code any secret value here!
|
||||||
// 警告!不要在这里写入任何敏感信息!
|
// 警告!不要在这里写入任何敏感信息!
|
||||||
const DANGER_CONFIG = {
|
const DANGER_CONFIG = {
|
||||||
needCode: serverConfig.needCode,
|
needCode: serverConfig.needCode,
|
||||||
hideUserApiKey: serverConfig.hideUserApiKey,
|
hideUserApiKey: serverConfig.hideUserApiKey,
|
||||||
disableGPT4: serverConfig.disableGPT4,
|
disableGPT4: serverConfig.disableGPT4,
|
||||||
hideBalanceQuery: serverConfig.hideBalanceQuery,
|
hideBalanceQuery: serverConfig.hideBalanceQuery,
|
||||||
|
disableFastLink: serverConfig.disableFastLink,
|
||||||
|
customModels: serverConfig.customModels,
|
||||||
|
defaultModel: serverConfig.defaultModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
116
app/api/google/[...path]/route.ts
Normal file
116
app/api/google/[...path]/route.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "../../auth";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant";
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Google Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
|
||||||
|
|
||||||
|
if (!baseUrl.startsWith("http")) {
|
||||||
|
baseUrl = `https://${baseUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll("/api/google/", "");
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.GeminiPro);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearToken = req.headers.get("Authorization") ?? "";
|
||||||
|
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||||
|
|
||||||
|
const key = token ? token : serverConfig.googleApiKey;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `missing GOOGLE_API_KEY in server env vars`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUrl = `${baseUrl}/${path}?key=${key}`;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to disable nginx buffering
|
||||||
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = handle;
|
||||||
|
export const POST = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = [
|
||||||
|
"bom1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
|
import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
|
||||||
import { getServerSideConfig } from "@/app/config/server";
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
import { OpenaiPath } from "@/app/constant";
|
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "../../auth";
|
import { auth } from "../../auth";
|
||||||
@@ -45,7 +45,7 @@ async function handle(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const authResult = auth(req);
|
const authResult = auth(req, ModelProvider.GPT);
|
||||||
if (authResult.error) {
|
if (authResult.error) {
|
||||||
return NextResponse.json(authResult, {
|
return NextResponse.json(authResult, {
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -75,3 +75,22 @@ export const GET = handle;
|
|||||||
export const POST = handle;
|
export const POST = handle;
|
||||||
|
|
||||||
export const runtime = "edge";
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = [
|
||||||
|
"arn1",
|
||||||
|
"bom1",
|
||||||
|
"cdg1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"dub1",
|
||||||
|
"fra1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"lhr1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|||||||
93
app/api/provider/[...path]/route.ts
Normal file
93
app/api/provider/[...path]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import * as ProviderTemplates from "@/app/client/providers";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cloneDeep } from "lodash-es";
|
||||||
|
import {
|
||||||
|
disableSystemApiKey,
|
||||||
|
makeUrlsUsable,
|
||||||
|
modelNameRequestHeader,
|
||||||
|
} from "@/app/client/common";
|
||||||
|
import { collectModelTable } from "@/app/utils/model";
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
const [providerName] = params.path;
|
||||||
|
const { headers } = req;
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
const modelName = headers.get(modelNameRequestHeader);
|
||||||
|
|
||||||
|
const ProviderTemplate = Object.values(ProviderTemplates).find(
|
||||||
|
(t) => t.prototype.name === providerName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ProviderTemplate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: "No provider found: " + providerName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #1815 try to refuse gpt4 request
|
||||||
|
if (modelName && serverConfig.customModels) {
|
||||||
|
try {
|
||||||
|
const modelTable = collectModelTable([], serverConfig.customModels);
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (modelTable[modelName]?.available === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${modelName} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("models filter", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = disableSystemApiKey(
|
||||||
|
makeUrlsUsable(cloneDeep(serverConfig), [
|
||||||
|
"anthropicUrl",
|
||||||
|
"azureUrl",
|
||||||
|
"googleUrl",
|
||||||
|
"baseUrl",
|
||||||
|
]),
|
||||||
|
["anthropicApiKey", "azureApiKey", "googleApiKey", "apiKey"],
|
||||||
|
serverConfig.needCode &&
|
||||||
|
ProviderTemplate !== ProviderTemplates.NextChatProvider, // if it must take a access code in the req, do not provide system-keys for Non-nextchat providers
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = Object.assign({}, req, {
|
||||||
|
subpath: params.path.join("/"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new ProviderTemplate().serverSideRequestHandler(request, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = handle;
|
||||||
|
export const POST = handle;
|
||||||
|
export const PUT = handle;
|
||||||
|
export const PATCH = handle;
|
||||||
|
export const DELETE = handle;
|
||||||
|
export const OPTIONS = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = Array.from(
|
||||||
|
new Set(
|
||||||
|
Object.values(ProviderTemplates).reduce(
|
||||||
|
(arr, t) => [...arr, ...(t.prototype.preferredRegion ?? [])],
|
||||||
|
[] as string[],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
73
app/api/upstash/[action]/[...key]/route.ts
Normal file
73
app/api/upstash/[action]/[...key]/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { action: string; key: string[] } },
|
||||||
|
) {
|
||||||
|
const requestUrl = new URL(req.url);
|
||||||
|
const endpoint = requestUrl.searchParams.get("endpoint");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
const [...key] = params.key;
|
||||||
|
// only allow to request to *.upstash.io
|
||||||
|
if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + params.key.join("/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// only allow upstash get and set method
|
||||||
|
if (params.action !== "get" && params.action !== "set") {
|
||||||
|
console.log("[Upstash Route] forbidden action ", params.action);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + params.action,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`;
|
||||||
|
|
||||||
|
const method = req.method;
|
||||||
|
const shouldNotHaveBody = ["get", "head"].includes(
|
||||||
|
method?.toLowerCase() ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
authorization: req.headers.get("authorization") ?? "",
|
||||||
|
},
|
||||||
|
body: shouldNotHaveBody ? null : req.body,
|
||||||
|
method,
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Upstash Proxy]", targetUrl, fetchOptions);
|
||||||
|
const fetchResult = await fetch(targetUrl, fetchOptions);
|
||||||
|
|
||||||
|
console.log("[Any Proxy]", targetUrl, {
|
||||||
|
status: fetchResult.status,
|
||||||
|
statusText: fetchResult.statusText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = handle;
|
||||||
|
export const GET = handle;
|
||||||
|
export const OPTIONS = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
144
app/api/webdav/[...path]/route.ts
Normal file
144
app/api/webdav/[...path]/route.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
|
||||||
|
const config = getServerSideConfig();
|
||||||
|
|
||||||
|
const mergedAllowedWebDavEndpoints = [
|
||||||
|
...internalAllowedWebDavEndpoints,
|
||||||
|
...config.allowedWebDevEndpoints,
|
||||||
|
].filter((domain) => Boolean(domain.trim()));
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
const folder = STORAGE_KEY;
|
||||||
|
const fileName = `${folder}/backup.json`;
|
||||||
|
|
||||||
|
const requestUrl = new URL(req.url);
|
||||||
|
let endpoint = requestUrl.searchParams.get("endpoint");
|
||||||
|
|
||||||
|
// Validate the endpoint to prevent potential SSRF attacks
|
||||||
|
if (
|
||||||
|
!mergedAllowedWebDavEndpoints.some(
|
||||||
|
(allowedEndpoint) => endpoint?.startsWith(allowedEndpoint),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "Invalid endpoint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint?.endsWith("/")) {
|
||||||
|
endpoint += "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointPath = params.path.join("/");
|
||||||
|
const targetPath = `${endpoint}${endpointPath}`;
|
||||||
|
|
||||||
|
// only allow MKCOL, GET, PUT
|
||||||
|
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + targetPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// for MKCOL request, only allow request ${folder}
|
||||||
|
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + targetPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// for GET request, only allow request ending with fileName
|
||||||
|
if (req.method === "GET" && !targetPath.endsWith(fileName)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + targetPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// for PUT request, only allow request ending with fileName
|
||||||
|
if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + targetPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = targetPath;
|
||||||
|
|
||||||
|
const method = req.method;
|
||||||
|
const shouldNotHaveBody = ["get", "head"].includes(
|
||||||
|
method?.toLowerCase() ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
authorization: req.headers.get("authorization") ?? "",
|
||||||
|
},
|
||||||
|
body: shouldNotHaveBody ? null : req.body,
|
||||||
|
redirect: "manual",
|
||||||
|
method,
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchResult;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetchResult = await fetch(targetUrl, fetchOptions);
|
||||||
|
} finally {
|
||||||
|
console.log(
|
||||||
|
"[Any Proxy]",
|
||||||
|
targetUrl,
|
||||||
|
{
|
||||||
|
method: req.method,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: fetchResult?.status,
|
||||||
|
statusText: fetchResult?.statusText,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PUT = handle;
|
||||||
|
export const GET = handle;
|
||||||
|
export const OPTIONS = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
9
app/azure.ts
Normal file
9
app/azure.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function makeAzurePath(path: string, apiVersion: string) {
|
||||||
|
// should omit /v1 prefix
|
||||||
|
path = path.replaceAll("v1/", "");
|
||||||
|
|
||||||
|
// should add api-key to query string
|
||||||
|
path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`;
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import { ACCESS_CODE_PREFIX } from "../constant";
|
import {
|
||||||
import { ChatMessage, ModelType, useAccessStore } from "../store";
|
ACCESS_CODE_PREFIX,
|
||||||
|
Azure,
|
||||||
|
ModelProvider,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "../constant";
|
||||||
|
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
|
||||||
import { ChatGPTApi } from "./platforms/openai";
|
import { ChatGPTApi } from "./platforms/openai";
|
||||||
|
import { GeminiProApi } from "./platforms/google";
|
||||||
|
import { ClaudeApi } from "./platforms/anthropic";
|
||||||
export const ROLES = ["system", "user", "assistant"] as const;
|
export const ROLES = ["system", "user", "assistant"] as const;
|
||||||
export type MessageRole = (typeof ROLES)[number];
|
export type MessageRole = (typeof ROLES)[number];
|
||||||
|
|
||||||
export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
|
export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
|
||||||
export type ChatModel = ModelType;
|
export type ChatModel = ModelType;
|
||||||
|
|
||||||
|
export interface MultimodalContent {
|
||||||
|
type: "text" | "image_url";
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestMessage {
|
export interface RequestMessage {
|
||||||
role: MessageRole;
|
role: MessageRole;
|
||||||
content: string;
|
content: string | MultimodalContent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMConfig {
|
export interface LLMConfig {
|
||||||
@@ -41,6 +55,13 @@ export interface LLMUsage {
|
|||||||
export interface LLMModel {
|
export interface LLMModel {
|
||||||
name: string;
|
name: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
provider: LLMModelProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMModelProvider {
|
||||||
|
id: string;
|
||||||
|
providerName: string;
|
||||||
|
providerType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class LLMApi {
|
export abstract class LLMApi {
|
||||||
@@ -73,8 +94,17 @@ interface ChatProvider {
|
|||||||
export class ClientApi {
|
export class ClientApi {
|
||||||
public llm: LLMApi;
|
public llm: LLMApi;
|
||||||
|
|
||||||
constructor() {
|
constructor(provider: ModelProvider = ModelProvider.GPT) {
|
||||||
this.llm = new ChatGPTApi();
|
switch (provider) {
|
||||||
|
case ModelProvider.GeminiPro:
|
||||||
|
this.llm = new GeminiProApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Claude:
|
||||||
|
this.llm = new ClaudeApi();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.llm = new ChatGPTApi();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config() {}
|
config() {}
|
||||||
@@ -93,7 +123,7 @@ export class ClientApi {
|
|||||||
{
|
{
|
||||||
from: "human",
|
from: "human",
|
||||||
value:
|
value:
|
||||||
"Share from [ChatGPT Next Web]: https://github.com/Yidadaa/ChatGPT-Next-Web",
|
"Share from [NextChat]: https://github.com/Yidadaa/ChatGPT-Next-Web",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
||||||
@@ -123,28 +153,38 @@ export class ClientApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ClientApi();
|
|
||||||
|
|
||||||
export function getHeaders() {
|
export function getHeaders() {
|
||||||
const accessStore = useAccessStore.getState();
|
const accessStore = useAccessStore.getState();
|
||||||
let headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-requested-with": "XMLHttpRequest",
|
Accept: "application/json",
|
||||||
};
|
};
|
||||||
|
const modelConfig = useChatStore.getState().currentSession().mask.modelConfig;
|
||||||
const makeBearer = (token: string) => `Bearer ${token.trim()}`;
|
const isGoogle = modelConfig.model.startsWith("gemini");
|
||||||
|
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||||
|
const authHeader = isAzure ? "api-key" : "Authorization";
|
||||||
|
const apiKey = isGoogle
|
||||||
|
? accessStore.googleApiKey
|
||||||
|
: isAzure
|
||||||
|
? accessStore.azureApiKey
|
||||||
|
: accessStore.openaiApiKey;
|
||||||
|
const clientConfig = getClientConfig();
|
||||||
|
const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`;
|
||||||
const validString = (x: string) => x && x.length > 0;
|
const validString = (x: string) => x && x.length > 0;
|
||||||
|
|
||||||
// use user's api key first
|
// when using google api in app, not set auth header
|
||||||
if (validString(accessStore.token)) {
|
if (!(isGoogle && clientConfig?.isApp)) {
|
||||||
headers.Authorization = makeBearer(accessStore.token);
|
// use user's api key first
|
||||||
} else if (
|
if (validString(apiKey)) {
|
||||||
accessStore.enabledAccessControl() &&
|
headers[authHeader] = makeBearer(apiKey);
|
||||||
validString(accessStore.accessCode)
|
} else if (
|
||||||
) {
|
accessStore.enabledAccessControl() &&
|
||||||
headers.Authorization = makeBearer(
|
validString(accessStore.accessCode)
|
||||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
) {
|
||||||
);
|
headers[authHeader] = makeBearer(
|
||||||
|
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
|
|||||||
7
app/client/common/index.ts
Normal file
7
app/client/common/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./types";
|
||||||
|
|
||||||
|
export * from "./locale";
|
||||||
|
|
||||||
|
export * from "./utils";
|
||||||
|
|
||||||
|
export const modelNameRequestHeader = "x-nextchat-model-name";
|
||||||
19
app/client/common/locale.ts
Normal file
19
app/client/common/locale.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Lang, getLang } from "@/app/locales";
|
||||||
|
|
||||||
|
interface PlainConfig {
|
||||||
|
[k: string]: PlainConfig | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocaleMap<
|
||||||
|
TextPlainConfig extends PlainConfig,
|
||||||
|
Default extends Lang,
|
||||||
|
> = Partial<Record<Lang, TextPlainConfig>> & {
|
||||||
|
[name in Default]: TextPlainConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLocaleText<
|
||||||
|
TextPlainConfig extends PlainConfig,
|
||||||
|
DefaultLang extends Lang,
|
||||||
|
>(textMap: LocaleMap<TextPlainConfig, DefaultLang>, defaultLang: DefaultLang) {
|
||||||
|
return textMap[getLang()] || textMap[defaultLang];
|
||||||
|
}
|
||||||
211
app/client/common/types.ts
Normal file
211
app/client/common/types.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { RequestMessage } from "../api";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export { type RequestMessage };
|
||||||
|
|
||||||
|
// ===================================== LLM Types start ======================================
|
||||||
|
|
||||||
|
export interface ModelConfig {
|
||||||
|
temperature: number;
|
||||||
|
top_p: number;
|
||||||
|
presence_penalty: number;
|
||||||
|
frequency_penalty: number;
|
||||||
|
max_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSettings extends Omit<ModelConfig, "max_tokens"> {
|
||||||
|
global_max_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelTemplate = {
|
||||||
|
name: string; // id of model in a provider
|
||||||
|
displayName: string;
|
||||||
|
isVisionModel?: boolean;
|
||||||
|
isDefaultActive: boolean; // model is initialized to be active
|
||||||
|
isDefaultSelected?: boolean; // model is initialized to be as default used model
|
||||||
|
max_tokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Model extends Omit<ModelTemplate, "isDefaultActive"> {
|
||||||
|
providerTemplateName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
providerName: string;
|
||||||
|
available: boolean;
|
||||||
|
customized: boolean; // Only customized model is allowed to be modified
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelInfo extends Pick<ModelTemplate, "name"> {
|
||||||
|
[k: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================== LLM Types end ======================================
|
||||||
|
|
||||||
|
// ===================================== Chat Request Types start ======================================
|
||||||
|
|
||||||
|
export interface ChatRequestPayload {
|
||||||
|
messages: RequestMessage[];
|
||||||
|
context: {
|
||||||
|
isApp: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandChatRequestPayload extends ChatRequestPayload {
|
||||||
|
modelConfig: ModelConfig;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InternalChatRequestPayload<SettingKeys extends string = "">
|
||||||
|
extends StandChatRequestPayload {
|
||||||
|
providerConfig: Partial<Record<SettingKeys, string>>;
|
||||||
|
isVisionModel: Model["isVisionModel"];
|
||||||
|
stream: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderRequestPayload {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InternalChatHandlers {
|
||||||
|
onProgress: (message: string, chunk: string) => void;
|
||||||
|
onFinish: (message: string) => void;
|
||||||
|
onError: (err: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatHandlers extends InternalChatHandlers {
|
||||||
|
onProgress: (chunk: string) => void;
|
||||||
|
onFinish: () => void;
|
||||||
|
onFlash: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================== Chat Request Types end ======================================
|
||||||
|
|
||||||
|
// ===================================== Chat Response Types start ======================================
|
||||||
|
|
||||||
|
export interface StandChatReponseMessage {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================== Chat Request Types end ======================================
|
||||||
|
|
||||||
|
// ===================================== Provider Settings Types start ======================================
|
||||||
|
|
||||||
|
type NumberRange = [number, number];
|
||||||
|
|
||||||
|
export type Validator =
|
||||||
|
| "required"
|
||||||
|
| "number"
|
||||||
|
| "string"
|
||||||
|
| NumberRange
|
||||||
|
| NumberRange[]
|
||||||
|
| ((v: any) => Promise<string | void>);
|
||||||
|
|
||||||
|
export type CommonSettingItem<SettingKeys extends string> = {
|
||||||
|
name: SettingKeys;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
validators?: Validator[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InputSettingItem = {
|
||||||
|
type: "input";
|
||||||
|
placeholder?: string;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
inputType?: "password" | "normal";
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
inputType?: "number";
|
||||||
|
defaultValue?: number;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SelectSettingItem = {
|
||||||
|
type: "select";
|
||||||
|
options: {
|
||||||
|
name: string;
|
||||||
|
value: "number" | "string" | "boolean";
|
||||||
|
}[];
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RangeSettingItem = {
|
||||||
|
type: "range";
|
||||||
|
range: NumberRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SwitchSettingItem = {
|
||||||
|
type: "switch";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingItem<SettingKeys extends string = ""> =
|
||||||
|
CommonSettingItem<SettingKeys> &
|
||||||
|
(
|
||||||
|
| InputSettingItem
|
||||||
|
| SelectSettingItem
|
||||||
|
| RangeSettingItem
|
||||||
|
| SwitchSettingItem
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===================================== Provider Settings Types end ======================================
|
||||||
|
|
||||||
|
// ===================================== Provider Template Types start ======================================
|
||||||
|
|
||||||
|
export type ServerConfig = ReturnType<typeof getServerSideConfig>;
|
||||||
|
|
||||||
|
export interface IProviderTemplate<
|
||||||
|
SettingKeys extends string,
|
||||||
|
NAME extends string,
|
||||||
|
Meta extends Record<string, any>,
|
||||||
|
> {
|
||||||
|
readonly name: NAME;
|
||||||
|
|
||||||
|
readonly apiRouteRootName: `/api/provider/${NAME}`;
|
||||||
|
|
||||||
|
readonly allowedApiMethods: Array<
|
||||||
|
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"
|
||||||
|
>;
|
||||||
|
|
||||||
|
readonly metas: Meta;
|
||||||
|
|
||||||
|
readonly providerMeta: {
|
||||||
|
displayName: string;
|
||||||
|
settingItems: SettingItem<SettingKeys>[];
|
||||||
|
};
|
||||||
|
readonly defaultModels: ModelTemplate[];
|
||||||
|
|
||||||
|
streamChat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
handlers: ChatHandlers,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
): AbortController;
|
||||||
|
|
||||||
|
chat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
): Promise<StandChatReponseMessage>;
|
||||||
|
|
||||||
|
getAvailableModels?(
|
||||||
|
providerConfig: InternalChatRequestPayload<SettingKeys>["providerConfig"],
|
||||||
|
): Promise<ModelInfo[]>;
|
||||||
|
|
||||||
|
readonly runtime: "edge";
|
||||||
|
readonly preferredRegion: "auto" | "global" | "home" | string | string[];
|
||||||
|
|
||||||
|
serverSideRequestHandler(
|
||||||
|
req: NextRequest & {
|
||||||
|
subpath: string;
|
||||||
|
},
|
||||||
|
serverConfig: ServerConfig,
|
||||||
|
): Promise<NextResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderTemplate = IProviderTemplate<any, any, any>;
|
||||||
|
|
||||||
|
export interface Serializable<Snapshot> {
|
||||||
|
serialize(): Snapshot;
|
||||||
|
}
|
||||||
88
app/client/common/utils.ts
Normal file
88
app/client/common/utils.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { RequestMessage, ServerConfig } from "./types";
|
||||||
|
import { cloneDeep } from "lodash-es";
|
||||||
|
|
||||||
|
export function getMessageTextContent(message: RequestMessage) {
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
return message.content;
|
||||||
|
}
|
||||||
|
for (const c of message.content) {
|
||||||
|
if (c.type === "text") {
|
||||||
|
return c.text ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageImages(message: RequestMessage): string[] {
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const c of message.content) {
|
||||||
|
if (c.type === "image_url") {
|
||||||
|
urls.push(c.image_url?.url ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUrl(baseUrl?: string) {
|
||||||
|
if (baseUrl && !baseUrl.startsWith("http")) {
|
||||||
|
baseUrl = `https://${baseUrl}`;
|
||||||
|
}
|
||||||
|
if (baseUrl?.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function travel(
|
||||||
|
config: ServerConfig,
|
||||||
|
keys: Array<keyof ServerConfig>,
|
||||||
|
handle: (prop: any) => any,
|
||||||
|
): ServerConfig {
|
||||||
|
const copiedConfig = cloneDeep(config);
|
||||||
|
keys.forEach((k) => {
|
||||||
|
copiedConfig[k] = handle(copiedConfig[k] as string) as never;
|
||||||
|
});
|
||||||
|
return copiedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeUrlsUsable = (
|
||||||
|
config: ServerConfig,
|
||||||
|
keys: Array<keyof ServerConfig>,
|
||||||
|
) => travel(config, keys, formatUrl);
|
||||||
|
|
||||||
|
export const disableSystemApiKey = (
|
||||||
|
config: ServerConfig,
|
||||||
|
keys: Array<keyof ServerConfig>,
|
||||||
|
forbidden: boolean,
|
||||||
|
) =>
|
||||||
|
travel(config, keys, (p) => {
|
||||||
|
return forbidden ? undefined : p;
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isSameOrigin(requestUrl: string) {
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = requestUrl;
|
||||||
|
|
||||||
|
// 检查协议、主机名和端口号是否与当前页面相同
|
||||||
|
return (
|
||||||
|
a.protocol === window.location.protocol &&
|
||||||
|
a.hostname === window.location.hostname &&
|
||||||
|
a.port === window.location.port
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/client/core/index.ts
Normal file
9
app/client/core/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from "./shim";
|
||||||
|
|
||||||
|
export * from "../common/types";
|
||||||
|
|
||||||
|
export * from "./providerClient";
|
||||||
|
|
||||||
|
export * from "./modelClient";
|
||||||
|
|
||||||
|
export * from "../common/locale";
|
||||||
98
app/client/core/modelClient.ts
Normal file
98
app/client/core/modelClient.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
ChatRequestPayload,
|
||||||
|
Model,
|
||||||
|
ModelSettings,
|
||||||
|
InternalChatHandlers,
|
||||||
|
} from "../common";
|
||||||
|
import { Provider, ProviderClient } from "./providerClient";
|
||||||
|
|
||||||
|
export class ModelClient {
|
||||||
|
constructor(
|
||||||
|
private model: Model,
|
||||||
|
private modelSettings: ModelSettings,
|
||||||
|
private providerClient: ProviderClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
chat(payload: ChatRequestPayload, handlers: InternalChatHandlers) {
|
||||||
|
try {
|
||||||
|
return this.providerClient.streamChat(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
modelConfig: {
|
||||||
|
...this.modelSettings,
|
||||||
|
max_tokens:
|
||||||
|
this.model.max_tokens ?? this.modelSettings.global_max_tokens,
|
||||||
|
},
|
||||||
|
model: this.model.name,
|
||||||
|
},
|
||||||
|
handlers,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
handlers.onError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summerize(payload: ChatRequestPayload) {
|
||||||
|
try {
|
||||||
|
return this.providerClient.chat({
|
||||||
|
...payload,
|
||||||
|
modelConfig: {
|
||||||
|
...this.modelSettings,
|
||||||
|
max_tokens:
|
||||||
|
this.model.max_tokens ?? this.modelSettings.global_max_tokens,
|
||||||
|
},
|
||||||
|
model: this.model.name,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// must generate new ModelClient during every chat
|
||||||
|
export function ModelClientFactory(
|
||||||
|
model: Model,
|
||||||
|
provider: Provider,
|
||||||
|
modelSettings: ModelSettings,
|
||||||
|
) {
|
||||||
|
const providerClient = new ProviderClient(provider);
|
||||||
|
return new ModelClient(model, modelSettings, providerClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFiltertModels(
|
||||||
|
models: readonly Model[],
|
||||||
|
customModels: string,
|
||||||
|
) {
|
||||||
|
const modelTable: Record<string, Model> = {};
|
||||||
|
|
||||||
|
// default models
|
||||||
|
models.forEach((m) => {
|
||||||
|
modelTable[m.name] = m;
|
||||||
|
});
|
||||||
|
|
||||||
|
// server custom models
|
||||||
|
customModels
|
||||||
|
.split(",")
|
||||||
|
.filter((v) => !!v && v.length > 0)
|
||||||
|
.forEach((m) => {
|
||||||
|
const available = !m.startsWith("-");
|
||||||
|
const nameConfig =
|
||||||
|
m.startsWith("+") || m.startsWith("-") ? m.slice(1) : m;
|
||||||
|
const [name, displayName] = nameConfig.split("=");
|
||||||
|
|
||||||
|
// enable or disable all models
|
||||||
|
if (name === "all") {
|
||||||
|
Object.values(modelTable).forEach(
|
||||||
|
(model) => (model.available = available),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
modelTable[name] = {
|
||||||
|
...modelTable[name],
|
||||||
|
displayName,
|
||||||
|
available,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return modelTable;
|
||||||
|
}
|
||||||
256
app/client/core/providerClient.ts
Normal file
256
app/client/core/providerClient.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
IProviderTemplate,
|
||||||
|
InternalChatHandlers,
|
||||||
|
Model,
|
||||||
|
ModelTemplate,
|
||||||
|
ProviderTemplate,
|
||||||
|
StandChatReponseMessage,
|
||||||
|
StandChatRequestPayload,
|
||||||
|
isSameOrigin,
|
||||||
|
modelNameRequestHeader,
|
||||||
|
} from "../common";
|
||||||
|
import * as ProviderTemplates from "@/app/client/providers";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
export type ProviderTemplateName =
|
||||||
|
(typeof ProviderTemplates)[keyof typeof ProviderTemplates]["prototype"]["name"];
|
||||||
|
|
||||||
|
export interface Provider<
|
||||||
|
Providerconfig extends Record<string, any> = Record<string, any>,
|
||||||
|
> {
|
||||||
|
name: string; // id of provider
|
||||||
|
isActive: boolean;
|
||||||
|
providerTemplateName: ProviderTemplateName;
|
||||||
|
providerConfig: Providerconfig;
|
||||||
|
isDefault: boolean; // Not allow to modify models of default provider
|
||||||
|
updated: boolean; // provider initial is finished
|
||||||
|
|
||||||
|
displayName: string;
|
||||||
|
models: Model[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerTemplates = Object.values(ProviderTemplates).reduce(
|
||||||
|
(r, t) => ({
|
||||||
|
...r,
|
||||||
|
[t.prototype.name]: new t(),
|
||||||
|
}),
|
||||||
|
{} as Record<ProviderTemplateName, ProviderTemplate>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export class ProviderClient {
|
||||||
|
providerTemplate: IProviderTemplate<any, any, any>;
|
||||||
|
genFetch: (modelName: string) => typeof window.fetch;
|
||||||
|
|
||||||
|
static ProviderTemplates = providerTemplates;
|
||||||
|
|
||||||
|
static getAllProviderTemplates = () => {
|
||||||
|
return Object.values(providerTemplates).reduce(
|
||||||
|
(r, t) => ({
|
||||||
|
...r,
|
||||||
|
[t.name]: t,
|
||||||
|
}),
|
||||||
|
{} as Record<ProviderTemplateName, ProviderTemplate>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
static getProviderTemplateMetaList = () => {
|
||||||
|
return Object.values(providerTemplates).map((t) => ({
|
||||||
|
...t.providerMeta,
|
||||||
|
name: t.name,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private provider: Provider) {
|
||||||
|
const { providerTemplateName } = provider;
|
||||||
|
this.providerTemplate = this.getProviderTemplate(providerTemplateName);
|
||||||
|
this.genFetch =
|
||||||
|
(modelName: string) =>
|
||||||
|
(...args) => {
|
||||||
|
const req = new Request(...args);
|
||||||
|
const headers: Record<string, any> = {
|
||||||
|
...req.headers,
|
||||||
|
};
|
||||||
|
if (isSameOrigin(req.url)) {
|
||||||
|
headers[modelNameRequestHeader] = modelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.fetch(req.url, {
|
||||||
|
method: req.method,
|
||||||
|
keepalive: req.keepalive,
|
||||||
|
headers,
|
||||||
|
body: req.body,
|
||||||
|
redirect: req.redirect,
|
||||||
|
integrity: req.integrity,
|
||||||
|
signal: req.signal,
|
||||||
|
credentials: req.credentials,
|
||||||
|
mode: req.mode,
|
||||||
|
referrer: req.referrer,
|
||||||
|
referrerPolicy: req.referrerPolicy,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProviderTemplate(providerTemplateName: string) {
|
||||||
|
const providerTemplate = Object.values(providerTemplates).find(
|
||||||
|
(template) => template.name === providerTemplateName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return providerTemplate || providerTemplates.openai;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getModelConfig(modelName: string) {
|
||||||
|
const { models } = this.provider;
|
||||||
|
return (
|
||||||
|
models.find((m) => m.name === modelName) ||
|
||||||
|
models.find((m) => m.isDefaultSelected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableModels() {
|
||||||
|
return Promise.resolve(
|
||||||
|
this.providerTemplate.getAvailableModels?.(this.provider.providerConfig),
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
const { defaultModels } = this.providerTemplate;
|
||||||
|
const availableModelsSet = new Set(
|
||||||
|
(res ?? defaultModels).map((o) => o.name),
|
||||||
|
);
|
||||||
|
return defaultModels.filter((m) => availableModelsSet.has(m.name));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
return this.providerTemplate.defaultModels;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
payload: StandChatRequestPayload,
|
||||||
|
): Promise<StandChatReponseMessage> {
|
||||||
|
return this.providerTemplate.chat(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
stream: false,
|
||||||
|
isVisionModel: this.getModelConfig(payload.model)?.isVisionModel,
|
||||||
|
providerConfig: this.provider.providerConfig,
|
||||||
|
},
|
||||||
|
this.genFetch(payload.model),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamChat(payload: StandChatRequestPayload, handlers: InternalChatHandlers) {
|
||||||
|
let responseText = "";
|
||||||
|
let remainText = "";
|
||||||
|
|
||||||
|
const timer = this.providerTemplate.streamChat(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
stream: true,
|
||||||
|
isVisionModel: this.getModelConfig(payload.model)?.isVisionModel,
|
||||||
|
providerConfig: this.provider.providerConfig,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: (chunk) => {
|
||||||
|
remainText += chunk;
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
handlers.onError(err);
|
||||||
|
},
|
||||||
|
onFinish: () => {},
|
||||||
|
onFlash: (message: string) => {
|
||||||
|
handlers.onFinish(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.genFetch(payload.model),
|
||||||
|
);
|
||||||
|
|
||||||
|
timer.signal.onabort = () => {
|
||||||
|
const message = responseText + remainText;
|
||||||
|
remainText = "";
|
||||||
|
handlers.onFinish(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const animateResponseText = () => {
|
||||||
|
if (remainText.length > 0) {
|
||||||
|
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||||
|
const fetchText = remainText.slice(0, fetchCount);
|
||||||
|
responseText += fetchText;
|
||||||
|
remainText = remainText.slice(fetchCount);
|
||||||
|
handlers.onProgress(responseText, fetchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animateResponseText);
|
||||||
|
};
|
||||||
|
|
||||||
|
// start animaion
|
||||||
|
animateResponseText();
|
||||||
|
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = Omit<Provider, "providerTemplateName" | "name" | "isDefault">;
|
||||||
|
|
||||||
|
function createProvider(
|
||||||
|
provider: ProviderTemplateName,
|
||||||
|
isDefault: true,
|
||||||
|
): Provider;
|
||||||
|
function createProvider(provider: ProviderTemplate, isDefault: true): Provider;
|
||||||
|
function createProvider(
|
||||||
|
provider: ProviderTemplateName,
|
||||||
|
isDefault: false,
|
||||||
|
params: Params,
|
||||||
|
): Provider;
|
||||||
|
function createProvider(
|
||||||
|
provider: ProviderTemplate,
|
||||||
|
isDefault: false,
|
||||||
|
params: Params,
|
||||||
|
): Provider;
|
||||||
|
function createProvider(
|
||||||
|
provider: ProviderTemplate | ProviderTemplateName,
|
||||||
|
isDefault: boolean,
|
||||||
|
params?: Params,
|
||||||
|
): Provider {
|
||||||
|
let providerTemplate: ProviderTemplate;
|
||||||
|
if (typeof provider === "string") {
|
||||||
|
providerTemplate = ProviderClient.getAllProviderTemplates()[provider];
|
||||||
|
} else {
|
||||||
|
providerTemplate = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `${providerTemplate.name}__${nanoid()}`;
|
||||||
|
|
||||||
|
const {
|
||||||
|
displayName = providerTemplate.providerMeta.displayName,
|
||||||
|
models = providerTemplate.defaultModels.map((m) =>
|
||||||
|
createModelFromModelTemplate(m, providerTemplate, name),
|
||||||
|
),
|
||||||
|
providerConfig,
|
||||||
|
} = params ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
displayName,
|
||||||
|
isActive: true,
|
||||||
|
models,
|
||||||
|
providerTemplateName: providerTemplate.name,
|
||||||
|
providerConfig: isDefault ? {} : providerConfig!,
|
||||||
|
isDefault,
|
||||||
|
updated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModelFromModelTemplate(
|
||||||
|
m: ModelTemplate,
|
||||||
|
p: ProviderTemplate,
|
||||||
|
providerName: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
providerTemplateName: p.name,
|
||||||
|
providerName,
|
||||||
|
isActive: m.isDefaultActive,
|
||||||
|
available: true,
|
||||||
|
customized: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createProvider };
|
||||||
25
app/client/core/shim.ts
Normal file
25
app/client/core/shim.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
|
||||||
|
if (!(window.fetch as any).__hijacked__) {
|
||||||
|
let _fetch = window.fetch;
|
||||||
|
|
||||||
|
function fetch(...args: Parameters<typeof _fetch>) {
|
||||||
|
const { isApp } = getClientConfig() || {};
|
||||||
|
|
||||||
|
let fetch: typeof _fetch = _fetch;
|
||||||
|
|
||||||
|
if (isApp) {
|
||||||
|
try {
|
||||||
|
fetch = window.__TAURI__!.http.fetch;
|
||||||
|
} catch (e) {
|
||||||
|
fetch = _fetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch.__hijacked__ = true;
|
||||||
|
|
||||||
|
window.fetch = fetch;
|
||||||
|
}
|
||||||
3
app/client/index.ts
Normal file
3
app/client/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./core";
|
||||||
|
|
||||||
|
export * from "./providers";
|
||||||
415
app/client/platforms/anthropic.ts
Normal file
415
app/client/platforms/anthropic.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
|
||||||
|
import { ChatOptions, LLMApi, MultimodalContent } from "../api";
|
||||||
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { DEFAULT_API_HOST } from "@/app/constant";
|
||||||
|
import { RequestMessage } from "@/app/typing";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
|
||||||
|
import Locale from "../../locales";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||||
|
|
||||||
|
export type MultiBlockContent = {
|
||||||
|
type: "image" | "text";
|
||||||
|
source?: {
|
||||||
|
type: string;
|
||||||
|
media_type: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnthropicMessage = {
|
||||||
|
role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper];
|
||||||
|
content: string | MultiBlockContent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AnthropicChatRequest {
|
||||||
|
model: string; // The model that will complete your prompt.
|
||||||
|
messages: AnthropicMessage[]; // The prompt that you want Claude to complete.
|
||||||
|
max_tokens: number; // The maximum number of tokens to generate before stopping.
|
||||||
|
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
|
||||||
|
temperature?: number; // Amount of randomness injected into the response.
|
||||||
|
top_p?: number; // Use nucleus sampling.
|
||||||
|
top_k?: number; // Only sample from the top K options for each subsequent token.
|
||||||
|
metadata?: object; // An object describing metadata about the request.
|
||||||
|
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRequest {
|
||||||
|
model: string; // The model that will complete your prompt.
|
||||||
|
prompt: string; // The prompt that you want Claude to complete.
|
||||||
|
max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping.
|
||||||
|
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
|
||||||
|
temperature?: number; // Amount of randomness injected into the response.
|
||||||
|
top_p?: number; // Use nucleus sampling.
|
||||||
|
top_k?: number; // Only sample from the top K options for each subsequent token.
|
||||||
|
metadata?: object; // An object describing metadata about the request.
|
||||||
|
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
completion: string;
|
||||||
|
stop_reason: "stop_sequence" | "max_tokens";
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatStreamResponse = ChatResponse & {
|
||||||
|
stop?: string;
|
||||||
|
log_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClaudeMapper = {
|
||||||
|
assistant: "assistant",
|
||||||
|
user: "user",
|
||||||
|
system: "user",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const keys = ["claude-2, claude-instant-1"];
|
||||||
|
|
||||||
|
export class ClaudeApi implements LLMApi {
|
||||||
|
extractMessage(res: any) {
|
||||||
|
console.log("[Response] claude response: ", res);
|
||||||
|
|
||||||
|
return res?.content?.[0]?.text;
|
||||||
|
}
|
||||||
|
async chat(options: ChatOptions): Promise<void> {
|
||||||
|
const visionModel = isVisionModel(options.config.model);
|
||||||
|
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = [...options.messages];
|
||||||
|
|
||||||
|
const keys = ["system", "user"];
|
||||||
|
|
||||||
|
// roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages
|
||||||
|
for (let i = 0; i < messages.length - 1; i++) {
|
||||||
|
const message = messages[i];
|
||||||
|
const nextMessage = messages[i + 1];
|
||||||
|
|
||||||
|
if (keys.includes(message.role) && keys.includes(nextMessage.role)) {
|
||||||
|
messages[i] = [
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: ";",
|
||||||
|
},
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = messages
|
||||||
|
.flat()
|
||||||
|
.filter((v) => {
|
||||||
|
if (!v.content) return false;
|
||||||
|
if (typeof v.content === "string" && !v.content.trim()) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((v) => {
|
||||||
|
const { role, content } = v;
|
||||||
|
const insideRole = ClaudeMapper[role] ?? "user";
|
||||||
|
|
||||||
|
if (!visionModel || typeof content === "string") {
|
||||||
|
return {
|
||||||
|
role: insideRole,
|
||||||
|
content: getMessageTextContent(v),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: insideRole,
|
||||||
|
content: content
|
||||||
|
.filter((v) => v.image_url || v.text)
|
||||||
|
.map(({ type, text, image_url }) => {
|
||||||
|
if (type === "text") {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
text: text!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { url = "" } = image_url || {};
|
||||||
|
const colonIndex = url.indexOf(":");
|
||||||
|
const semicolonIndex = url.indexOf(";");
|
||||||
|
const comma = url.indexOf(",");
|
||||||
|
|
||||||
|
const mimeType = url.slice(colonIndex + 1, semicolonIndex);
|
||||||
|
const encodeType = url.slice(semicolonIndex + 1, comma);
|
||||||
|
const data = url.slice(comma + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "image" as const,
|
||||||
|
source: {
|
||||||
|
type: encodeType,
|
||||||
|
media_type: mimeType,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prompt[0]?.role === "assistant") {
|
||||||
|
prompt.unshift({
|
||||||
|
role: "user",
|
||||||
|
content: ";",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody: AnthropicChatRequest = {
|
||||||
|
messages: prompt,
|
||||||
|
stream: shouldStream,
|
||||||
|
|
||||||
|
model: modelConfig.model,
|
||||||
|
max_tokens: modelConfig.max_tokens,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
top_p: modelConfig.top_p,
|
||||||
|
// top_k: modelConfig.top_k,
|
||||||
|
top_k: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const path = this.path(Anthropic.ChatPath);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"x-api-key": accessStore.anthropicApiKey,
|
||||||
|
"anthropic-version": accessStore.anthropicApiVersion,
|
||||||
|
Authorization: getAuthKey(accessStore.anthropicApiKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldStream) {
|
||||||
|
try {
|
||||||
|
const context = {
|
||||||
|
text: "",
|
||||||
|
finished: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (!context.finished) {
|
||||||
|
options.onFinish(context.text);
|
||||||
|
context.finished = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.signal.onabort = finish;
|
||||||
|
fetchEventSource(path, {
|
||||||
|
...payload,
|
||||||
|
async onopen(res) {
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
context.text = await res.clone().text();
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [context.text];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.text = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
let chunkJson:
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
type: "content_block_delta" | "content_block_stop";
|
||||||
|
delta?: {
|
||||||
|
type: "text_delta";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
chunkJson = JSON.parse(msg.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Response] parse error", msg.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chunkJson || chunkJson.type === "content_block_stop") {
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { delta } = chunkJson;
|
||||||
|
if (delta?.text) {
|
||||||
|
context.text += delta.text;
|
||||||
|
options.onUpdate?.(context.text, delta.text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("failed to chat", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
controller.signal.onabort = () => options.onFinish("");
|
||||||
|
|
||||||
|
const res = await fetch(path, payload);
|
||||||
|
const resJson = await res.json();
|
||||||
|
|
||||||
|
const message = this.extractMessage(resJson);
|
||||||
|
options.onFinish(message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("failed to chat", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async usage() {
|
||||||
|
return {
|
||||||
|
used: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async models() {
|
||||||
|
// const provider = {
|
||||||
|
// id: "anthropic",
|
||||||
|
// providerName: "Anthropic",
|
||||||
|
// providerType: "anthropic",
|
||||||
|
// };
|
||||||
|
|
||||||
|
return [
|
||||||
|
// {
|
||||||
|
// name: "claude-instant-1.2",
|
||||||
|
// available: true,
|
||||||
|
// provider,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "claude-2.0",
|
||||||
|
// available: true,
|
||||||
|
// provider,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "claude-2.1",
|
||||||
|
// available: true,
|
||||||
|
// provider,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "claude-3-opus-20240229",
|
||||||
|
// available: true,
|
||||||
|
// provider,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "claude-3-sonnet-20240229",
|
||||||
|
// available: true,
|
||||||
|
// provider,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: "claude-3-haiku-20240307",
|
||||||
|
// available: true,
|
||||||
|
// provider,
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl: string = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.anthropicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if endpoint is empty, use default endpoint
|
||||||
|
if (baseUrl.trim().length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
|
||||||
|
baseUrl = isApp
|
||||||
|
? DEFAULT_API_HOST + "/api/proxy/anthropic"
|
||||||
|
: ApiPath.Anthropic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl = trimEnd(baseUrl, "/");
|
||||||
|
|
||||||
|
return `${baseUrl}/${path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimEnd(s: string, end = " ") {
|
||||||
|
if (end.length === 0) return s;
|
||||||
|
|
||||||
|
while (s.endsWith(end)) {
|
||||||
|
s = s.slice(0, -end.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearer(value: string) {
|
||||||
|
return `Bearer ${value.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthKey(apiKey = "") {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
let authKey = "";
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
// use user's api key first
|
||||||
|
authKey = bearer(apiKey);
|
||||||
|
} else if (
|
||||||
|
accessStore.enabledAccessControl() &&
|
||||||
|
!isApp &&
|
||||||
|
!!accessStore.accessCode
|
||||||
|
) {
|
||||||
|
// or use access code
|
||||||
|
authKey = bearer(ACCESS_CODE_PREFIX + accessStore.accessCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return authKey;
|
||||||
|
}
|
||||||
281
app/client/platforms/google.ts
Normal file
281
app/client/platforms/google.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||||
|
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
|
||||||
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { DEFAULT_API_HOST } from "@/app/constant";
|
||||||
|
import {
|
||||||
|
getMessageTextContent,
|
||||||
|
getMessageImages,
|
||||||
|
isVisionModel,
|
||||||
|
} from "@/app/utils";
|
||||||
|
|
||||||
|
export class GeminiProApi implements LLMApi {
|
||||||
|
extractMessage(res: any) {
|
||||||
|
console.log("[Response] gemini-pro response: ", res);
|
||||||
|
|
||||||
|
return (
|
||||||
|
res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
|
||||||
|
res?.error?.message ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async chat(options: ChatOptions): Promise<void> {
|
||||||
|
// const apiClient = this;
|
||||||
|
let multimodal = false;
|
||||||
|
const messages = options.messages.map((v) => {
|
||||||
|
let parts: any[] = [{ text: getMessageTextContent(v) }];
|
||||||
|
if (isVisionModel(options.config.model)) {
|
||||||
|
const images = getMessageImages(v);
|
||||||
|
if (images.length > 0) {
|
||||||
|
multimodal = true;
|
||||||
|
parts = parts.concat(
|
||||||
|
images.map((image) => {
|
||||||
|
const imageType = image.split(";")[0].split(":")[1];
|
||||||
|
const imageData = image.split(",")[1];
|
||||||
|
return {
|
||||||
|
inline_data: {
|
||||||
|
mime_type: imageType,
|
||||||
|
data: imageData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: v.role.replace("assistant", "model").replace("system", "user"),
|
||||||
|
parts: parts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// google requires that role in neighboring messages must not be the same
|
||||||
|
for (let i = 0; i < messages.length - 1; ) {
|
||||||
|
// Check if current and next item both have the role "model"
|
||||||
|
if (messages[i].role === messages[i + 1].role) {
|
||||||
|
// Concatenate the 'parts' of the current and next item
|
||||||
|
messages[i].parts = messages[i].parts.concat(messages[i + 1].parts);
|
||||||
|
// Remove the next item
|
||||||
|
messages.splice(i + 1, 1);
|
||||||
|
} else {
|
||||||
|
// Move to the next item
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if (visionModel && messages.length > 1) {
|
||||||
|
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
|
||||||
|
// }
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const requestPayload = {
|
||||||
|
contents: messages,
|
||||||
|
generationConfig: {
|
||||||
|
// stopSequences: [
|
||||||
|
// "Title"
|
||||||
|
// ],
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
maxOutputTokens: modelConfig.max_tokens,
|
||||||
|
topP: modelConfig.top_p,
|
||||||
|
// "topK": modelConfig.top_k,
|
||||||
|
},
|
||||||
|
safetySettings: [
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_HARASSMENT",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.googleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
|
||||||
|
let shouldStream = !!options.config.stream;
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
try {
|
||||||
|
// let baseUrl = accessStore.googleUrl;
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
baseUrl = isApp
|
||||||
|
? DEFAULT_API_HOST +
|
||||||
|
"/api/proxy/google/" +
|
||||||
|
Google.ChatPath(modelConfig.model)
|
||||||
|
: this.path(Google.ChatPath(modelConfig.model));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isApp) {
|
||||||
|
baseUrl += `?key=${accessStore.googleApiKey}`;
|
||||||
|
}
|
||||||
|
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 remainText = "";
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
let existingTexts: string[] = [];
|
||||||
|
const finish = () => {
|
||||||
|
finished = true;
|
||||||
|
options.onFinish(existingTexts.join(""));
|
||||||
|
};
|
||||||
|
|
||||||
|
// animate response to make it looks smooth
|
||||||
|
function animateResponseText() {
|
||||||
|
if (finished || controller.signal.aborted) {
|
||||||
|
responseText += remainText;
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainText.length > 0) {
|
||||||
|
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||||
|
const fetchText = remainText.slice(0, fetchCount);
|
||||||
|
responseText += fetchText;
|
||||||
|
remainText = remainText.slice(fetchCount);
|
||||||
|
options.onUpdate?.(responseText, fetchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animateResponseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// start animaion
|
||||||
|
animateResponseText();
|
||||||
|
|
||||||
|
fetch(
|
||||||
|
baseUrl.replace("generateContent", "streamGenerateContent"),
|
||||||
|
chatPayload,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
const reader = response?.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let partialData = "";
|
||||||
|
|
||||||
|
return reader?.read().then(function processText({
|
||||||
|
done,
|
||||||
|
value,
|
||||||
|
}): Promise<any> {
|
||||||
|
if (done) {
|
||||||
|
if (response.status !== 200) {
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(ensureProperEnding(partialData));
|
||||||
|
if (data && data[0].error) {
|
||||||
|
options.onError?.(new Error(data[0].error.message));
|
||||||
|
} else {
|
||||||
|
options.onError?.(new Error("Request failed"));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
options.onError?.(new Error("Request failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Stream complete");
|
||||||
|
// options.onFinish(responseText + remainText);
|
||||||
|
finished = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
partialData += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(ensureProperEnding(partialData));
|
||||||
|
|
||||||
|
const textArray = data.reduce(
|
||||||
|
(acc: string[], item: { candidates: any[] }) => {
|
||||||
|
const texts = item.candidates.map((candidate) =>
|
||||||
|
candidate.content.parts
|
||||||
|
.map((part: { text: any }) => part.text)
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
return acc.concat(texts);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textArray.length > existingTexts.length) {
|
||||||
|
const deltaArray = textArray.slice(existingTexts.length);
|
||||||
|
existingTexts = textArray;
|
||||||
|
remainText += deltaArray.join("");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.log("[Response Animation] error: ", error,partialData);
|
||||||
|
// skip error message when parsing json
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader.read().then(processText);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await fetch(baseUrl, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
const resJson = await res.json();
|
||||||
|
if (resJson?.promptFeedback?.blockReason) {
|
||||||
|
// being blocked
|
||||||
|
options.onError?.(
|
||||||
|
new Error(
|
||||||
|
"Message is being blocked for reason: " +
|
||||||
|
resJson.promptFeedback.blockReason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = this.extractMessage(resJson);
|
||||||
|
options.onFinish(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usage(): Promise<LLMUsage> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
path(path: string): string {
|
||||||
|
return "/api/google/" + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProperEnding(str: string) {
|
||||||
|
if (str.startsWith("[") && !str.endsWith("]")) {
|
||||||
|
return str + "]";
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
@@ -1,18 +1,35 @@
|
|||||||
|
"use client";
|
||||||
import {
|
import {
|
||||||
|
ApiPath,
|
||||||
DEFAULT_API_HOST,
|
DEFAULT_API_HOST,
|
||||||
DEFAULT_MODELS,
|
DEFAULT_MODELS,
|
||||||
OpenaiPath,
|
OpenaiPath,
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
|
ServiceProvider,
|
||||||
} from "@/app/constant";
|
} from "@/app/constant";
|
||||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
|
||||||
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
LLMUsage,
|
||||||
|
MultimodalContent,
|
||||||
|
} from "../api";
|
||||||
import Locale from "../../locales";
|
import Locale from "../../locales";
|
||||||
import {
|
import {
|
||||||
EventStreamContentType,
|
EventStreamContentType,
|
||||||
fetchEventSource,
|
fetchEventSource,
|
||||||
} from "@fortaine/fetch-event-source";
|
} from "@fortaine/fetch-event-source";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { makeAzurePath } from "@/app/azure";
|
||||||
|
import {
|
||||||
|
getMessageTextContent,
|
||||||
|
getMessageImages,
|
||||||
|
isVisionModel,
|
||||||
|
} from "@/app/utils";
|
||||||
|
|
||||||
export interface OpenAIListModelResponse {
|
export interface OpenAIListModelResponse {
|
||||||
object: string;
|
object: string;
|
||||||
@@ -23,21 +40,61 @@ export interface OpenAIListModelResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RequestPayload {
|
||||||
|
messages: {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}[];
|
||||||
|
stream?: boolean;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
presence_penalty: number;
|
||||||
|
frequency_penalty: number;
|
||||||
|
top_p: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class ChatGPTApi implements LLMApi {
|
export class ChatGPTApi implements LLMApi {
|
||||||
private disableListModels = true;
|
private disableListModels = true;
|
||||||
|
|
||||||
path(path: string): string {
|
path(path: string): string {
|
||||||
let openaiUrl = useAccessStore.getState().openaiUrl;
|
const accessStore = useAccessStore.getState();
|
||||||
if (openaiUrl.length === 0) {
|
|
||||||
openaiUrl = DEFAULT_API_HOST;
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||||
|
|
||||||
|
if (isAzure && !accessStore.isValidAzure()) {
|
||||||
|
throw Error(
|
||||||
|
"incomplete azure config, please check it in your settings page",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAzure) {
|
||||||
|
path = makeAzurePath(path, accessStore.azureApiVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||||
}
|
}
|
||||||
if (openaiUrl.endsWith("/")) {
|
|
||||||
openaiUrl = openaiUrl.slice(0, openaiUrl.length - 1);
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
baseUrl = isApp
|
||||||
|
? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI
|
||||||
|
: ApiPath.OpenAI;
|
||||||
}
|
}
|
||||||
if (!openaiUrl.startsWith("http") && !openaiUrl.startsWith("/api/openai")) {
|
|
||||||
openaiUrl = "https://" + openaiUrl;
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
}
|
}
|
||||||
return [openaiUrl, path].join("/");
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
return [baseUrl, path].join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
extractMessage(res: any) {
|
extractMessage(res: any) {
|
||||||
@@ -45,9 +102,10 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions) {
|
async chat(options: ChatOptions) {
|
||||||
|
const visionModel = isVisionModel(options.config.model);
|
||||||
const messages = options.messages.map((v) => ({
|
const messages = options.messages.map((v) => ({
|
||||||
role: v.role,
|
role: v.role,
|
||||||
content: v.content,
|
content: visionModel ? v.content : getMessageTextContent(v),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modelConfig = {
|
const modelConfig = {
|
||||||
@@ -58,7 +116,7 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestPayload = {
|
const requestPayload: RequestPayload = {
|
||||||
messages,
|
messages,
|
||||||
stream: options.config.stream,
|
stream: options.config.stream,
|
||||||
model: modelConfig.model,
|
model: modelConfig.model,
|
||||||
@@ -66,8 +124,15 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
presence_penalty: modelConfig.presence_penalty,
|
presence_penalty: modelConfig.presence_penalty,
|
||||||
frequency_penalty: modelConfig.frequency_penalty,
|
frequency_penalty: modelConfig.frequency_penalty,
|
||||||
top_p: modelConfig.top_p,
|
top_p: modelConfig.top_p,
|
||||||
|
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
||||||
|
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// add max_tokens to vision model
|
||||||
|
if (visionModel && modelConfig.model.includes("preview")) {
|
||||||
|
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[Request] openai payload: ", requestPayload);
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
const shouldStream = !!options.config.stream;
|
const shouldStream = !!options.config.stream;
|
||||||
@@ -91,12 +156,38 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
|
|
||||||
if (shouldStream) {
|
if (shouldStream) {
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
|
let remainText = "";
|
||||||
let finished = false;
|
let finished = false;
|
||||||
|
|
||||||
|
// animate response to make it looks smooth
|
||||||
|
function animateResponseText() {
|
||||||
|
if (finished || controller.signal.aborted) {
|
||||||
|
responseText += remainText;
|
||||||
|
console.log("[Response Animation] finished");
|
||||||
|
if (responseText?.length === 0) {
|
||||||
|
options.onError?.(new Error("empty response from server"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainText.length > 0) {
|
||||||
|
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||||
|
const fetchText = remainText.slice(0, fetchCount);
|
||||||
|
responseText += fetchText;
|
||||||
|
remainText = remainText.slice(fetchCount);
|
||||||
|
options.onUpdate?.(responseText, fetchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animateResponseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// start animaion
|
||||||
|
animateResponseText();
|
||||||
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
options.onFinish(responseText);
|
|
||||||
finished = true;
|
finished = true;
|
||||||
|
options.onFinish(responseText + remainText);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,10 +242,27 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
const text = msg.data;
|
const text = msg.data;
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
const delta = json.choices[0].delta.content;
|
const choices = json.choices as Array<{
|
||||||
|
delta: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.delta?.content;
|
||||||
|
const textmoderation = json?.prompt_filter_results;
|
||||||
|
|
||||||
if (delta) {
|
if (delta) {
|
||||||
responseText += delta;
|
remainText += delta;
|
||||||
options.onUpdate?.(responseText, delta);
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
textmoderation &&
|
||||||
|
textmoderation.length > 0 &&
|
||||||
|
ServiceProvider.Azure
|
||||||
|
) {
|
||||||
|
const contentFilterResults =
|
||||||
|
textmoderation[0]?.content_filter_results;
|
||||||
|
console.log(
|
||||||
|
`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
|
||||||
|
contentFilterResults,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Request] parse error", text, msg);
|
console.error("[Request] parse error", text, msg);
|
||||||
@@ -271,6 +379,11 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
return chatModels.map((m) => ({
|
return chatModels.map((m) => ({
|
||||||
name: m.id,
|
name: m.id,
|
||||||
available: true,
|
available: true,
|
||||||
|
provider: {
|
||||||
|
id: "openai",
|
||||||
|
providerName: "OpenAI",
|
||||||
|
providerType: "openai",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
app/client/providers/anthropic/config.ts
Normal file
131
app/client/providers/anthropic/config.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { SettingItem } from "../../common";
|
||||||
|
import Locale from "./locale";
|
||||||
|
|
||||||
|
export type SettingKeys =
|
||||||
|
| "anthropicUrl"
|
||||||
|
| "anthropicApiKey"
|
||||||
|
| "anthropicApiVersion";
|
||||||
|
|
||||||
|
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||||
|
|
||||||
|
export const AnthropicMetas = {
|
||||||
|
ChatPath: "v1/messages",
|
||||||
|
ExampleEndpoint: ANTHROPIC_BASE_URL,
|
||||||
|
Vision: "2023-06-01",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClaudeMapper = {
|
||||||
|
assistant: "assistant",
|
||||||
|
user: "user",
|
||||||
|
system: "user",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const modelConfigs = [
|
||||||
|
{
|
||||||
|
name: "claude-instant-1.2",
|
||||||
|
displayName: "claude-instant-1.2",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claude-2.0",
|
||||||
|
displayName: "claude-2.0",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claude-2.1",
|
||||||
|
displayName: "claude-2.1",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claude-3-sonnet-20240229",
|
||||||
|
displayName: "claude-3-sonnet-20240229",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claude-3-opus-20240229",
|
||||||
|
displayName: "claude-3-opus-20240229",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claude-3-haiku-20240307",
|
||||||
|
displayName: "claude-3-haiku-20240307",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const preferredRegion: string | string[] = [
|
||||||
|
"arn1",
|
||||||
|
"bom1",
|
||||||
|
"cdg1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"dub1",
|
||||||
|
"fra1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"lhr1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const settingItems: (
|
||||||
|
defaultEndpoint: string,
|
||||||
|
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
|
||||||
|
{
|
||||||
|
name: "anthropicUrl",
|
||||||
|
title: Locale.Endpoint.Title,
|
||||||
|
description: Locale.Endpoint.SubTitle + AnthropicMetas.ExampleEndpoint,
|
||||||
|
placeholder: AnthropicMetas.ExampleEndpoint,
|
||||||
|
type: "input",
|
||||||
|
defaultValue: defaultEndpoint,
|
||||||
|
validators: [
|
||||||
|
"required",
|
||||||
|
async (v: any) => {
|
||||||
|
if (typeof v === "string" && !v.startsWith(defaultEndpoint)) {
|
||||||
|
try {
|
||||||
|
new URL(v);
|
||||||
|
} catch (e) {
|
||||||
|
return Locale.Endpoint.Error.IllegalURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof v === "string" && v.endsWith("/")) {
|
||||||
|
return Locale.Endpoint.Error.EndWithBackslash;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropicApiKey",
|
||||||
|
title: Locale.ApiKey.Title,
|
||||||
|
description: Locale.ApiKey.SubTitle,
|
||||||
|
placeholder: Locale.ApiKey.Placeholder,
|
||||||
|
type: "input",
|
||||||
|
inputType: "password",
|
||||||
|
// validators: ["required"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropicApiVersion",
|
||||||
|
title: Locale.ApiVerion.Title,
|
||||||
|
description: Locale.ApiVerion.SubTitle,
|
||||||
|
defaultValue: AnthropicMetas.Vision,
|
||||||
|
type: "input",
|
||||||
|
// validators: ["required"],
|
||||||
|
},
|
||||||
|
];
|
||||||
356
app/client/providers/anthropic/index.ts
Normal file
356
app/client/providers/anthropic/index.ts
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import {
|
||||||
|
ANTHROPIC_BASE_URL,
|
||||||
|
AnthropicMetas,
|
||||||
|
ClaudeMapper,
|
||||||
|
SettingKeys,
|
||||||
|
modelConfigs,
|
||||||
|
preferredRegion,
|
||||||
|
settingItems,
|
||||||
|
} from "./config";
|
||||||
|
import {
|
||||||
|
ChatHandlers,
|
||||||
|
InternalChatRequestPayload,
|
||||||
|
IProviderTemplate,
|
||||||
|
ServerConfig,
|
||||||
|
} from "../../common";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import {
|
||||||
|
prettyObject,
|
||||||
|
getTimer,
|
||||||
|
authHeaderName,
|
||||||
|
auth,
|
||||||
|
parseResp,
|
||||||
|
formatMessage,
|
||||||
|
} from "./utils";
|
||||||
|
import { cloneDeep } from "lodash-es";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export type AnthropicProviderSettingKeys = SettingKeys;
|
||||||
|
|
||||||
|
export type MultiBlockContent = {
|
||||||
|
type: "image" | "text";
|
||||||
|
source?: {
|
||||||
|
type: string;
|
||||||
|
media_type: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnthropicMessage = {
|
||||||
|
role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper];
|
||||||
|
content: string | MultiBlockContent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AnthropicChatRequest {
|
||||||
|
model: string; // The model that will complete your prompt.
|
||||||
|
messages: AnthropicMessage[]; // The prompt that you want Claude to complete.
|
||||||
|
max_tokens: number; // The maximum number of tokens to generate before stopping.
|
||||||
|
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
|
||||||
|
temperature?: number; // Amount of randomness injected into the response.
|
||||||
|
top_p?: number; // Use nucleus sampling.
|
||||||
|
top_k?: number; // Only sample from the top K options for each subsequent token.
|
||||||
|
metadata?: object; // An object describing metadata about the request.
|
||||||
|
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRequest {
|
||||||
|
model: string; // The model that will complete your prompt.
|
||||||
|
prompt: string; // The prompt that you want Claude to complete.
|
||||||
|
max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping.
|
||||||
|
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
|
||||||
|
temperature?: number; // Amount of randomness injected into the response.
|
||||||
|
top_p?: number; // Use nucleus sampling.
|
||||||
|
top_k?: number; // Only sample from the top K options for each subsequent token.
|
||||||
|
metadata?: object; // An object describing metadata about the request.
|
||||||
|
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderTemplate = IProviderTemplate<
|
||||||
|
SettingKeys,
|
||||||
|
"anthropic",
|
||||||
|
typeof AnthropicMetas
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default class AnthropicProvider implements ProviderTemplate {
|
||||||
|
apiRouteRootName = "/api/provider/anthropic" as const;
|
||||||
|
allowedApiMethods: ["GET", "POST"] = ["GET", "POST"];
|
||||||
|
|
||||||
|
runtime = "edge" as const;
|
||||||
|
preferredRegion = preferredRegion;
|
||||||
|
|
||||||
|
name = "anthropic" as const;
|
||||||
|
|
||||||
|
metas = AnthropicMetas;
|
||||||
|
|
||||||
|
providerMeta = {
|
||||||
|
displayName: "Anthropic",
|
||||||
|
settingItems: settingItems(
|
||||||
|
`${this.apiRouteRootName}//${AnthropicMetas.ChatPath}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultModels = modelConfigs;
|
||||||
|
|
||||||
|
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
|
||||||
|
const {
|
||||||
|
messages: outsideMessages,
|
||||||
|
model,
|
||||||
|
stream,
|
||||||
|
modelConfig,
|
||||||
|
providerConfig,
|
||||||
|
} = payload;
|
||||||
|
const { anthropicApiKey, anthropicApiVersion, anthropicUrl } =
|
||||||
|
providerConfig;
|
||||||
|
const { temperature, top_p, max_tokens } = modelConfig;
|
||||||
|
|
||||||
|
const keys = ["system", "user"];
|
||||||
|
|
||||||
|
// roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages
|
||||||
|
const messages = cloneDeep(outsideMessages);
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length - 1; i++) {
|
||||||
|
const message = messages[i];
|
||||||
|
const nextMessage = messages[i + 1];
|
||||||
|
|
||||||
|
if (keys.includes(message.role) && keys.includes(nextMessage.role)) {
|
||||||
|
messages[i] = [
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: ";",
|
||||||
|
},
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = formatMessage(messages, payload.isVisionModel);
|
||||||
|
|
||||||
|
const requestBody: AnthropicChatRequest = {
|
||||||
|
messages: prompt,
|
||||||
|
stream,
|
||||||
|
model,
|
||||||
|
max_tokens,
|
||||||
|
temperature,
|
||||||
|
top_p,
|
||||||
|
top_k: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
[authHeaderName]: anthropicApiKey ?? "",
|
||||||
|
"anthropic-version": anthropicApiVersion ?? "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
method: "POST",
|
||||||
|
url: anthropicUrl!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const authValue = req.headers.get(authHeaderName) ?? "";
|
||||||
|
|
||||||
|
const path = `${req.nextUrl.pathname}`.replaceAll(
|
||||||
|
this.apiRouteRootName,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseUrl = serverConfig.anthropicUrl || ANTHROPIC_BASE_URL;
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchUrl = `${baseUrl}${path}`;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
[authHeaderName]: authValue,
|
||||||
|
"anthropic-version":
|
||||||
|
req.headers.get("anthropic-version") ||
|
||||||
|
serverConfig.anthropicApiVersion ||
|
||||||
|
AnthropicMetas.Vision,
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Anthropic request]", fetchOptions.headers, req.method);
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to disable nginx buffering
|
||||||
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
return new NextResponse(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
const res = await fetch(requestPayload.url, {
|
||||||
|
headers: {
|
||||||
|
...requestPayload.headers,
|
||||||
|
},
|
||||||
|
body: requestPayload.body,
|
||||||
|
method: requestPayload.method,
|
||||||
|
signal: timer.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
timer.clear();
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = parseResp(resJson);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamChat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
handlers: ChatHandlers,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
fetchEventSource(requestPayload.url, {
|
||||||
|
...requestPayload,
|
||||||
|
fetch,
|
||||||
|
async onopen(res) {
|
||||||
|
timer.clear();
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("[OpenAI] request response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
const responseText = await res.clone().text();
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [];
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.choices as Array<{
|
||||||
|
delta: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.delta?.content;
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
handlers.onProgress(delta);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
handlers.onFinish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
handlers.onError(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
|
||||||
|
async (req, config) => {
|
||||||
|
const { subpath } = req;
|
||||||
|
const ALLOWD_PATH = [AnthropicMetas.ChatPath];
|
||||||
|
|
||||||
|
if (!ALLOWD_PATH.includes(subpath)) {
|
||||||
|
console.log("[Anthropic Route] forbidden path ", subpath);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to request " + subpath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, config);
|
||||||
|
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.request(req, config);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Anthropic] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
134
app/client/providers/anthropic/locale.ts
Normal file
134
app/client/providers/anthropic/locale.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { getLocaleText } from "../../common";
|
||||||
|
|
||||||
|
export default getLocaleText<
|
||||||
|
{
|
||||||
|
ApiKey: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Placeholder: string;
|
||||||
|
};
|
||||||
|
Endpoint: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: string;
|
||||||
|
IllegalURL: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
ApiVerion: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
"en"
|
||||||
|
>(
|
||||||
|
{
|
||||||
|
cn: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "接口密钥",
|
||||||
|
SubTitle: "使用自定义 Anthropic Key 绕过密码访问限制",
|
||||||
|
Placeholder: "Anthropic API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "接口地址",
|
||||||
|
SubTitle: "样例:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」结尾",
|
||||||
|
IllegalURL: "请输入一个完整可用的url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "接口版本 (claude api version)",
|
||||||
|
SubTitle: "选择一个特定的 API 版本输入",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "Anthropic API Key",
|
||||||
|
SubTitle:
|
||||||
|
"Use a custom Anthropic Key to bypass password access restrictions",
|
||||||
|
Placeholder: "Anthropic API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Endpoint Address",
|
||||||
|
SubTitle: "Example:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Cannot end with '/'",
|
||||||
|
IllegalURL: "Please enter a complete available url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "API Version (claude api version)",
|
||||||
|
SubTitle: "Select and input a specific API version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "Chave API Anthropic",
|
||||||
|
SubTitle: "Verifique sua chave API do console Anthropic",
|
||||||
|
Placeholder: "Chave API Anthropic",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Endpoint Address",
|
||||||
|
SubTitle: "Exemplo: ",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Não é possível terminar com '/'",
|
||||||
|
IllegalURL: "Insira um URL completo disponível",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "Versão API (Versão api claude)",
|
||||||
|
SubTitle: "Verifique sua versão API do console Anthropic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sk: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API kľúč Anthropic",
|
||||||
|
SubTitle: "Skontrolujte svoj API kľúč v Anthropic konzole",
|
||||||
|
Placeholder: "API kľúč Anthropic",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Adresa koncového bodu",
|
||||||
|
SubTitle: "Príklad:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Nemôže končiť znakom „/“",
|
||||||
|
IllegalURL: "Zadajte úplnú dostupnú adresu URL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "Verzia API (claude verzia API)",
|
||||||
|
SubTitle: "Vyberte špecifickú verziu časti",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tw: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API 金鑰",
|
||||||
|
SubTitle: "從 Anthropic AI 取得您的 API 金鑰",
|
||||||
|
Placeholder: "Anthropic API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "終端地址",
|
||||||
|
SubTitle: "範例:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」結尾",
|
||||||
|
IllegalURL: "請輸入一個完整可用的url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "API 版本 (claude api version)",
|
||||||
|
SubTitle: "選擇一個特定的 API 版本輸入",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"en",
|
||||||
|
);
|
||||||
151
app/client/providers/anthropic/utils.ts
Normal file
151
app/client/providers/anthropic/utils.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import {
|
||||||
|
RequestMessage,
|
||||||
|
ServerConfig,
|
||||||
|
getIP,
|
||||||
|
getMessageTextContent,
|
||||||
|
} from "../../common";
|
||||||
|
import { ClaudeMapper } from "./config";
|
||||||
|
|
||||||
|
export const REQUEST_TIMEOUT_MS = 60000;
|
||||||
|
export const authHeaderName = "x-api-key";
|
||||||
|
|
||||||
|
export function trimEnd(s: string, end = " ") {
|
||||||
|
if (end.length === 0) return s;
|
||||||
|
|
||||||
|
while (s.endsWith(end)) {
|
||||||
|
s = s.slice(0, -end.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bearer(value: string) {
|
||||||
|
return `Bearer ${value.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyObject(msg: any) {
|
||||||
|
const obj = msg;
|
||||||
|
if (typeof msg !== "string") {
|
||||||
|
msg = JSON.stringify(msg, null, " ");
|
||||||
|
}
|
||||||
|
if (msg === "{}") {
|
||||||
|
return obj.toString();
|
||||||
|
}
|
||||||
|
if (msg.startsWith("```json")) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
return ["```json", msg, "```"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimer() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...controller,
|
||||||
|
clear: () => {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auth(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
const apiKey = req.headers.get(authHeaderName);
|
||||||
|
|
||||||
|
console.log("[User IP] ", getIP(req));
|
||||||
|
console.log("[Time] ", new Date().toLocaleString());
|
||||||
|
|
||||||
|
if (serverConfig.hideUserApiKey && apiKey) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to access with your own api key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
console.log("[Auth] use user api key");
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// if user does not provide an api key, inject system api key
|
||||||
|
const systemApiKey = serverConfig.anthropicApiKey;
|
||||||
|
|
||||||
|
if (systemApiKey) {
|
||||||
|
console.log("[Auth] use system api key");
|
||||||
|
req.headers.set(authHeaderName, systemApiKey);
|
||||||
|
} else {
|
||||||
|
console.log("[Auth] admin did not provide an api key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResp(res: any) {
|
||||||
|
return {
|
||||||
|
message: res?.content?.[0]?.text ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessage(
|
||||||
|
messages: RequestMessage[],
|
||||||
|
isVisionModel?: boolean,
|
||||||
|
) {
|
||||||
|
return messages
|
||||||
|
.flat()
|
||||||
|
.filter((v) => {
|
||||||
|
if (!v.content) return false;
|
||||||
|
if (typeof v.content === "string" && !v.content.trim()) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((v) => {
|
||||||
|
const { role, content } = v;
|
||||||
|
const insideRole = ClaudeMapper[role] ?? "user";
|
||||||
|
|
||||||
|
if (!isVisionModel || typeof content === "string") {
|
||||||
|
return {
|
||||||
|
role: insideRole,
|
||||||
|
content: getMessageTextContent(v),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: insideRole,
|
||||||
|
content: content
|
||||||
|
.filter((v) => v.image_url || v.text)
|
||||||
|
.map(({ type, text, image_url }) => {
|
||||||
|
if (type === "text") {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
text: text!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { url = "" } = image_url || {};
|
||||||
|
const colonIndex = url.indexOf(":");
|
||||||
|
const semicolonIndex = url.indexOf(";");
|
||||||
|
const comma = url.indexOf(",");
|
||||||
|
|
||||||
|
const mimeType = url.slice(colonIndex + 1, semicolonIndex);
|
||||||
|
const encodeType = url.slice(semicolonIndex + 1, comma);
|
||||||
|
const data = url.slice(comma + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "image" as const,
|
||||||
|
source: {
|
||||||
|
type: encodeType,
|
||||||
|
media_type: mimeType,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
79
app/client/providers/azure/config.ts
Normal file
79
app/client/providers/azure/config.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Locale from "./locale";
|
||||||
|
|
||||||
|
import { SettingItem } from "../../common";
|
||||||
|
import { modelConfigs as openaiModelConfigs } from "../openai/config";
|
||||||
|
|
||||||
|
export const AzureMetas = {
|
||||||
|
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
|
||||||
|
ChatPath: "chat/completions",
|
||||||
|
ListModelPath: "v1/models",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingKeys = "azureUrl" | "azureApiKey" | "azureApiVersion";
|
||||||
|
|
||||||
|
export const preferredRegion: string | string[] = [
|
||||||
|
"arn1",
|
||||||
|
"bom1",
|
||||||
|
"cdg1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"dub1",
|
||||||
|
"fra1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"lhr1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const modelConfigs = openaiModelConfigs;
|
||||||
|
|
||||||
|
export const settingItems: (
|
||||||
|
defaultEndpoint: string,
|
||||||
|
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
|
||||||
|
{
|
||||||
|
name: "azureUrl",
|
||||||
|
title: Locale.Endpoint.Title,
|
||||||
|
description: Locale.Endpoint.SubTitle + AzureMetas.ExampleEndpoint,
|
||||||
|
placeholder: AzureMetas.ExampleEndpoint,
|
||||||
|
type: "input",
|
||||||
|
defaultValue: defaultEndpoint,
|
||||||
|
validators: [
|
||||||
|
async (v: any) => {
|
||||||
|
if (typeof v === "string") {
|
||||||
|
try {
|
||||||
|
new URL(v);
|
||||||
|
} catch (e) {
|
||||||
|
return Locale.Endpoint.Error.IllegalURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof v === "string" && v.endsWith("/")) {
|
||||||
|
return Locale.Endpoint.Error.EndWithBackslash;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "azureApiKey",
|
||||||
|
title: Locale.ApiKey.Title,
|
||||||
|
description: Locale.ApiKey.SubTitle,
|
||||||
|
placeholder: Locale.ApiKey.Placeholder,
|
||||||
|
type: "input",
|
||||||
|
inputType: "password",
|
||||||
|
validators: ["required"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "azureApiVersion",
|
||||||
|
title: Locale.ApiVerion.Title,
|
||||||
|
description: Locale.ApiVerion.SubTitle,
|
||||||
|
placeholder: "2023-08-01-preview",
|
||||||
|
type: "input",
|
||||||
|
validators: ["required"],
|
||||||
|
},
|
||||||
|
];
|
||||||
408
app/client/providers/azure/index.ts
Normal file
408
app/client/providers/azure/index.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import {
|
||||||
|
settingItems,
|
||||||
|
SettingKeys,
|
||||||
|
modelConfigs,
|
||||||
|
AzureMetas,
|
||||||
|
preferredRegion,
|
||||||
|
} from "./config";
|
||||||
|
import {
|
||||||
|
ChatHandlers,
|
||||||
|
InternalChatRequestPayload,
|
||||||
|
IProviderTemplate,
|
||||||
|
ModelInfo,
|
||||||
|
getMessageTextContent,
|
||||||
|
ServerConfig,
|
||||||
|
} from "../../common";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
authHeaderName,
|
||||||
|
getHeaders,
|
||||||
|
getTimer,
|
||||||
|
makeAzurePath,
|
||||||
|
parseResp,
|
||||||
|
prettyObject,
|
||||||
|
} from "./utils";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export type AzureProviderSettingKeys = SettingKeys;
|
||||||
|
|
||||||
|
export const ROLES = ["system", "user", "assistant"] as const;
|
||||||
|
export type MessageRole = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
export interface MultimodalContent {
|
||||||
|
type: "text" | "image_url";
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestMessage {
|
||||||
|
role: MessageRole;
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestPayload {
|
||||||
|
messages: {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}[];
|
||||||
|
stream?: boolean;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
presence_penalty: number;
|
||||||
|
frequency_penalty: number;
|
||||||
|
top_p: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelList {
|
||||||
|
object: "list";
|
||||||
|
data: Array<{
|
||||||
|
capabilities: {
|
||||||
|
fine_tune: boolean;
|
||||||
|
inference: boolean;
|
||||||
|
completion: boolean;
|
||||||
|
chat_completion: boolean;
|
||||||
|
embeddings: boolean;
|
||||||
|
};
|
||||||
|
lifecycle_status: "generally-available";
|
||||||
|
id: string;
|
||||||
|
created_at: number;
|
||||||
|
object: "model";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAIListModelResponse {
|
||||||
|
object: string;
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
root: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderTemplate = IProviderTemplate<
|
||||||
|
SettingKeys,
|
||||||
|
"azure",
|
||||||
|
typeof AzureMetas
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default class Azure implements ProviderTemplate {
|
||||||
|
apiRouteRootName: "/api/provider/azure" = "/api/provider/azure";
|
||||||
|
allowedApiMethods: (
|
||||||
|
| "POST"
|
||||||
|
| "GET"
|
||||||
|
| "OPTIONS"
|
||||||
|
| "PUT"
|
||||||
|
| "PATCH"
|
||||||
|
| "DELETE"
|
||||||
|
)[] = ["POST", "GET"];
|
||||||
|
runtime = "edge" as const;
|
||||||
|
|
||||||
|
preferredRegion = preferredRegion;
|
||||||
|
|
||||||
|
name = "azure" as const;
|
||||||
|
metas = AzureMetas;
|
||||||
|
|
||||||
|
defaultModels = modelConfigs;
|
||||||
|
|
||||||
|
providerMeta = {
|
||||||
|
displayName: "Azure",
|
||||||
|
settingItems: settingItems(
|
||||||
|
`${this.apiRouteRootName}/${AzureMetas.ChatPath}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
isVisionModel,
|
||||||
|
model,
|
||||||
|
stream,
|
||||||
|
modelConfig: {
|
||||||
|
temperature,
|
||||||
|
presence_penalty,
|
||||||
|
frequency_penalty,
|
||||||
|
top_p,
|
||||||
|
max_tokens,
|
||||||
|
},
|
||||||
|
providerConfig: { azureUrl, azureApiVersion },
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
const openAiMessages = messages.map((v) => ({
|
||||||
|
role: v.role,
|
||||||
|
content: isVisionModel ? v.content : getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
messages: openAiMessages,
|
||||||
|
stream,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
presence_penalty,
|
||||||
|
frequency_penalty,
|
||||||
|
top_p,
|
||||||
|
};
|
||||||
|
|
||||||
|
// add max_tokens to vision model
|
||||||
|
if (isVisionModel) {
|
||||||
|
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: getHeaders(payload.providerConfig.azureApiKey),
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
method: "POST",
|
||||||
|
url: `${azureUrl}?api-version=${azureApiVersion!}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestAzure(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const authValue =
|
||||||
|
req.headers
|
||||||
|
.get("Authorization")
|
||||||
|
?.trim()
|
||||||
|
.replaceAll("Bearer ", "")
|
||||||
|
.trim() ?? "";
|
||||||
|
|
||||||
|
const { azureUrl, azureApiVersion } = serverConfig;
|
||||||
|
|
||||||
|
if (!azureUrl) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: true,
|
||||||
|
message: `missing AZURE_URL in server env vars`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!azureApiVersion) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: true,
|
||||||
|
message: `missing AZURE_API_VERSION in server env vars`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||||
|
this.apiRouteRootName,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
path = makeAzurePath(path, azureApiVersion);
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", azureUrl);
|
||||||
|
|
||||||
|
const fetchUrl = `${azureUrl}/${path}`;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
[authHeaderName]: authValue,
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to disable nginx buffering
|
||||||
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
|
||||||
|
// So if the streaming is disabled, we need to remove the content-encoding header
|
||||||
|
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
|
||||||
|
// The browser will try to decode the response with brotli and fail
|
||||||
|
newHeaders.delete("content-encoding");
|
||||||
|
|
||||||
|
return new NextResponse(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
const res = await fetch(requestPayload.url, {
|
||||||
|
headers: {
|
||||||
|
...requestPayload.headers,
|
||||||
|
},
|
||||||
|
body: requestPayload.body,
|
||||||
|
method: requestPayload.method,
|
||||||
|
signal: timer.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
timer.clear();
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = parseResp(resJson);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamChat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
handlers: ChatHandlers,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
fetchEventSource(requestPayload.url, {
|
||||||
|
...requestPayload,
|
||||||
|
fetch,
|
||||||
|
async onopen(res) {
|
||||||
|
timer.clear();
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("[OpenAI] request response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
const responseText = await res.clone().text();
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [];
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.choices as Array<{
|
||||||
|
delta: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.delta?.content;
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
handlers.onProgress(delta);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
handlers.onFinish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
handlers.onError(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModels(
|
||||||
|
providerConfig: Record<SettingKeys, string>,
|
||||||
|
): Promise<ModelInfo[]> {
|
||||||
|
const { azureApiKey, azureUrl } = providerConfig;
|
||||||
|
const res = await fetch(`${azureUrl}/${AzureMetas.ListModelPath}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${azureApiKey}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
const data: ModelList = await res.json();
|
||||||
|
|
||||||
|
return data.data.map((o) => ({
|
||||||
|
name: o.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
|
||||||
|
async (req, config) => {
|
||||||
|
const { subpath } = req;
|
||||||
|
const ALLOWD_PATH = [AzureMetas.ChatPath];
|
||||||
|
|
||||||
|
if (!ALLOWD_PATH.includes(subpath)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to request " + subpath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, config);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestAzure(req, config);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
133
app/client/providers/azure/locale.ts
Normal file
133
app/client/providers/azure/locale.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { getLocaleText } from "../../common";
|
||||||
|
|
||||||
|
export default getLocaleText<
|
||||||
|
{
|
||||||
|
ApiKey: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Placeholder: string;
|
||||||
|
};
|
||||||
|
Endpoint: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: string;
|
||||||
|
IllegalURL: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
ApiVerion: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
"en"
|
||||||
|
>(
|
||||||
|
{
|
||||||
|
cn: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "接口密钥",
|
||||||
|
SubTitle: "使用自定义 Azure Key 绕过密码访问限制",
|
||||||
|
Placeholder: "Azure API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "接口地址",
|
||||||
|
SubTitle: "样例:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」结尾",
|
||||||
|
IllegalURL: "请输入一个完整可用的url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "接口版本 (azure api version)",
|
||||||
|
SubTitle: "选择指定的部分版本",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "Azure Api Key",
|
||||||
|
SubTitle: "Check your api key from Azure console",
|
||||||
|
Placeholder: "Azure Api Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Azure Endpoint",
|
||||||
|
SubTitle: "Example: ",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Cannot end with '/'",
|
||||||
|
IllegalURL: "Please enter a complete available url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "Azure Api Version",
|
||||||
|
SubTitle: "Check your api version from azure console",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "Chave API Azure",
|
||||||
|
SubTitle: "Verifique sua chave API do console Azure",
|
||||||
|
Placeholder: "Chave API Azure",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Endpoint Azure",
|
||||||
|
SubTitle: "Exemplo: ",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Não é possível terminar com '/'",
|
||||||
|
IllegalURL: "Insira um URL completo disponível",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "Versão API Azure",
|
||||||
|
SubTitle: "Verifique sua versão API do console Azure",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sk: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API kľúč Azure",
|
||||||
|
SubTitle: "Skontrolujte svoj API kľúč v Azure konzole",
|
||||||
|
Placeholder: "API kľúč Azure",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Koncový bod Azure",
|
||||||
|
SubTitle: "Príklad: ",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Nemôže končiť znakom „/“",
|
||||||
|
IllegalURL: "Zadajte úplnú dostupnú adresu URL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "Verzia API Azure",
|
||||||
|
SubTitle: "Skontrolujte svoju verziu API v Azure konzole",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tw: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "介面金鑰",
|
||||||
|
SubTitle: "使用自定義 Azure Key 繞過密碼存取限制",
|
||||||
|
Placeholder: "Azure API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "介面(Endpoint) 地址",
|
||||||
|
SubTitle: "樣例:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」結尾",
|
||||||
|
IllegalURL: "請輸入一個完整可用的url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVerion: {
|
||||||
|
Title: "介面版本 (azure api version)",
|
||||||
|
SubTitle: "選擇指定的部分版本",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"en",
|
||||||
|
);
|
||||||
110
app/client/providers/azure/utils.ts
Normal file
110
app/client/providers/azure/utils.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { ServerConfig, getIP } from "../../common";
|
||||||
|
|
||||||
|
export const authHeaderName = "api-key";
|
||||||
|
export const REQUEST_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export function getHeaders(azureApiKey?: string) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validString(azureApiKey)) {
|
||||||
|
headers[authHeaderName] = makeBearer(azureApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResp(res: any) {
|
||||||
|
return {
|
||||||
|
message: res.choices?.at(0)?.message?.content ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAzurePath(path: string, apiVersion: string) {
|
||||||
|
// should add api-key to query string
|
||||||
|
path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`;
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyObject(msg: any) {
|
||||||
|
const obj = msg;
|
||||||
|
if (typeof msg !== "string") {
|
||||||
|
msg = JSON.stringify(msg, null, " ");
|
||||||
|
}
|
||||||
|
if (msg === "{}") {
|
||||||
|
return obj.toString();
|
||||||
|
}
|
||||||
|
if (msg.startsWith("```json")) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
return ["```json", msg, "```"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeBearer = (s: string) => `Bearer ${s.trim()}`;
|
||||||
|
export const validString = (x?: string): x is string =>
|
||||||
|
Boolean(x && x.length > 0);
|
||||||
|
|
||||||
|
export function parseApiKey(bearToken: string) {
|
||||||
|
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimer() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...controller,
|
||||||
|
clear: () => {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auth(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
const authToken = req.headers.get(authHeaderName) ?? "";
|
||||||
|
|
||||||
|
const { hideUserApiKey, apiKey: systemApiKey } = serverConfig;
|
||||||
|
|
||||||
|
const { apiKey } = parseApiKey(authToken);
|
||||||
|
|
||||||
|
console.log("[User IP] ", getIP(req));
|
||||||
|
console.log("[Time] ", new Date().toLocaleString());
|
||||||
|
|
||||||
|
if (hideUserApiKey && apiKey) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to access with your own api key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
console.log("[Auth] use user api key");
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemApiKey) {
|
||||||
|
console.log("[Auth] use system api key");
|
||||||
|
req.headers.set("Authorization", `Bearer ${systemApiKey}`);
|
||||||
|
} else {
|
||||||
|
console.log("[Auth] admin did not provide an api key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
95
app/client/providers/google/config.ts
Normal file
95
app/client/providers/google/config.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { SettingItem } from "../../common";
|
||||||
|
import Locale from "./locale";
|
||||||
|
|
||||||
|
export const preferredRegion: string | string[] = [
|
||||||
|
"bom1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
|
||||||
|
|
||||||
|
export const GoogleMetas = {
|
||||||
|
ExampleEndpoint: GEMINI_BASE_URL,
|
||||||
|
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingKeys = "googleUrl" | "googleApiKey" | "googleApiVersion";
|
||||||
|
|
||||||
|
export const modelConfigs = [
|
||||||
|
{
|
||||||
|
name: "gemini-1.0-pro",
|
||||||
|
displayName: "gemini-1.0-pro",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gemini-1.5-pro-latest",
|
||||||
|
displayName: "gemini-1.5-pro-latest",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gemini-pro-vision",
|
||||||
|
displayName: "gemini-pro-vision",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const settingItems: (
|
||||||
|
defaultEndpoint: string,
|
||||||
|
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
|
||||||
|
{
|
||||||
|
name: "googleUrl",
|
||||||
|
title: Locale.Endpoint.Title,
|
||||||
|
description: Locale.Endpoint.SubTitle + GoogleMetas.ExampleEndpoint,
|
||||||
|
placeholder: GoogleMetas.ExampleEndpoint,
|
||||||
|
type: "input",
|
||||||
|
defaultValue: defaultEndpoint,
|
||||||
|
validators: [
|
||||||
|
async (v: any) => {
|
||||||
|
if (typeof v === "string") {
|
||||||
|
try {
|
||||||
|
new URL(v);
|
||||||
|
} catch (e) {
|
||||||
|
return Locale.Endpoint.Error.IllegalURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof v === "string" && v.endsWith("/")) {
|
||||||
|
return Locale.Endpoint.Error.EndWithBackslash;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "googleApiKey",
|
||||||
|
title: Locale.ApiKey.Title,
|
||||||
|
description: Locale.ApiKey.SubTitle,
|
||||||
|
placeholder: Locale.ApiKey.Placeholder,
|
||||||
|
type: "input",
|
||||||
|
inputType: "password",
|
||||||
|
// validators: ["required"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "googleApiVersion",
|
||||||
|
title: Locale.ApiVersion.Title,
|
||||||
|
description: Locale.ApiVersion.SubTitle,
|
||||||
|
placeholder: "2023-08-01-preview",
|
||||||
|
type: "input",
|
||||||
|
// validators: ["required"],
|
||||||
|
},
|
||||||
|
];
|
||||||
353
app/client/providers/google/index.ts
Normal file
353
app/client/providers/google/index.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import {
|
||||||
|
SettingKeys,
|
||||||
|
modelConfigs,
|
||||||
|
settingItems,
|
||||||
|
GoogleMetas,
|
||||||
|
GEMINI_BASE_URL,
|
||||||
|
preferredRegion,
|
||||||
|
} from "./config";
|
||||||
|
import {
|
||||||
|
ChatHandlers,
|
||||||
|
InternalChatRequestPayload,
|
||||||
|
IProviderTemplate,
|
||||||
|
ModelInfo,
|
||||||
|
StandChatReponseMessage,
|
||||||
|
getMessageTextContent,
|
||||||
|
getMessageImages,
|
||||||
|
} from "../../common";
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
ensureProperEnding,
|
||||||
|
getTimer,
|
||||||
|
parseResp,
|
||||||
|
urlParamApikeyName,
|
||||||
|
} from "./utils";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export type GoogleProviderSettingKeys = SettingKeys;
|
||||||
|
|
||||||
|
interface ModelList {
|
||||||
|
models: Array<{
|
||||||
|
name: string;
|
||||||
|
baseModelId: string;
|
||||||
|
version: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
inputTokenLimit: number; // Integer
|
||||||
|
outputTokenLimit: number; // Integer
|
||||||
|
supportedGenerationMethods: [string];
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
topK: number; // Integer
|
||||||
|
}>;
|
||||||
|
nextPageToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderTemplate = IProviderTemplate<
|
||||||
|
SettingKeys,
|
||||||
|
"azure",
|
||||||
|
typeof GoogleMetas
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default class GoogleProvider
|
||||||
|
implements IProviderTemplate<SettingKeys, "google", typeof GoogleMetas>
|
||||||
|
{
|
||||||
|
allowedApiMethods: (
|
||||||
|
| "POST"
|
||||||
|
| "GET"
|
||||||
|
| "OPTIONS"
|
||||||
|
| "PUT"
|
||||||
|
| "PATCH"
|
||||||
|
| "DELETE"
|
||||||
|
)[] = ["GET", "POST"];
|
||||||
|
runtime = "edge" as const;
|
||||||
|
|
||||||
|
apiRouteRootName: "/api/provider/google" = "/api/provider/google";
|
||||||
|
|
||||||
|
preferredRegion = preferredRegion;
|
||||||
|
|
||||||
|
name = "google" as const;
|
||||||
|
metas = GoogleMetas;
|
||||||
|
|
||||||
|
providerMeta = {
|
||||||
|
displayName: "Google",
|
||||||
|
settingItems: settingItems(this.apiRouteRootName),
|
||||||
|
};
|
||||||
|
defaultModels = modelConfigs;
|
||||||
|
|
||||||
|
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
isVisionModel,
|
||||||
|
model,
|
||||||
|
stream,
|
||||||
|
modelConfig,
|
||||||
|
providerConfig,
|
||||||
|
} = payload;
|
||||||
|
const { googleUrl, googleApiKey } = providerConfig;
|
||||||
|
const { temperature, top_p, max_tokens } = modelConfig;
|
||||||
|
|
||||||
|
const internalMessages = messages.map((v) => {
|
||||||
|
let parts: any[] = [{ text: getMessageTextContent(v) }];
|
||||||
|
|
||||||
|
if (isVisionModel) {
|
||||||
|
const images = getMessageImages(v);
|
||||||
|
if (images.length > 0) {
|
||||||
|
parts = parts.concat(
|
||||||
|
images.map((image) => {
|
||||||
|
const imageType = image.split(";")[0].split(":")[1];
|
||||||
|
const imageData = image.split(",")[1];
|
||||||
|
return {
|
||||||
|
inline_data: {
|
||||||
|
mime_type: imageType,
|
||||||
|
data: imageData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: v.role.replace("assistant", "model").replace("system", "user"),
|
||||||
|
parts: parts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// google requires that role in neighboring messages must not be the same
|
||||||
|
for (let i = 0; i < internalMessages.length - 1; ) {
|
||||||
|
// Check if current and next item both have the role "model"
|
||||||
|
if (internalMessages[i].role === internalMessages[i + 1].role) {
|
||||||
|
// Concatenate the 'parts' of the current and next item
|
||||||
|
internalMessages[i].parts = internalMessages[i].parts.concat(
|
||||||
|
internalMessages[i + 1].parts,
|
||||||
|
);
|
||||||
|
// Remove the next item
|
||||||
|
internalMessages.splice(i + 1, 1);
|
||||||
|
} else {
|
||||||
|
// Move to the next item
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPayload = {
|
||||||
|
contents: internalMessages,
|
||||||
|
generationConfig: {
|
||||||
|
temperature,
|
||||||
|
maxOutputTokens: max_tokens,
|
||||||
|
topP: top_p,
|
||||||
|
},
|
||||||
|
safetySettings: [
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_HARASSMENT",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||||
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseUrl = `${googleUrl}/${GoogleMetas.ChatPath(
|
||||||
|
model,
|
||||||
|
)}?${urlParamApikeyName}=${googleApiKey}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
method: "POST",
|
||||||
|
url: stream
|
||||||
|
? baseUrl.replace("generateContent", "streamGenerateContent")
|
||||||
|
: baseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
streamChat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
handlers: ChatHandlers,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
let existingTexts: string[] = [];
|
||||||
|
|
||||||
|
fetch(requestPayload.url, {
|
||||||
|
...requestPayload,
|
||||||
|
signal: timer.signal,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const reader = response?.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let partialData = "";
|
||||||
|
|
||||||
|
return reader?.read().then(function processText({
|
||||||
|
done,
|
||||||
|
value,
|
||||||
|
}): Promise<any> {
|
||||||
|
if (done) {
|
||||||
|
if (response.status !== 200) {
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(ensureProperEnding(partialData));
|
||||||
|
if (data && data[0].error) {
|
||||||
|
handlers.onError(new Error(data[0].error.message));
|
||||||
|
} else {
|
||||||
|
handlers.onError(new Error("Request failed"));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
handlers.onError(new Error("Request failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Stream complete");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
partialData += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(ensureProperEnding(partialData));
|
||||||
|
|
||||||
|
const textArray = data.reduce(
|
||||||
|
(acc: string[], item: { candidates: any[] }) => {
|
||||||
|
const texts = item.candidates.map((candidate) =>
|
||||||
|
candidate.content.parts
|
||||||
|
.map((part: { text: any }) => part.text)
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
return acc.concat(texts);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textArray.length > existingTexts.length) {
|
||||||
|
const deltaArray = textArray.slice(existingTexts.length);
|
||||||
|
existingTexts = textArray;
|
||||||
|
handlers.onProgress(deltaArray.join(""));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.log("[Response Animation] error: ", error,partialData);
|
||||||
|
// skip error message when parsing json
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader.read().then(processText);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error:", error);
|
||||||
|
});
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
): Promise<StandChatReponseMessage> {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
const res = await fetch(requestPayload.url, {
|
||||||
|
headers: {
|
||||||
|
...requestPayload.headers,
|
||||||
|
},
|
||||||
|
body: requestPayload.body,
|
||||||
|
method: requestPayload.method,
|
||||||
|
signal: timer.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
timer.clear();
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = parseResp(resJson);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModels(
|
||||||
|
providerConfig: Record<SettingKeys, string>,
|
||||||
|
): Promise<ModelInfo[]> {
|
||||||
|
const { googleApiKey, googleUrl } = providerConfig;
|
||||||
|
const res = await fetch(`${googleUrl}/v1beta/models?key=${googleApiKey}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${googleApiKey}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
const data: ModelList = await res.json();
|
||||||
|
|
||||||
|
return data.models;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
|
||||||
|
async (req, serverConfig) => {
|
||||||
|
const { googleUrl = GEMINI_BASE_URL } = serverConfig;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const path = `${req.nextUrl.pathname}`.replaceAll(
|
||||||
|
this.apiRouteRootName,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", googleUrl);
|
||||||
|
|
||||||
|
const authResult = auth(req, serverConfig);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUrl = `${googleUrl}/${path}?key=${authResult.apiKey}`;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to disable nginx buffering
|
||||||
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
return new NextResponse(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
113
app/client/providers/google/locale.ts
Normal file
113
app/client/providers/google/locale.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { getLocaleText } from "../../common";
|
||||||
|
|
||||||
|
export default getLocaleText<
|
||||||
|
{
|
||||||
|
ApiKey: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Placeholder: string;
|
||||||
|
};
|
||||||
|
Endpoint: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: string;
|
||||||
|
IllegalURL: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
ApiVersion: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
"en"
|
||||||
|
>(
|
||||||
|
{
|
||||||
|
cn: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API 密钥",
|
||||||
|
SubTitle: "从 Google AI 获取您的 API 密钥",
|
||||||
|
Placeholder: "输入您的 Google AI Studio API 密钥",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "终端地址",
|
||||||
|
SubTitle: "示例:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」结尾",
|
||||||
|
IllegalURL: "请输入一个完整可用的url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVersion: {
|
||||||
|
Title: "API 版本(仅适用于 gemini-pro)",
|
||||||
|
SubTitle: "选择一个特定的 API 版本",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API Key",
|
||||||
|
SubTitle: "Obtain your API Key from Google AI",
|
||||||
|
Placeholder: "Enter your Google AI Studio API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Endpoint Address",
|
||||||
|
SubTitle: "Example:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Cannot end with '/'",
|
||||||
|
IllegalURL: "Please enter a complete available url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVersion: {
|
||||||
|
Title: "API Version (specific to gemini-pro)",
|
||||||
|
SubTitle: "Select a specific API version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sk: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API kľúč",
|
||||||
|
SubTitle:
|
||||||
|
"Obísť obmedzenia prístupu heslom pomocou vlastného API kľúča Google AI Studio",
|
||||||
|
Placeholder: "API kľúč Google AI Studio",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Adresa koncového bodu",
|
||||||
|
SubTitle: "Príklad:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Nemôže končiť znakom „/“",
|
||||||
|
IllegalURL: "Zadajte úplnú dostupnú adresu URL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVersion: {
|
||||||
|
Title: "Verzia API (gemini-pro verzia API)",
|
||||||
|
SubTitle: "Vyberte špecifickú verziu časti",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tw: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API 金鑰",
|
||||||
|
SubTitle: "從 Google AI 取得您的 API 金鑰",
|
||||||
|
Placeholder: "輸入您的 Google AI Studio API 金鑰",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "終端地址",
|
||||||
|
SubTitle: "範例:",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」結尾",
|
||||||
|
IllegalURL: "請輸入一個完整可用的url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ApiVersion: {
|
||||||
|
Title: "API 版本(僅適用於 gemini-pro)",
|
||||||
|
SubTitle: "選擇一個特定的 API 版本",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"en",
|
||||||
|
);
|
||||||
87
app/client/providers/google/utils.ts
Normal file
87
app/client/providers/google/utils.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { ServerConfig, getIP } from "../../common";
|
||||||
|
|
||||||
|
export const urlParamApikeyName = "key";
|
||||||
|
|
||||||
|
export const REQUEST_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export const makeBearer = (s: string) => `Bearer ${s.trim()}`;
|
||||||
|
export const validString = (x?: string): x is string =>
|
||||||
|
Boolean(x && x.length > 0);
|
||||||
|
|
||||||
|
export function ensureProperEnding(str: string) {
|
||||||
|
if (str.startsWith("[") && !str.endsWith("]")) {
|
||||||
|
return str + "]";
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auth(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
let apiKey = req.nextUrl.searchParams.get(urlParamApikeyName);
|
||||||
|
|
||||||
|
const { hideUserApiKey, googleApiKey } = serverConfig;
|
||||||
|
|
||||||
|
console.log("[User IP] ", getIP(req));
|
||||||
|
console.log("[Time] ", new Date().toLocaleString());
|
||||||
|
|
||||||
|
if (hideUserApiKey && apiKey) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to access with your own api key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
console.log("[Auth] use user api key");
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (googleApiKey) {
|
||||||
|
console.log("[Auth] use system api key");
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
apiKey: googleApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Auth] admin did not provide an api key");
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: `missing api key`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimer() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...controller,
|
||||||
|
clear: () => {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResp(res: any) {
|
||||||
|
if (res?.promptFeedback?.blockReason) {
|
||||||
|
// being blocked
|
||||||
|
throw new Error(
|
||||||
|
"Message is being blocked for reason: " + res.promptFeedback.blockReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
res.candidates?.at(0)?.content?.parts?.at(0)?.text ||
|
||||||
|
res.error?.message ||
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
}
|
||||||
20
app/client/providers/index.ts
Normal file
20
app/client/providers/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export {
|
||||||
|
default as NextChatProvider,
|
||||||
|
type NextChatProviderSettingKeys,
|
||||||
|
} from "@/app/client/providers/nextchat";
|
||||||
|
export {
|
||||||
|
default as GoogleProvider,
|
||||||
|
type GoogleProviderSettingKeys,
|
||||||
|
} from "@/app/client/providers/google";
|
||||||
|
export {
|
||||||
|
default as OpenAIProvider,
|
||||||
|
type OpenAIProviderSettingKeys,
|
||||||
|
} from "@/app/client/providers/openai";
|
||||||
|
export {
|
||||||
|
default as AnthropicProvider,
|
||||||
|
type AnthropicProviderSettingKeys,
|
||||||
|
} from "@/app/client/providers/anthropic";
|
||||||
|
export {
|
||||||
|
default as AzureProvider,
|
||||||
|
type AzureProviderSettingKeys,
|
||||||
|
} from "@/app/client/providers/azure";
|
||||||
89
app/client/providers/nextchat/config.ts
Normal file
89
app/client/providers/nextchat/config.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { SettingItem } from "../../common";
|
||||||
|
import { isVisionModel } from "@/app/utils";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
|
||||||
|
export const OPENAI_BASE_URL = "https://api.openai.com";
|
||||||
|
|
||||||
|
export const NextChatMetas = {
|
||||||
|
ChatPath: "v1/chat/completions",
|
||||||
|
UsagePath: "dashboard/billing/usage",
|
||||||
|
SubsPath: "dashboard/billing/subscription",
|
||||||
|
ListModelPath: "v1/models",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preferredRegion: string | string[] = [
|
||||||
|
"arn1",
|
||||||
|
"bom1",
|
||||||
|
"cdg1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"dub1",
|
||||||
|
"fra1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"lhr1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type SettingKeys = "accessCode";
|
||||||
|
|
||||||
|
export const defaultModal = "gpt-3.5-turbo";
|
||||||
|
|
||||||
|
export const models = [
|
||||||
|
defaultModal,
|
||||||
|
"gpt-3.5-turbo-0301",
|
||||||
|
"gpt-3.5-turbo-0613",
|
||||||
|
"gpt-3.5-turbo-1106",
|
||||||
|
"gpt-3.5-turbo-0125",
|
||||||
|
"gpt-3.5-turbo-16k",
|
||||||
|
"gpt-3.5-turbo-16k-0613",
|
||||||
|
"gpt-4",
|
||||||
|
"gpt-4-0314",
|
||||||
|
"gpt-4-0613",
|
||||||
|
"gpt-4-1106-preview",
|
||||||
|
"gpt-4-0125-preview",
|
||||||
|
"gpt-4-32k",
|
||||||
|
"gpt-4-32k-0314",
|
||||||
|
"gpt-4-32k-0613",
|
||||||
|
"gpt-4-turbo",
|
||||||
|
"gpt-4-turbo-preview",
|
||||||
|
"gpt-4-vision-preview",
|
||||||
|
"gpt-4-turbo-2024-04-09",
|
||||||
|
|
||||||
|
"gemini-1.0-pro",
|
||||||
|
"gemini-1.5-pro-latest",
|
||||||
|
"gemini-pro-vision",
|
||||||
|
|
||||||
|
"claude-instant-1.2",
|
||||||
|
"claude-2.0",
|
||||||
|
"claude-2.1",
|
||||||
|
"claude-3-sonnet-20240229",
|
||||||
|
"claude-3-opus-20240229",
|
||||||
|
"claude-3-haiku-20240307",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const modelConfigs = models.map((name) => ({
|
||||||
|
name,
|
||||||
|
displayName: name,
|
||||||
|
isVision: isVisionModel(name),
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: name === defaultModal,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const settingItems: SettingItem<SettingKeys>[] = [
|
||||||
|
{
|
||||||
|
name: "accessCode",
|
||||||
|
title: Locale.Auth.Title,
|
||||||
|
description: Locale.Auth.Tips,
|
||||||
|
placeholder: Locale.Auth.Input,
|
||||||
|
type: "input",
|
||||||
|
inputType: "password",
|
||||||
|
validators: ["required"],
|
||||||
|
},
|
||||||
|
];
|
||||||
348
app/client/providers/nextchat/index.ts
Normal file
348
app/client/providers/nextchat/index.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import {
|
||||||
|
modelConfigs,
|
||||||
|
settingItems,
|
||||||
|
SettingKeys,
|
||||||
|
NextChatMetas,
|
||||||
|
preferredRegion,
|
||||||
|
OPENAI_BASE_URL,
|
||||||
|
} from "./config";
|
||||||
|
import {
|
||||||
|
ChatHandlers,
|
||||||
|
getMessageTextContent,
|
||||||
|
InternalChatRequestPayload,
|
||||||
|
IProviderTemplate,
|
||||||
|
ServerConfig,
|
||||||
|
StandChatReponseMessage,
|
||||||
|
} from "../../common";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { auth, authHeaderName, getHeaders, getTimer, parseResp } from "./utils";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export type NextChatProviderSettingKeys = SettingKeys;
|
||||||
|
|
||||||
|
export const ROLES = ["system", "user", "assistant"] as const;
|
||||||
|
export type MessageRole = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
export interface MultimodalContent {
|
||||||
|
type: "text" | "image_url";
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestMessage {
|
||||||
|
role: MessageRole;
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestPayload {
|
||||||
|
messages: {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}[];
|
||||||
|
stream?: boolean;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
presence_penalty: number;
|
||||||
|
frequency_penalty: number;
|
||||||
|
top_p: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderTemplate = IProviderTemplate<
|
||||||
|
SettingKeys,
|
||||||
|
"azure",
|
||||||
|
typeof NextChatMetas
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default class NextChatProvider
|
||||||
|
implements IProviderTemplate<SettingKeys, "nextchat", typeof NextChatMetas>
|
||||||
|
{
|
||||||
|
apiRouteRootName: "/api/provider/nextchat" = "/api/provider/nextchat";
|
||||||
|
allowedApiMethods: (
|
||||||
|
| "POST"
|
||||||
|
| "GET"
|
||||||
|
| "OPTIONS"
|
||||||
|
| "PUT"
|
||||||
|
| "PATCH"
|
||||||
|
| "DELETE"
|
||||||
|
)[] = ["GET", "POST"];
|
||||||
|
|
||||||
|
runtime = "edge" as const;
|
||||||
|
preferredRegion = preferredRegion;
|
||||||
|
name = "nextchat" as const;
|
||||||
|
metas = NextChatMetas;
|
||||||
|
|
||||||
|
defaultModels = modelConfigs;
|
||||||
|
|
||||||
|
providerMeta = {
|
||||||
|
displayName: "NextChat",
|
||||||
|
settingItems,
|
||||||
|
};
|
||||||
|
|
||||||
|
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
|
||||||
|
const { messages, isVisionModel, model, stream, modelConfig } = payload;
|
||||||
|
const {
|
||||||
|
temperature,
|
||||||
|
presence_penalty,
|
||||||
|
frequency_penalty,
|
||||||
|
top_p,
|
||||||
|
max_tokens,
|
||||||
|
} = modelConfig;
|
||||||
|
|
||||||
|
const openAiMessages = messages.map((v) => ({
|
||||||
|
role: v.role,
|
||||||
|
content: isVisionModel ? v.content : getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
messages: openAiMessages,
|
||||||
|
stream,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
presence_penalty,
|
||||||
|
frequency_penalty,
|
||||||
|
top_p,
|
||||||
|
};
|
||||||
|
|
||||||
|
// add max_tokens to vision model
|
||||||
|
if (isVisionModel) {
|
||||||
|
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: getHeaders(payload.providerConfig.accessCode!),
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
method: "POST",
|
||||||
|
url: [this.apiRouteRootName, NextChatMetas.ChatPath].join("/"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestOpenai(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
const { baseUrl = OPENAI_BASE_URL, openaiOrgId } = serverConfig;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const authValue = req.headers.get(authHeaderName) ?? "";
|
||||||
|
|
||||||
|
const path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||||
|
this.apiRouteRootName,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchUrl = `${baseUrl}/${path}`;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
[authHeaderName]: authValue,
|
||||||
|
...(openaiOrgId && {
|
||||||
|
"OpenAI-Organization": openaiOrgId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// Extract the OpenAI-Organization header from the response
|
||||||
|
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
|
||||||
|
|
||||||
|
// Check if serverConfig.openaiOrgId is defined and not an empty string
|
||||||
|
if (openaiOrgId && openaiOrgId.trim() !== "") {
|
||||||
|
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
|
||||||
|
console.log("[Org ID]", openaiOrganizationHeader);
|
||||||
|
} else {
|
||||||
|
console.log("[Org ID] is not set up.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to disable nginx buffering
|
||||||
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
|
||||||
|
// Also, this is to prevent the header from being sent to the client
|
||||||
|
if (!openaiOrgId || openaiOrgId.trim() === "") {
|
||||||
|
newHeaders.delete("OpenAI-Organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
|
||||||
|
// So if the streaming is disabled, we need to remove the content-encoding header
|
||||||
|
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
|
||||||
|
// The browser will try to decode the response with brotli and fail
|
||||||
|
newHeaders.delete("content-encoding");
|
||||||
|
|
||||||
|
return new NextResponse(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamChat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
handlers: ChatHandlers,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
fetchEventSource(requestPayload.url, {
|
||||||
|
...requestPayload,
|
||||||
|
fetch,
|
||||||
|
async onopen(res) {
|
||||||
|
timer.clear();
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("[OpenAI] request response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
const responseText = await res.clone().text();
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [];
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.choices as Array<{
|
||||||
|
delta: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.delta?.content;
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
handlers.onProgress(delta);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
handlers.onFinish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
handlers.onError(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
payload: InternalChatRequestPayload<"accessCode">,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
): Promise<StandChatReponseMessage> {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
const res = await fetch(requestPayload.url, {
|
||||||
|
headers: {
|
||||||
|
...requestPayload.headers,
|
||||||
|
},
|
||||||
|
body: requestPayload.body,
|
||||||
|
method: requestPayload.method,
|
||||||
|
signal: timer.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
timer.clear();
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = parseResp(resJson);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
|
||||||
|
async (req, config) => {
|
||||||
|
const { subpath } = req;
|
||||||
|
const ALLOWD_PATH = new Set(Object.values(NextChatMetas));
|
||||||
|
|
||||||
|
if (!ALLOWD_PATH.has(subpath)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to request " + subpath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, config);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestOpenai(req, config);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
112
app/client/providers/nextchat/utils.ts
Normal file
112
app/client/providers/nextchat/utils.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { ServerConfig, getIP } from "../../common";
|
||||||
|
import md5 from "spark-md5";
|
||||||
|
|
||||||
|
export const ACCESS_CODE_PREFIX = "nk-";
|
||||||
|
|
||||||
|
export const REQUEST_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export const authHeaderName = "Authorization";
|
||||||
|
|
||||||
|
export const makeBearer = (s: string) => `Bearer ${s.trim()}`;
|
||||||
|
|
||||||
|
export const validString = (x?: string): x is string =>
|
||||||
|
Boolean(x && x.length > 0);
|
||||||
|
|
||||||
|
export function prettyObject(msg: any) {
|
||||||
|
const obj = msg;
|
||||||
|
if (typeof msg !== "string") {
|
||||||
|
msg = JSON.stringify(msg, null, " ");
|
||||||
|
}
|
||||||
|
if (msg === "{}") {
|
||||||
|
return obj.toString();
|
||||||
|
}
|
||||||
|
if (msg.startsWith("```json")) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
return ["```json", msg, "```"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimer() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...controller,
|
||||||
|
clear: () => {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeaders(accessCode: string) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
[authHeaderName]: makeBearer(ACCESS_CODE_PREFIX + accessCode),
|
||||||
|
};
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResp(res: { choices: { message: { content: any } }[] }) {
|
||||||
|
return {
|
||||||
|
message: res.choices?.[0]?.message?.content ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApiKey(req: NextRequest) {
|
||||||
|
const authToken = req.headers.get("Authorization") ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessCode:
|
||||||
|
authToken.startsWith(ACCESS_CODE_PREFIX) &&
|
||||||
|
authToken.slice(ACCESS_CODE_PREFIX.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auth(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
// check if it is openai api key or user token
|
||||||
|
const { accessCode } = parseApiKey(req);
|
||||||
|
const { googleApiKey, apiKey, anthropicApiKey, azureApiKey, codes } =
|
||||||
|
serverConfig;
|
||||||
|
|
||||||
|
const hashedCode = md5.hash(accessCode || "").trim();
|
||||||
|
|
||||||
|
console.log("[Auth] allowed hashed codes: ", [...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 (!codes.has(hashedCode)) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: !accessCode ? "empty access code" : "wrong access code",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemApiKey = googleApiKey || apiKey || anthropicApiKey || azureApiKey;
|
||||||
|
|
||||||
|
if (systemApiKey) {
|
||||||
|
console.log("[Auth] use system api key");
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
accessCode,
|
||||||
|
systemApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Auth] admin did not provide an api key");
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: `Server internal error`,
|
||||||
|
};
|
||||||
|
}
|
||||||
214
app/client/providers/openai/config.ts
Normal file
214
app/client/providers/openai/config.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { SettingItem } from "../../common";
|
||||||
|
import Locale from "./locale";
|
||||||
|
|
||||||
|
export const OPENAI_BASE_URL = "https://api.openai.com";
|
||||||
|
|
||||||
|
export const ROLES = ["system", "user", "assistant"] as const;
|
||||||
|
|
||||||
|
export const preferredRegion: string | string[] = [
|
||||||
|
"arn1",
|
||||||
|
"bom1",
|
||||||
|
"cdg1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"dub1",
|
||||||
|
"fra1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"lhr1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OpenaiMetas = {
|
||||||
|
ChatPath: "v1/chat/completions",
|
||||||
|
UsagePath: "dashboard/billing/usage",
|
||||||
|
SubsPath: "dashboard/billing/subscription",
|
||||||
|
ListModelPath: "v1/models",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingKeys = "openaiUrl" | "openaiApiKey";
|
||||||
|
|
||||||
|
export const modelConfigs = [
|
||||||
|
{
|
||||||
|
name: "gpt-4o",
|
||||||
|
displayName: "gpt-4o",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-3.5-turbo",
|
||||||
|
displayName: "gpt-3.5-turbo",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-3.5-turbo-0301",
|
||||||
|
displayName: "gpt-3.5-turbo-0301",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-3.5-turbo-0613",
|
||||||
|
displayName: "gpt-3.5-turbo-0613",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-3.5-turbo-1106",
|
||||||
|
displayName: "gpt-3.5-turbo-1106",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-3.5-turbo-0125",
|
||||||
|
displayName: "gpt-3.5-turbo-0125",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-3.5-turbo-16k",
|
||||||
|
displayName: "gpt-3.5-turbo-16k",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-3.5-turbo-16k-0613",
|
||||||
|
displayName: "gpt-3.5-turbo-16k-0613",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4",
|
||||||
|
displayName: "gpt-4",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-0314",
|
||||||
|
displayName: "gpt-4-0314",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-0613",
|
||||||
|
displayName: "gpt-4-0613",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-1106-preview",
|
||||||
|
displayName: "gpt-4-1106-preview",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-0125-preview",
|
||||||
|
displayName: "gpt-4-0125-preview",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-32k",
|
||||||
|
displayName: "gpt-4-32k",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-32k-0314",
|
||||||
|
displayName: "gpt-4-32k-0314",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-32k-0613",
|
||||||
|
displayName: "gpt-4-32k-0613",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-turbo",
|
||||||
|
displayName: "gpt-4-turbo",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: true,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-turbo-preview",
|
||||||
|
displayName: "gpt-4-turbo-preview",
|
||||||
|
isVision: false,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-vision-preview",
|
||||||
|
displayName: "gpt-4-vision-preview",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpt-4-turbo-2024-04-09",
|
||||||
|
displayName: "gpt-4-turbo-2024-04-09",
|
||||||
|
isVision: true,
|
||||||
|
isDefaultActive: false,
|
||||||
|
isDefaultSelected: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const settingItems: (
|
||||||
|
defaultEndpoint: string,
|
||||||
|
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
|
||||||
|
{
|
||||||
|
name: "openaiUrl",
|
||||||
|
title: Locale.Endpoint.Title,
|
||||||
|
description: Locale.Endpoint.SubTitle,
|
||||||
|
defaultValue: defaultEndpoint,
|
||||||
|
type: "input",
|
||||||
|
validators: [
|
||||||
|
"required",
|
||||||
|
async (v: any) => {
|
||||||
|
if (typeof v === "string" && v.endsWith("/")) {
|
||||||
|
return Locale.Endpoint.Error.EndWithBackslash;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof v === "string" &&
|
||||||
|
!v.startsWith(defaultEndpoint) &&
|
||||||
|
!v.startsWith("http")
|
||||||
|
) {
|
||||||
|
return Locale.Endpoint.SubTitle;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openaiApiKey",
|
||||||
|
title: Locale.ApiKey.Title,
|
||||||
|
description: Locale.ApiKey.SubTitle,
|
||||||
|
placeholder: Locale.ApiKey.Placeholder,
|
||||||
|
type: "input",
|
||||||
|
inputType: "password",
|
||||||
|
// validators: ["required"],
|
||||||
|
},
|
||||||
|
];
|
||||||
381
app/client/providers/openai/index.ts
Normal file
381
app/client/providers/openai/index.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import {
|
||||||
|
ChatHandlers,
|
||||||
|
InternalChatRequestPayload,
|
||||||
|
IProviderTemplate,
|
||||||
|
ModelInfo,
|
||||||
|
getMessageTextContent,
|
||||||
|
ServerConfig,
|
||||||
|
} from "../../common";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import {
|
||||||
|
authHeaderName,
|
||||||
|
prettyObject,
|
||||||
|
parseResp,
|
||||||
|
auth,
|
||||||
|
getTimer,
|
||||||
|
getHeaders,
|
||||||
|
} from "./utils";
|
||||||
|
import {
|
||||||
|
modelConfigs,
|
||||||
|
settingItems,
|
||||||
|
SettingKeys,
|
||||||
|
OpenaiMetas,
|
||||||
|
ROLES,
|
||||||
|
OPENAI_BASE_URL,
|
||||||
|
preferredRegion,
|
||||||
|
} from "./config";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { ModelList } from "./type";
|
||||||
|
|
||||||
|
export type OpenAIProviderSettingKeys = SettingKeys;
|
||||||
|
|
||||||
|
export type MessageRole = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
export interface MultimodalContent {
|
||||||
|
type: "text" | "image_url";
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestMessage {
|
||||||
|
role: MessageRole;
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}
|
||||||
|
interface RequestPayload {
|
||||||
|
messages: {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}[];
|
||||||
|
stream?: boolean;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
presence_penalty: number;
|
||||||
|
frequency_penalty: number;
|
||||||
|
top_p: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderTemplate = IProviderTemplate<
|
||||||
|
SettingKeys,
|
||||||
|
"azure",
|
||||||
|
typeof OpenaiMetas
|
||||||
|
>;
|
||||||
|
|
||||||
|
class OpenAIProvider
|
||||||
|
implements IProviderTemplate<SettingKeys, "openai", typeof OpenaiMetas>
|
||||||
|
{
|
||||||
|
apiRouteRootName: "/api/provider/openai" = "/api/provider/openai";
|
||||||
|
allowedApiMethods: (
|
||||||
|
| "POST"
|
||||||
|
| "GET"
|
||||||
|
| "OPTIONS"
|
||||||
|
| "PUT"
|
||||||
|
| "PATCH"
|
||||||
|
| "DELETE"
|
||||||
|
)[] = ["GET", "POST"];
|
||||||
|
runtime = "edge" as const;
|
||||||
|
preferredRegion = preferredRegion;
|
||||||
|
|
||||||
|
name = "openai" as const;
|
||||||
|
metas = OpenaiMetas;
|
||||||
|
|
||||||
|
defaultModels = modelConfigs;
|
||||||
|
|
||||||
|
providerMeta = {
|
||||||
|
displayName: "OpenAI",
|
||||||
|
settingItems: settingItems(
|
||||||
|
`${this.apiRouteRootName}/${OpenaiMetas.ChatPath}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
isVisionModel,
|
||||||
|
model,
|
||||||
|
stream,
|
||||||
|
modelConfig: {
|
||||||
|
temperature,
|
||||||
|
presence_penalty,
|
||||||
|
frequency_penalty,
|
||||||
|
top_p,
|
||||||
|
max_tokens,
|
||||||
|
},
|
||||||
|
providerConfig: { openaiUrl },
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
const openAiMessages = messages.map((v) => ({
|
||||||
|
role: v.role,
|
||||||
|
content: isVisionModel ? v.content : getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
messages: openAiMessages,
|
||||||
|
stream,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
presence_penalty,
|
||||||
|
frequency_penalty,
|
||||||
|
top_p,
|
||||||
|
};
|
||||||
|
|
||||||
|
// add max_tokens to vision model
|
||||||
|
if (isVisionModel) {
|
||||||
|
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: getHeaders(payload.providerConfig.openaiApiKey),
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
method: "POST",
|
||||||
|
url: openaiUrl!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestOpenai(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
const { baseUrl = OPENAI_BASE_URL, openaiOrgId } = serverConfig;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const authValue = req.headers.get(authHeaderName) ?? "";
|
||||||
|
|
||||||
|
const path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||||
|
this.apiRouteRootName,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchUrl = `${baseUrl}/${path}`;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
[authHeaderName]: authValue,
|
||||||
|
...(openaiOrgId && {
|
||||||
|
"OpenAI-Organization": openaiOrgId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// Extract the OpenAI-Organization header from the response
|
||||||
|
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
|
||||||
|
|
||||||
|
// Check if serverConfig.openaiOrgId is defined and not an empty string
|
||||||
|
if (openaiOrgId && openaiOrgId.trim() !== "") {
|
||||||
|
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
|
||||||
|
console.log("[Org ID]", openaiOrganizationHeader);
|
||||||
|
} else {
|
||||||
|
console.log("[Org ID] is not set up.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to disable nginx buffering
|
||||||
|
newHeaders.set("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
|
||||||
|
// Also, this is to prevent the header from being sent to the client
|
||||||
|
if (!openaiOrgId || openaiOrgId.trim() === "") {
|
||||||
|
newHeaders.delete("OpenAI-Organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
|
||||||
|
// So if the streaming is disabled, we need to remove the content-encoding header
|
||||||
|
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
|
||||||
|
// The browser will try to decode the response with brotli and fail
|
||||||
|
newHeaders.delete("content-encoding");
|
||||||
|
|
||||||
|
return new NextResponse(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
const res = await fetch(requestPayload.url, {
|
||||||
|
headers: {
|
||||||
|
...requestPayload.headers,
|
||||||
|
},
|
||||||
|
body: requestPayload.body,
|
||||||
|
method: requestPayload.method,
|
||||||
|
signal: timer.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
timer.clear();
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = parseResp(resJson);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamChat(
|
||||||
|
payload: InternalChatRequestPayload<SettingKeys>,
|
||||||
|
handlers: ChatHandlers,
|
||||||
|
fetch: typeof window.fetch,
|
||||||
|
) {
|
||||||
|
const requestPayload = this.formatChatPayload(payload);
|
||||||
|
|
||||||
|
const timer = getTimer();
|
||||||
|
|
||||||
|
fetchEventSource(requestPayload.url, {
|
||||||
|
...requestPayload,
|
||||||
|
fetch,
|
||||||
|
async onopen(res) {
|
||||||
|
timer.clear();
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("[OpenAI] request response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
const responseText = await res.clone().text();
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [];
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return handlers.onFlash(responseText);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.choices as Array<{
|
||||||
|
delta: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.delta?.content;
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
handlers.onProgress(delta);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
handlers.onFinish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
handlers.onError(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModels(
|
||||||
|
providerConfig: Record<SettingKeys, string>,
|
||||||
|
): Promise<ModelInfo[]> {
|
||||||
|
const { openaiApiKey, openaiUrl } = providerConfig;
|
||||||
|
const res = await fetch(`${openaiUrl}/v1/models`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${openaiApiKey}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
const data: ModelList = await res.json();
|
||||||
|
|
||||||
|
return data.data.map((o) => ({
|
||||||
|
name: o.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
|
||||||
|
async (req, config) => {
|
||||||
|
const { subpath } = req;
|
||||||
|
const ALLOWD_PATH = new Set(Object.values(OpenaiMetas));
|
||||||
|
|
||||||
|
if (!ALLOWD_PATH.has(subpath)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to request " + subpath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, config);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestOpenai(req, config);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpenAIProvider;
|
||||||
100
app/client/providers/openai/locale.ts
Normal file
100
app/client/providers/openai/locale.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { getLocaleText } from "../../common/locale";
|
||||||
|
|
||||||
|
export default getLocaleText<
|
||||||
|
{
|
||||||
|
ApiKey: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Placeholder: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: string;
|
||||||
|
SubTitle: string;
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
"en"
|
||||||
|
>(
|
||||||
|
{
|
||||||
|
cn: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API Key",
|
||||||
|
SubTitle: "使用自定义 OpenAI Key 绕过密码访问限制",
|
||||||
|
Placeholder: "OpenAI API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "接口地址",
|
||||||
|
SubTitle: "除默认地址外,必须包含 http(s)://",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」结尾",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "OpenAI API Key",
|
||||||
|
SubTitle: "User custom OpenAI Api Key",
|
||||||
|
Placeholder: "sk-xxx",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "OpenAI Endpoint",
|
||||||
|
SubTitle: "Must starts with http(s):// or use /api/openai as default",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Cannot end with '/'",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "Chave API OpenAI",
|
||||||
|
SubTitle: "Usar Chave API OpenAI personalizada",
|
||||||
|
Placeholder: "sk-xxx",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Endpoint OpenAI",
|
||||||
|
SubTitle: "Deve começar com http(s):// ou usar /api/openai como padrão",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Não é possível terminar com '/'",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sk: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API kľúč OpenAI",
|
||||||
|
SubTitle: "Použiť vlastný API kľúč OpenAI",
|
||||||
|
Placeholder: "sk-xxx",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "Koncový bod OpenAI",
|
||||||
|
SubTitle:
|
||||||
|
"Musí začínať http(s):// alebo použiť /api/openai ako predvolený",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "Nemôže končiť znakom „/“",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tw: {
|
||||||
|
ApiKey: {
|
||||||
|
Title: "API Key",
|
||||||
|
SubTitle: "使用自定義 OpenAI Key 繞過密碼存取限制",
|
||||||
|
Placeholder: "OpenAI API Key",
|
||||||
|
},
|
||||||
|
|
||||||
|
Endpoint: {
|
||||||
|
Title: "介面(Endpoint) 地址",
|
||||||
|
SubTitle: "除預設地址外,必須包含 http(s)://",
|
||||||
|
Error: {
|
||||||
|
EndWithBackslash: "不能以「/」結尾",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"en",
|
||||||
|
);
|
||||||
18
app/client/providers/openai/type.ts
Normal file
18
app/client/providers/openai/type.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface ModelList {
|
||||||
|
object: "list";
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: "model";
|
||||||
|
created: number;
|
||||||
|
owned_by: "system" | "openai-internal";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIListModelResponse {
|
||||||
|
object: string;
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
root: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
103
app/client/providers/openai/utils.ts
Normal file
103
app/client/providers/openai/utils.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { ServerConfig, getIP } from "../../common";
|
||||||
|
|
||||||
|
export const REQUEST_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export const authHeaderName = "Authorization";
|
||||||
|
|
||||||
|
const makeBearer = (s: string) => `Bearer ${s.trim()}`;
|
||||||
|
|
||||||
|
const validString = (x?: string): x is string => Boolean(x && x.length > 0);
|
||||||
|
|
||||||
|
function parseApiKey(bearToken: string) {
|
||||||
|
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyObject(msg: any) {
|
||||||
|
const obj = msg;
|
||||||
|
if (typeof msg !== "string") {
|
||||||
|
msg = JSON.stringify(msg, null, " ");
|
||||||
|
}
|
||||||
|
if (msg === "{}") {
|
||||||
|
return obj.toString();
|
||||||
|
}
|
||||||
|
if (msg.startsWith("```json")) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
return ["```json", msg, "```"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResp(res: { choices: { message: { content: any } }[] }) {
|
||||||
|
return {
|
||||||
|
message: res.choices?.[0]?.message?.content ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auth(req: NextRequest, serverConfig: ServerConfig) {
|
||||||
|
const { hideUserApiKey, apiKey: systemApiKey } = serverConfig;
|
||||||
|
const authToken = req.headers.get(authHeaderName) ?? "";
|
||||||
|
|
||||||
|
const { apiKey } = parseApiKey(authToken);
|
||||||
|
|
||||||
|
console.log("[User IP] ", getIP(req));
|
||||||
|
console.log("[Time] ", new Date().toLocaleString());
|
||||||
|
|
||||||
|
if (hideUserApiKey && apiKey) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: "you are not allowed to access with your own api key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
console.log("[Auth] use user api key");
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemApiKey) {
|
||||||
|
console.log("[Auth] use system api key");
|
||||||
|
req.headers.set(authHeaderName, `Bearer ${systemApiKey}`);
|
||||||
|
} else {
|
||||||
|
console.log("[Auth] admin did not provide an api key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimer() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...controller,
|
||||||
|
clear: () => {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeaders(openaiApiKey?: string) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validString(openaiApiKey)) {
|
||||||
|
headers[authHeaderName] = makeBearer(openaiApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
123
app/components/ActionsBar/index.tsx
Normal file
123
app/components/ActionsBar/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { isValidElement } from "react";
|
||||||
|
|
||||||
|
type IconMap = {
|
||||||
|
active?: JSX.Element;
|
||||||
|
inactive?: JSX.Element;
|
||||||
|
mobileActive?: JSX.Element;
|
||||||
|
mobileInactive?: JSX.Element;
|
||||||
|
};
|
||||||
|
interface Action {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
icons: JSX.Element | IconMap;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
activeClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Groups = {
|
||||||
|
normal: string[][];
|
||||||
|
mobile: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ActionsBarProps {
|
||||||
|
actionsSchema: Action[];
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
selected?: string;
|
||||||
|
groups: string[][] | Groups;
|
||||||
|
className?: string;
|
||||||
|
inMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionsBar(props: ActionsBarProps) {
|
||||||
|
const { actionsSchema, onSelect, selected, groups, className, inMobile } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const handlerClick =
|
||||||
|
(action: Action) => (e: { preventDefault: () => void }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (action.onClick) {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
if (selected !== action.id) {
|
||||||
|
onSelect?.(action.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const internalGroup = Array.isArray(groups)
|
||||||
|
? groups
|
||||||
|
: inMobile
|
||||||
|
? groups.mobile
|
||||||
|
: groups.normal;
|
||||||
|
|
||||||
|
const content = internalGroup.reduce((res, group, ind, arr) => {
|
||||||
|
res.push(
|
||||||
|
...group.map((i) => {
|
||||||
|
const action = actionsSchema.find((a) => a.id === i);
|
||||||
|
if (!action) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icons } = action;
|
||||||
|
let activeIcon, inactiveIcon, mobileActiveIcon, mobileInactiveIcon;
|
||||||
|
|
||||||
|
if (isValidElement(icons)) {
|
||||||
|
activeIcon = icons;
|
||||||
|
inactiveIcon = icons;
|
||||||
|
mobileActiveIcon = icons;
|
||||||
|
mobileInactiveIcon = icons;
|
||||||
|
} else {
|
||||||
|
activeIcon = (icons as IconMap).active;
|
||||||
|
inactiveIcon = (icons as IconMap).inactive;
|
||||||
|
mobileActiveIcon = (icons as IconMap).mobileActive;
|
||||||
|
mobileInactiveIcon = (icons as IconMap).mobileInactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inMobile) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className={` cursor-pointer shrink-1 grow-0 basis-[${
|
||||||
|
(100 - 1) / arr.length
|
||||||
|
}%] flex flex-col items-center justify-around gap-0.5 py-1.5
|
||||||
|
${
|
||||||
|
selected === action.id
|
||||||
|
? "text-text-sidebar-tab-mobile-active"
|
||||||
|
: "text-text-sidebar-tab-mobile-inactive"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={handlerClick(action)}
|
||||||
|
>
|
||||||
|
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
|
||||||
|
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
|
||||||
|
{action.title || " "}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className={`cursor-pointer p-3 ${
|
||||||
|
selected === action.id
|
||||||
|
? `!bg-actions-bar-btn-default ${action.activeClassName}`
|
||||||
|
: "bg-transparent"
|
||||||
|
} rounded-md items-center ${
|
||||||
|
action.className
|
||||||
|
} transition duration-300 ease-in-out`}
|
||||||
|
onClick={handlerClick(action)}
|
||||||
|
>
|
||||||
|
{selected === action.id ? activeIcon : inactiveIcon}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (ind < arr.length - 1) {
|
||||||
|
res.push(<div key={String(ind)} className=" flex-1"></div>);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}, [] as JSX.Element[]);
|
||||||
|
|
||||||
|
return <div className={`flex items-center ${className} `}>{content}</div>;
|
||||||
|
}
|
||||||
78
app/components/Btn/index.tsx
Normal file
78
app/components/Btn/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export type ButtonType = "primary" | "danger" | null;
|
||||||
|
|
||||||
|
export interface BtnProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
prefixIcon?: JSX.Element;
|
||||||
|
type?: ButtonType;
|
||||||
|
text?: React.ReactNode;
|
||||||
|
bordered?: boolean;
|
||||||
|
shadow?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Btn(props: BtnProps) {
|
||||||
|
const {
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
tabIndex,
|
||||||
|
autoFocus,
|
||||||
|
prefixIcon,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
let btnClassName;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "primary":
|
||||||
|
btnClassName = `${
|
||||||
|
disabled
|
||||||
|
? "bg-primary-btn-disabled dark:opacity-30 dark:text-primary-btn-disabled-dark"
|
||||||
|
: "bg-primary-btn shadow-btn"
|
||||||
|
} text-text-btn-primary `;
|
||||||
|
break;
|
||||||
|
case "danger":
|
||||||
|
btnClassName = `bg-danger-btn text-text-btn-danger hover:bg-hovered-danger-btn`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
btnClassName = `bg-default-btn text-text-btn-default hover:bg-hovered-btn`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
${className ?? ""}
|
||||||
|
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn transition-all duration-300 select-none
|
||||||
|
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
|
||||||
|
${btnClassName}
|
||||||
|
follow-parent-svg
|
||||||
|
`}
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
role="button"
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
>
|
||||||
|
{prefixIcon && (
|
||||||
|
<div className={`flex items-center justify-center`}>{prefixIcon}</div>
|
||||||
|
)}
|
||||||
|
{text && (
|
||||||
|
<div className={`font-common text-sm-title leading-4 line-clamp-1`}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/components/Card/index.tsx
Normal file
32
app/components/Card/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface CardProps {
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card(props: CardProps) {
|
||||||
|
const { className, children, title } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
capitalize !font-semibold text-sm-mobile font-weight-setting-card-title text-text-card-title
|
||||||
|
mb-3
|
||||||
|
|
||||||
|
ml-3
|
||||||
|
md:ml-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`px-4 py-1 rounded-lg bg-card ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/components/GlobalLoading/index.tsx
Normal file
18
app/components/GlobalLoading/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import BotIcon from "@/app/icons/bot.svg";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
|
||||||
|
export default function GloablLoading({
|
||||||
|
noLogo,
|
||||||
|
}: {
|
||||||
|
noLogo?: boolean;
|
||||||
|
useSkeleton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center items-center w-[100%] h-[100%]`}
|
||||||
|
>
|
||||||
|
{!noLogo && <BotIcon />}
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/components/HoverPopover/index.tsx
Normal file
39
app/components/HoverPopover/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export interface PopoverProps {
|
||||||
|
content?: JSX.Element | string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
arrowClassName?: string;
|
||||||
|
popoverClassName?: string;
|
||||||
|
noArrow?: boolean;
|
||||||
|
align?: ComponentProps<typeof HoverCard.Content>["align"];
|
||||||
|
openDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HoverPopover(props: PopoverProps) {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
arrowClassName,
|
||||||
|
popoverClassName,
|
||||||
|
noArrow = false,
|
||||||
|
align,
|
||||||
|
openDelay = 300,
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<HoverCard.Root openDelay={openDelay}>
|
||||||
|
<HoverCard.Trigger asChild>{children}</HoverCard.Trigger>
|
||||||
|
<HoverCard.Portal>
|
||||||
|
<HoverCard.Content
|
||||||
|
className={`${popoverClassName}`}
|
||||||
|
sideOffset={5}
|
||||||
|
align={align}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
{!noArrow && <HoverCard.Arrow className={`${arrowClassName}`} />}
|
||||||
|
</HoverCard.Content>
|
||||||
|
</HoverCard.Portal>
|
||||||
|
</HoverCard.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
app/components/Imgs/index.tsx
Normal file
42
app/components/Imgs/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { getMessageImages } from "@/app/utils";
|
||||||
|
import { RequestMessage } from "@/app/client/api";
|
||||||
|
|
||||||
|
interface ImgsProps {
|
||||||
|
message: RequestMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Imgs(props: ImgsProps) {
|
||||||
|
const { message } = props;
|
||||||
|
const imgSrcs = getMessageImages(message);
|
||||||
|
|
||||||
|
if (imgSrcs.length < 1) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgVars = {
|
||||||
|
"--imgs-width": `calc(var(--max-message-width) - ${
|
||||||
|
imgSrcs.length - 1
|
||||||
|
}*0.25rem)`,
|
||||||
|
"--img-width": `calc(var(--imgs-width)/ ${imgSrcs.length})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-[100%] mt-[0.625rem] flex gap-1`}
|
||||||
|
style={imgVars as CSSProperties}
|
||||||
|
>
|
||||||
|
{imgSrcs.map((image, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 min-w-[var(--img-width)] pb-[var(--img-width)] object-cover bg-cover bg-no-repeat bg-center box-border rounded-chat-img"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${image})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
app/components/Input/index.tsx
Normal file
88
app/components/Input/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import PasswordVisible from "@/app/icons/passwordVisible.svg";
|
||||||
|
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
|
||||||
|
import {
|
||||||
|
DetailedHTMLProps,
|
||||||
|
InputHTMLAttributes,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import List, { ListContext } from "@/app/components/List";
|
||||||
|
|
||||||
|
export interface CommonInputProps
|
||||||
|
extends Omit<
|
||||||
|
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||||
|
"onChange" | "type" | "value"
|
||||||
|
> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberInputProps {
|
||||||
|
onChange?: (v: number) => void;
|
||||||
|
type?: "number";
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextInputProps {
|
||||||
|
onChange?: (v: string) => void;
|
||||||
|
type?: "text" | "password";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputProps {
|
||||||
|
onChange?: ((v: string) => void) | ((v: number) => void);
|
||||||
|
type?: "text" | "password" | "number";
|
||||||
|
value?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input(
|
||||||
|
props: CommonInputProps & NumberInputProps,
|
||||||
|
): JSX.Element;
|
||||||
|
export default function Input(
|
||||||
|
props: CommonInputProps & TextInputProps,
|
||||||
|
): JSX.Element;
|
||||||
|
export default function Input(props: CommonInputProps & InputProps) {
|
||||||
|
const { value, type = "text", onChange, className, ...rest } = props;
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
const { inputClassName } = useContext(ListContext);
|
||||||
|
|
||||||
|
const internalType = (show && "text") || type;
|
||||||
|
|
||||||
|
const { update, handleValidate } = useContext(List.ListContext);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
update?.({ type: "input" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
handleValidate?.(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={` group/input w-[100%] rounded-chat-input bg-input transition-colors duration-300 ease-in-out flex gap-3 items-center px-3 py-2 ${className} hover:bg-select-hover ${inputClassName}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
className=" overflow-hidden text-text-input text-sm-title leading-input outline-none flex-1 group-hover/input:bg-input-input-ele-hover"
|
||||||
|
type={internalType}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (type === "number") {
|
||||||
|
const v = e.currentTarget.valueAsNumber;
|
||||||
|
(onChange as NumberInputProps["onChange"])?.(v);
|
||||||
|
} else {
|
||||||
|
const v = e.currentTarget.value;
|
||||||
|
(onChange as TextInputProps["onChange"])?.(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{type == "password" && (
|
||||||
|
<div className=" cursor-pointer" onClick={() => setShow((pre) => !pre)}>
|
||||||
|
{show ? <PasswordVisible /> : <PasswordInvisible />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
app/components/List/index.tsx
Normal file
167
app/components/List/index.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface WidgetStyle {
|
||||||
|
selectClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
rangeClassName?: string;
|
||||||
|
switchClassName?: string;
|
||||||
|
inputNextLine?: boolean;
|
||||||
|
rangeNextLine?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChildrenMeta {
|
||||||
|
type?: "unknown" | "input" | "range";
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProps {
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
id?: string;
|
||||||
|
isMobileScreen?: boolean;
|
||||||
|
widgetStyle?: WidgetStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error =
|
||||||
|
| {
|
||||||
|
error: true;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
error: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Validate = (v: any) => Error | Promise<Error>;
|
||||||
|
|
||||||
|
export interface ListItemProps {
|
||||||
|
title: string;
|
||||||
|
subTitle?: string;
|
||||||
|
children?: JSX.Element | JSX.Element[];
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
nextline?: boolean;
|
||||||
|
validator?: Validate | Validate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListContext = createContext<
|
||||||
|
{
|
||||||
|
isMobileScreen?: boolean;
|
||||||
|
update?: (m: ChildrenMeta) => void;
|
||||||
|
handleValidate?: (v: any) => void;
|
||||||
|
} & WidgetStyle
|
||||||
|
>({ isMobileScreen: false });
|
||||||
|
|
||||||
|
export function ListItem(props: ListItemProps) {
|
||||||
|
const {
|
||||||
|
className = "",
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
subTitle,
|
||||||
|
children,
|
||||||
|
nextline,
|
||||||
|
validator,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const context = useContext(ListContext);
|
||||||
|
|
||||||
|
const [childrenMeta, setMeta] = useState<ChildrenMeta>({});
|
||||||
|
|
||||||
|
const { inputNextLine, rangeNextLine } = context;
|
||||||
|
|
||||||
|
const { type, error } = childrenMeta;
|
||||||
|
|
||||||
|
let internalNextLine;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "input":
|
||||||
|
internalNextLine = !!(nextline || inputNextLine);
|
||||||
|
break;
|
||||||
|
case "range":
|
||||||
|
internalNextLine = !!(nextline || rangeNextLine);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
internalNextLine = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = useCallback((m: ChildrenMeta) => {
|
||||||
|
setMeta((pre) => ({ ...pre, ...m }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValidate = useCallback((v: any) => {
|
||||||
|
let insideValidator;
|
||||||
|
if (!validator) {
|
||||||
|
insideValidator = () => {};
|
||||||
|
} else if (Array.isArray(validator)) {
|
||||||
|
insideValidator = (v: any) =>
|
||||||
|
Promise.race(validator.map((validate) => validate(v)));
|
||||||
|
} else {
|
||||||
|
insideValidator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.resolve(insideValidator(v)).then((result) => {
|
||||||
|
if (result && result.error) {
|
||||||
|
return update({
|
||||||
|
error: result.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
update({
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative after:h-[0.5px] after:bottom-0 after:w-[100%] after:left-0 after:absolute last:after:hidden after:bg-list-item-divider ${
|
||||||
|
internalNextLine ? "" : "flex gap-3"
|
||||||
|
} justify-between items-center px-0 py-2 md:py-3 ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className={`flex-1 flex flex-col justify-start gap-1`}>
|
||||||
|
<div className=" font-common text-sm-mobile font-weight-[500] line-clamp-1 text-text-list-title">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subTitle && (
|
||||||
|
<div className={` text-sm text-text-list-subtitle`}>{subTitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ListContext.Provider value={{ ...context, update, handleValidate }}>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
internalNextLine ? "mt-[0.625rem]" : "max-w-[70%]"
|
||||||
|
} flex flex-col items-center justify-center`}
|
||||||
|
>
|
||||||
|
<div>{children}</div>
|
||||||
|
{!!error && (
|
||||||
|
<div className="text-text-btn-danger text-sm-mobile-tab mt-[0.3125rem] flex items-start w-[100%]">
|
||||||
|
<div className="">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ListContext.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function List(props: ListProps) {
|
||||||
|
const { className, children, id, widgetStyle } = props;
|
||||||
|
const { isMobileScreen } = useContext(ListContext);
|
||||||
|
return (
|
||||||
|
<ListContext.Provider value={{ isMobileScreen, ...widgetStyle }}>
|
||||||
|
<div className={`flex flex-col w-[100%] ${className}`} id={id}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ListContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List.ListItem = ListItem;
|
||||||
|
List.ListContext = ListContext;
|
||||||
|
|
||||||
|
export default List;
|
||||||
35
app/components/Loading/index.tsx
Normal file
35
app/components/Loading/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import BotIcon from "@/app/icons/bot.svg";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
|
||||||
|
import { getCSSVar } from "@/app/utils";
|
||||||
|
|
||||||
|
export default function Loading({
|
||||||
|
noLogo,
|
||||||
|
useSkeleton = true,
|
||||||
|
}: {
|
||||||
|
noLogo?: boolean;
|
||||||
|
useSkeleton?: boolean;
|
||||||
|
}) {
|
||||||
|
let theme;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
theme = getCSSVar("--default-container-bg");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col justify-center items-center w-[100%]
|
||||||
|
h-[100%]
|
||||||
|
md:my-2.5
|
||||||
|
md:ml-1
|
||||||
|
md:mr-2.5
|
||||||
|
md:rounded-md
|
||||||
|
md:h-[calc(100%-1.25rem)]
|
||||||
|
`}
|
||||||
|
style={{ background: useSkeleton ? theme : "" }}
|
||||||
|
>
|
||||||
|
{!noLogo && <BotIcon />}
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
app/components/MenuLayout/index.tsx
Normal file
115
app/components/MenuLayout/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
Path,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import useDrag from "@/app/hooks/useDrag";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||||
|
import { ComponentType, useRef, useState } from "react";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
|
||||||
|
export interface MenuWrapperInspectProps {
|
||||||
|
setExternalProps?: (v: Record<string, any>) => void;
|
||||||
|
setShowPanel?: (v: boolean) => void;
|
||||||
|
showPanel?: boolean;
|
||||||
|
[k: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MenuLayout<
|
||||||
|
ListComponentProps extends MenuWrapperInspectProps,
|
||||||
|
PanelComponentProps extends MenuWrapperInspectProps,
|
||||||
|
>(
|
||||||
|
ListComponent: ComponentType<ListComponentProps>,
|
||||||
|
PanelComponent: ComponentType<PanelComponentProps>,
|
||||||
|
) {
|
||||||
|
return function MenuHood(props: ListComponentProps & PanelComponentProps) {
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [externalProps, setExternalProps] = useState({});
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
|
||||||
|
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||||
|
// drag side bar
|
||||||
|
const { onDragStart } = useDrag({
|
||||||
|
customToggle: () => {
|
||||||
|
config.update((config) => {
|
||||||
|
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
customDragMove: (nextWidth: number) => {
|
||||||
|
const { menuWidth } = updateGlobalCSSVars(nextWidth);
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--menu-width",
|
||||||
|
`${menuWidth}px`,
|
||||||
|
);
|
||||||
|
config.update((config) => {
|
||||||
|
config.sidebarWidth = nextWidth;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
customLimit: (x: number) =>
|
||||||
|
Math.max(
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-[100%] relative bg-center
|
||||||
|
max-md:h-[100%]
|
||||||
|
md:flex md:my-2.5
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col px-6
|
||||||
|
h-[100%]
|
||||||
|
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
|
||||||
|
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ListComponent
|
||||||
|
{...props}
|
||||||
|
setShowPanel={setShowPanel}
|
||||||
|
setExternalProps={setExternalProps}
|
||||||
|
showPanel={showPanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isMobileScreen && (
|
||||||
|
<div
|
||||||
|
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
startDragWidth.current = config.sidebarWidth;
|
||||||
|
onDragStart(e as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
md:flex-1 md:h-[100%] md:w-page
|
||||||
|
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
|
||||||
|
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
|
||||||
|
} max-md:z-10
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PanelComponent
|
||||||
|
{...props}
|
||||||
|
{...externalProps}
|
||||||
|
setShowPanel={setShowPanel}
|
||||||
|
setExternalProps={setExternalProps}
|
||||||
|
showPanel={showPanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
352
app/components/Modal/index.tsx
Normal file
352
app/components/Modal/index.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import React, { useLayoutEffect, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import * as AlertDialog from "@radix-ui/react-alert-dialog";
|
||||||
|
import Btn, { BtnProps } from "@/app/components/Btn";
|
||||||
|
|
||||||
|
import Warning from "@/app/icons/warning.svg";
|
||||||
|
import Close from "@/app/icons/closeIcon.svg";
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
onOk?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
okText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
okBtnProps?: BtnProps;
|
||||||
|
cancelBtnProps?: BtnProps;
|
||||||
|
content?:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((handlers: { close: () => void }) => JSX.Element);
|
||||||
|
title?: React.ReactNode;
|
||||||
|
visible?: boolean;
|
||||||
|
noFooter?: boolean;
|
||||||
|
noHeader?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
closeble?: boolean;
|
||||||
|
type?: "modal" | "bottom-drawer";
|
||||||
|
headerBordered?: boolean;
|
||||||
|
modelClassName?: string;
|
||||||
|
onOpen?: (v: boolean) => void;
|
||||||
|
maskCloseble?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarnProps
|
||||||
|
extends Omit<
|
||||||
|
ModalProps,
|
||||||
|
| "closeble"
|
||||||
|
| "isMobile"
|
||||||
|
| "noHeader"
|
||||||
|
| "noFooter"
|
||||||
|
| "onOk"
|
||||||
|
| "okBtnProps"
|
||||||
|
| "cancelBtnProps"
|
||||||
|
| "content"
|
||||||
|
> {
|
||||||
|
onOk?: () => Promise<void> | void;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerProps
|
||||||
|
extends Omit<ModalProps, "visible" | "onOk" | "onCancel"> {
|
||||||
|
children: JSX.Element;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseZIndex = 150;
|
||||||
|
|
||||||
|
const Modal = (props: ModalProps) => {
|
||||||
|
const {
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
okText,
|
||||||
|
cancelText,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
visible,
|
||||||
|
noFooter,
|
||||||
|
noHeader,
|
||||||
|
closeble = true,
|
||||||
|
okBtnProps,
|
||||||
|
cancelBtnProps,
|
||||||
|
type = "modal",
|
||||||
|
headerBordered,
|
||||||
|
modelClassName,
|
||||||
|
onOpen,
|
||||||
|
maskCloseble = true,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(!!visible);
|
||||||
|
|
||||||
|
const mergeOpen = visible ?? open;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
onCancel?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
setOpen(false);
|
||||||
|
onOk?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onOpen?.(mergeOpen);
|
||||||
|
}, [mergeOpen]);
|
||||||
|
|
||||||
|
let layoutClassName = "";
|
||||||
|
let panelClassName = "";
|
||||||
|
let titleClassName = "";
|
||||||
|
let footerClassName = "";
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "bottom-drawer":
|
||||||
|
layoutClassName = "fixed inset-0 flex flex-col w-[100%] bottom-0";
|
||||||
|
panelClassName =
|
||||||
|
"rounded-t-chat-model-select overflow-y-auto overflow-x-hidden";
|
||||||
|
titleClassName = "px-4 py-3";
|
||||||
|
footerClassName = "absolute w-[100%]";
|
||||||
|
break;
|
||||||
|
case "modal":
|
||||||
|
default:
|
||||||
|
layoutClassName =
|
||||||
|
"fixed inset-0 flex flex-col item-start top-0 left-[50vw] translate-x-[-50%] max-sm:w-modal-modal-type-mobile";
|
||||||
|
panelClassName = "rounded-lg px-6 sm:w-modal-modal-type";
|
||||||
|
titleClassName = "py-6 max-sm:pb-3";
|
||||||
|
footerClassName = "py-6";
|
||||||
|
}
|
||||||
|
const btnCommonClass = "px-4 py-2.5 rounded-md max-sm:flex-1";
|
||||||
|
const { className: okBtnClass } = okBtnProps || {};
|
||||||
|
const { className: cancelBtnClass } = cancelBtnProps || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={mergeOpen} onOpenChange={setOpen}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay
|
||||||
|
className="bg-modal-mask fixed inset-0 animate-mask "
|
||||||
|
style={{ zIndex: baseZIndex - 1 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (maskCloseble) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AlertDialog.Content
|
||||||
|
className={`
|
||||||
|
${layoutClassName}
|
||||||
|
`}
|
||||||
|
style={{ zIndex: baseZIndex - 1 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
if (maskCloseble) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col flex-0
|
||||||
|
bg-moda-panel text-modal-panel
|
||||||
|
${modelClassName}
|
||||||
|
${panelClassName}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{!noHeader && (
|
||||||
|
<AlertDialog.Title
|
||||||
|
className={`
|
||||||
|
flex items-center justify-between gap-3 font-common
|
||||||
|
md:text-chat-header-title md:font-bold md:leading-5
|
||||||
|
${
|
||||||
|
headerBordered
|
||||||
|
? " border-b border-modal-header-bottom"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${titleClassName}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 justify-start flex-1 items-center text-text-modal-title text-chat-header-title">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{closeble && (
|
||||||
|
<div
|
||||||
|
className="items-center"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDialog.Title>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-hidden text-text-modal-content text-sm-title">
|
||||||
|
{typeof content === "function"
|
||||||
|
? content({
|
||||||
|
close: () => {
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: content}
|
||||||
|
</div>
|
||||||
|
{!noFooter && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex gap-3 sm:justify-end max-sm:justify-between
|
||||||
|
${footerClassName}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<Btn
|
||||||
|
{...cancelBtnProps}
|
||||||
|
onClick={() => handleClose()}
|
||||||
|
text={cancelText}
|
||||||
|
className={`${btnCommonClass} ${cancelBtnClass}`}
|
||||||
|
/>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<Btn
|
||||||
|
{...okBtnProps}
|
||||||
|
onClick={handleOk}
|
||||||
|
text={okText}
|
||||||
|
className={`${btnCommonClass} ${okBtnClass}`}
|
||||||
|
/>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{type === "modal" && (
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
if (maskCloseble) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Warn = ({
|
||||||
|
title,
|
||||||
|
onOk,
|
||||||
|
visible,
|
||||||
|
content,
|
||||||
|
...props
|
||||||
|
}: WarnProps) => {
|
||||||
|
const [internalVisible, setVisible] = useState(visible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
{...props}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<Warning />
|
||||||
|
{title}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<AlertDialog.Description
|
||||||
|
className={`
|
||||||
|
font-common font-normal
|
||||||
|
md:text-sm-title md:leading-[158%]
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</AlertDialog.Description>
|
||||||
|
}
|
||||||
|
closeble={false}
|
||||||
|
onOk={() => {
|
||||||
|
const toDo = onOk?.();
|
||||||
|
if (toDo instanceof Promise) {
|
||||||
|
toDo.then(() => {
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
visible={internalVisible}
|
||||||
|
okBtnProps={{
|
||||||
|
className: `bg-delete-chat-ok-btn text-text-delete-chat-ok-btn `,
|
||||||
|
}}
|
||||||
|
cancelBtnProps={{
|
||||||
|
className: `bg-delete-chat-cancel-btn border border-delete-chat-cancel-btn text-text-delete-chat-cancel-btn`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.id = "confirm-root";
|
||||||
|
div.style.height = "0px";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
|
||||||
|
Modal.warn = (props: Omit<WarnProps, "visible" | "onCancel" | "onOk">) => {
|
||||||
|
const root = createRoot(div);
|
||||||
|
const closeModal = () => {
|
||||||
|
root.unmount();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
root.render(
|
||||||
|
<Warn
|
||||||
|
{...props}
|
||||||
|
visible={true}
|
||||||
|
onCancel={() => {
|
||||||
|
closeModal();
|
||||||
|
resolve(false);
|
||||||
|
}}
|
||||||
|
onOk={() => {
|
||||||
|
closeModal();
|
||||||
|
resolve(true);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Trigger = (props: TriggerProps) => {
|
||||||
|
const { children, className, content, ...rest } = props;
|
||||||
|
|
||||||
|
const [internalVisible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
onClick={() => {
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<Modal
|
||||||
|
{...rest}
|
||||||
|
visible={internalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
typeof content === "function"
|
||||||
|
? content({
|
||||||
|
close: () => {
|
||||||
|
setVisible(false);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: content
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Modal.Trigger = Trigger;
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
352
app/components/Popover/index.tsx
Normal file
352
app/components/Popover/index.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import useRelativePosition from "@/app/hooks/useRelativePosition";
|
||||||
|
import {
|
||||||
|
RefObject,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
const ArrowIcon = ({ sibling }: { sibling: RefObject<HTMLDivElement> }) => {
|
||||||
|
const [color, setColor] = useState<string>("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (sibling.current) {
|
||||||
|
const { backgroundColor } = window.getComputedStyle(sibling.current);
|
||||||
|
setColor(backgroundColor);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="6"
|
||||||
|
viewBox="0 0 16 6"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16 0H0C1.28058 0 2.50871 0.508709 3.41421 1.41421L6.91 4.91C7.51199 5.51199 8.48801 5.51199 9.09 4.91L12.5858 1.41421C13.4913 0.508708 14.7194 0 16 0Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseZIndex = 100;
|
||||||
|
const popoverRootName = "popoverRoot";
|
||||||
|
let popoverRoot = document.querySelector(
|
||||||
|
`#${popoverRootName}`,
|
||||||
|
) as HTMLDivElement;
|
||||||
|
if (!popoverRoot) {
|
||||||
|
popoverRoot = document.createElement("div");
|
||||||
|
document.body.appendChild(popoverRoot);
|
||||||
|
popoverRoot.style.height = "0px";
|
||||||
|
popoverRoot.style.width = "100%";
|
||||||
|
popoverRoot.style.position = "fixed";
|
||||||
|
popoverRoot.style.bottom = "0";
|
||||||
|
popoverRoot.style.zIndex = "10000";
|
||||||
|
popoverRoot.id = "popover-root";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopoverProps {
|
||||||
|
content?: JSX.Element | string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
show?: boolean;
|
||||||
|
onShow?: (v: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
popoverClassName?: string;
|
||||||
|
trigger?: "hover" | "click";
|
||||||
|
placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b" | "l" | "r";
|
||||||
|
noArrow?: boolean;
|
||||||
|
delayClose?: number;
|
||||||
|
useGlobalRoot?: boolean;
|
||||||
|
getPopoverPanelRef?: (ref: RefObject<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Popover(props: PopoverProps) {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
show,
|
||||||
|
onShow,
|
||||||
|
className,
|
||||||
|
popoverClassName,
|
||||||
|
trigger = "hover",
|
||||||
|
placement = "t",
|
||||||
|
noArrow = false,
|
||||||
|
delayClose = 0,
|
||||||
|
useGlobalRoot,
|
||||||
|
getPopoverPanelRef,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [internalShow, setShow] = useState(false);
|
||||||
|
const { position, getRelativePosition } = useRelativePosition({
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const popoverCommonClass = `absolute p-2 box-border`;
|
||||||
|
|
||||||
|
const mergedShow = show ?? internalShow;
|
||||||
|
|
||||||
|
const { arrowClassName, placementStyle, placementClassName } = useMemo(() => {
|
||||||
|
const arrowCommonClassName = `${
|
||||||
|
noArrow ? "hidden" : ""
|
||||||
|
} absolute z-10 left-[50%] translate-x-[calc(-50%)]`;
|
||||||
|
|
||||||
|
let defaultTopPlacement = true; // when users dont config 't' or 'b'
|
||||||
|
|
||||||
|
const {
|
||||||
|
distanceToBottomBoundary = 0,
|
||||||
|
distanceToLeftBoundary = 0,
|
||||||
|
distanceToRightBoundary = -10000,
|
||||||
|
distanceToTopBoundary = 0,
|
||||||
|
targetH = 0,
|
||||||
|
targetW = 0,
|
||||||
|
} = position?.poi || {};
|
||||||
|
|
||||||
|
if (distanceToBottomBoundary > distanceToTopBoundary) {
|
||||||
|
defaultTopPlacement = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placements = {
|
||||||
|
lt: {
|
||||||
|
placementStyle: {
|
||||||
|
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||||
|
placementClassName: "bottom-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
lb: {
|
||||||
|
placementStyle: {
|
||||||
|
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||||
|
placementClassName: "top-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
rt: {
|
||||||
|
placementStyle: {
|
||||||
|
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||||
|
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||||
|
placementClassName: "bottom-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
rb: {
|
||||||
|
placementStyle: {
|
||||||
|
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||||
|
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||||
|
placementClassName: "top-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
t: {
|
||||||
|
placementStyle: {
|
||||||
|
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||||
|
placementClassName:
|
||||||
|
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
placementStyle: {
|
||||||
|
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||||
|
placementClassName:
|
||||||
|
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyle = () => {
|
||||||
|
if (["l", "r"].includes(placement)) {
|
||||||
|
return placements[
|
||||||
|
`${placement}${defaultTopPlacement ? "t" : "b"}` as
|
||||||
|
| "lt"
|
||||||
|
| "lb"
|
||||||
|
| "rb"
|
||||||
|
| "rt"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return placements[placement as Exclude<typeof placement, "l" | "r">];
|
||||||
|
};
|
||||||
|
|
||||||
|
return getStyle();
|
||||||
|
}, [Object.values(position?.poi || {})]);
|
||||||
|
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeTimer = useRef<number>(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
getPopoverPanelRef?.(popoverRef);
|
||||||
|
onShow?.(internalShow);
|
||||||
|
}, [internalShow]);
|
||||||
|
|
||||||
|
if (trigger === "click") {
|
||||||
|
const handleOpen = (e: { currentTarget: any }) => {
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
setShow(true);
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
window.document.documentElement.style.overflow = "hidden";
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
if (delayClose) {
|
||||||
|
closeTimer.current = window.setTimeout(() => {
|
||||||
|
setShow(false);
|
||||||
|
}, delayClose);
|
||||||
|
} else {
|
||||||
|
setShow(false);
|
||||||
|
}
|
||||||
|
window.document.documentElement.style.overflow = "auto";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${className}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!mergedShow) {
|
||||||
|
handleOpen(e);
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{mergedShow && (
|
||||||
|
<>
|
||||||
|
{!noArrow && (
|
||||||
|
<div className={`${arrowClassName}`}>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className={`${popoverCommonClass} ${popoverClassName} cursor-pointer overflow-auto`}
|
||||||
|
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||||
|
ref={popoverRef}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className=" fixed w-[100vw] h-[100vh] right-0 bottom-0"
|
||||||
|
style={{ zIndex: baseZIndex }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useGlobalRoot) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${className}`}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
onShow?.(true);
|
||||||
|
setShow(true);
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
window.document.documentElement.style.overflow = "hidden";
|
||||||
|
}}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (delayClose) {
|
||||||
|
closeTimer.current = window.setTimeout(() => {
|
||||||
|
onShow?.(false);
|
||||||
|
setShow(false);
|
||||||
|
}, delayClose);
|
||||||
|
} else {
|
||||||
|
onShow?.(false);
|
||||||
|
setShow(false);
|
||||||
|
}
|
||||||
|
window.document.documentElement.style.overflow = "auto";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{mergedShow && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
noArrow ? "opacity-0" : ""
|
||||||
|
} bg-inherit ${arrowClassName}`}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className={` whitespace-nowrap ${popoverCommonClass} ${popoverClassName} cursor-pointer`}
|
||||||
|
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||||
|
ref={popoverRef}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group/popover relative ${className}`}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
hidden group-hover/popover:block
|
||||||
|
${noArrow ? "opacity-0" : ""}
|
||||||
|
bg-inherit
|
||||||
|
${arrowClassName}
|
||||||
|
`}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
hidden group-hover/popover:block whitespace-nowrap
|
||||||
|
${popoverCommonClass}
|
||||||
|
${placementClassName}
|
||||||
|
${popoverClassName}
|
||||||
|
`}
|
||||||
|
ref={popoverRef}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/components/Screen/index.tsx
Normal file
71
app/components/Screen/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useMemo, ReactNode } from "react";
|
||||||
|
import { Path, SIDEBAR_ID, SlotID } from "@/app/constant";
|
||||||
|
import { getLang } from "@/app/locales";
|
||||||
|
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
import { isIOS } from "@/app/utils";
|
||||||
|
import useListenWinResize from "@/app/hooks/useListenWinResize";
|
||||||
|
|
||||||
|
interface ScreenProps {
|
||||||
|
children: ReactNode;
|
||||||
|
noAuth: ReactNode;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Screen(props: ScreenProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isAuth = location.pathname === Path.Auth;
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const isIOSMobile = useMemo(
|
||||||
|
() => isIOS() && isMobileScreen,
|
||||||
|
[isMobileScreen],
|
||||||
|
);
|
||||||
|
|
||||||
|
useListenWinResize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex h-[100%] w-[100%] bg-center
|
||||||
|
max-md:relative max-md:flex-col-reverse max-md:bg-global-mobile
|
||||||
|
md:overflow-hidden md:bg-global
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
direction: getLang() === "ar" ? "rtl" : "ltr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAuth ? (
|
||||||
|
props.noAuth
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-md:absolute max-md:w-[100%] max-md:bottom-0 max-md:z-10
|
||||||
|
md:flex-0 md:overflow-hidden
|
||||||
|
`}
|
||||||
|
id={SIDEBAR_ID}
|
||||||
|
>
|
||||||
|
{props.sidebar}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
h-[100%]
|
||||||
|
max-md:w-[100%]
|
||||||
|
md:flex-1 md:min-w-0 md:overflow-hidden md:flex
|
||||||
|
`}
|
||||||
|
id={SlotID.AppBody}
|
||||||
|
style={{
|
||||||
|
// #3016 disable transition on ios mobile screen
|
||||||
|
transition: isIOSMobile ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/components/Search/index.module.scss
Normal file
24
app/components/Search/index.module.scss
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
max-width: 460px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 16px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--Light-Text-Black, #18182A);
|
||||||
|
background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70));
|
||||||
|
box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
flex: 0 0;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
height: 18px;
|
||||||
|
flex: 1 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/components/Search/index.tsx
Normal file
30
app/components/Search/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import styles from "./index.module.scss";
|
||||||
|
import SearchIcon from "@/app/icons/search.svg";
|
||||||
|
|
||||||
|
export interface SearchProps {
|
||||||
|
value?: string;
|
||||||
|
onSearch?: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Search = (props: SearchProps) => {
|
||||||
|
const { placeholder = "", value, onSearch } = props;
|
||||||
|
return (
|
||||||
|
<div className={styles["search"]}>
|
||||||
|
<div className={styles["icon"]}>
|
||||||
|
<SearchIcon />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className={styles["input"]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch?.(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
||||||
118
app/components/Select/index.tsx
Normal file
118
app/components/Select/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import SelectIcon from "@/app/icons/downArrowIcon.svg";
|
||||||
|
import Popover from "@/app/components/Popover";
|
||||||
|
import React, { useContext, useMemo, useRef } from "react";
|
||||||
|
import useRelativePosition, {
|
||||||
|
Orientation,
|
||||||
|
} from "@/app/hooks/useRelativePosition";
|
||||||
|
import List from "@/app/components/List";
|
||||||
|
|
||||||
|
import Selected from "@/app/icons/selectedIcon.svg";
|
||||||
|
|
||||||
|
export type Option<Value> = {
|
||||||
|
value: Value;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SearchProps<Value> {
|
||||||
|
value?: string;
|
||||||
|
onSelect?: (v: Value) => void;
|
||||||
|
options?: Option<Value>[];
|
||||||
|
inMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
|
||||||
|
const { value, onSelect, options = [], inMobile } = props;
|
||||||
|
|
||||||
|
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
|
||||||
|
|
||||||
|
const optionsRef = useRef<Option<Value>[]>([]);
|
||||||
|
optionsRef.current = options;
|
||||||
|
const selectedOption = useMemo(
|
||||||
|
() => optionsRef.current.find((o) => o.value === value),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { position, getRelativePosition } = useRelativePosition({
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let headerH = 100;
|
||||||
|
let baseH = position?.poi.distanceToBottomBoundary || 0;
|
||||||
|
if (isMobileScreen) {
|
||||||
|
headerH = 60;
|
||||||
|
}
|
||||||
|
if (position?.poi.relativePosition[1] === Orientation.bottom) {
|
||||||
|
baseH = position?.poi.distanceToTopBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxHeight = `${baseH - headerH}px`;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={` flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
{options?.map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.value}
|
||||||
|
className={`
|
||||||
|
flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect?.(o.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 flex-1 follow-parent-svg text-text-select-option">
|
||||||
|
{!!o.icon && <div className="flex items-center">{o.icon}</div>}
|
||||||
|
<div className={`flex-1 text-text-select-option`}>{o.label}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
selectedOption?.value === o.value ? "opacity-100" : "opacity-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Selected />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
trigger="click"
|
||||||
|
noArrow
|
||||||
|
placement={
|
||||||
|
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
|
||||||
|
}
|
||||||
|
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-select-popover-panel"
|
||||||
|
onShow={(e) => {
|
||||||
|
getRelativePosition(contentRef.current!, "");
|
||||||
|
}}
|
||||||
|
className={selectClassName}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 py-2 px-3 bg-select rounded-action-btn font-common text-sm-title cursor-pointer hover:bg-select-hover transition duration-300 ease-in-out`}
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 flex-1 follow-parent-svg text-text-select`}
|
||||||
|
>
|
||||||
|
{!!selectedOption?.icon && (
|
||||||
|
<div className={``}>{selectedOption?.icon}</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex-1`}>{selectedOption?.label}</div>
|
||||||
|
</div>
|
||||||
|
<div className={``}>
|
||||||
|
<SelectIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Select;
|
||||||
99
app/components/SlideRange/index.tsx
Normal file
99
app/components/SlideRange/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useContext, useEffect, useRef } from "react";
|
||||||
|
import { ListContext } from "@/app/components/List";
|
||||||
|
import { useResizeObserver } from "usehooks-ts";
|
||||||
|
|
||||||
|
interface SlideRangeProps {
|
||||||
|
className?: string;
|
||||||
|
description?: string;
|
||||||
|
range?: {
|
||||||
|
start?: number;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
onSlide?: (v: number) => void;
|
||||||
|
value?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = 15;
|
||||||
|
|
||||||
|
export default function SlideRange(props: SlideRangeProps) {
|
||||||
|
const {
|
||||||
|
className = "",
|
||||||
|
description = "",
|
||||||
|
range = {},
|
||||||
|
value,
|
||||||
|
onSlide,
|
||||||
|
step,
|
||||||
|
} = props;
|
||||||
|
const { start = 0, stroke = 1 } = range;
|
||||||
|
|
||||||
|
const { rangeClassName, update } = useContext(ListContext);
|
||||||
|
|
||||||
|
const slideRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useResizeObserver({
|
||||||
|
ref: slideRef,
|
||||||
|
onResize: () => {
|
||||||
|
setProperty(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformToWidth = (x: number = start) => {
|
||||||
|
const abs = x - start;
|
||||||
|
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
|
||||||
|
const result = (abs / stroke) * maxWidth;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProperty = (value?: number) => {
|
||||||
|
const initWidth = transformToWidth(value);
|
||||||
|
slideRef.current?.style.setProperty(
|
||||||
|
"--slide-value-size",
|
||||||
|
`${initWidth + margin}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
update?.({ type: "range" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
|
||||||
|
>
|
||||||
|
{!!description && (
|
||||||
|
<div className=" text-common text-sm ">{description}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex my-1.5 relative w-[100%] h-1.5 bg-slider rounded-slide cursor-pointer"
|
||||||
|
ref={slideRef}
|
||||||
|
>
|
||||||
|
<div className="cursor-pointer absolute marker:top-0 h-[100%] w-[var(--slide-value-size)] bg-slider-slided-travel rounded-slide">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer absolute z-1 w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border border-slider-block bg-slider-block hover:bg-slider-block-hover text-text-slider-block"
|
||||||
|
// onPointerDown={onPointerDown}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="w-[100%] h-[100%] opacity-0 cursor-pointer"
|
||||||
|
value={value}
|
||||||
|
min={start}
|
||||||
|
max={start + stroke}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProperty(e.target.valueAsNumber);
|
||||||
|
onSlide?.(e.target.valueAsNumber);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginLeft: margin,
|
||||||
|
marginRight: margin,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/components/Switch/index.tsx
Normal file
33
app/components/Switch/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import List from "../List";
|
||||||
|
|
||||||
|
interface SwitchProps {
|
||||||
|
value: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Switch(props: SwitchProps) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
const { switchClassName = "" } = useContext(List.ListContext);
|
||||||
|
return (
|
||||||
|
<RadixSwitch.Root
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
className={`
|
||||||
|
cursor-pointer flex w-switch h-switch p-0.5 box-content rounded-md transition-colors duration-300 ease-in-out
|
||||||
|
${switchClassName}
|
||||||
|
${
|
||||||
|
value
|
||||||
|
? "bg-switch-checked justify-end"
|
||||||
|
: "bg-switch-unchecked justify-start"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<RadixSwitch.Thumb
|
||||||
|
className={` bg-switch-btn block w-4 h-4 drop-shadow-sm rounded-md`}
|
||||||
|
/>
|
||||||
|
</RadixSwitch.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/components/ThumbnailImg/index.tsx
Normal file
27
app/components/ThumbnailImg/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
|
||||||
|
|
||||||
|
export interface ThumbnailProps {
|
||||||
|
image: string;
|
||||||
|
deleteImage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Thumbnail(props: ThumbnailProps) {
|
||||||
|
const { image, deleteImage } = props;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={` h-thumbnail w-thumbnail cursor-default border border-thumbnail rounded-action-btn flex-0 bg-cover bg-center`}
|
||||||
|
style={{ backgroundImage: `url("${image}")` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer flex items-center justify-center float-right`}
|
||||||
|
onClick={deleteImage}
|
||||||
|
>
|
||||||
|
<ImgDeleteIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
transform: scale(1.4);
|
transform: scale(1.4);
|
||||||
}
|
}
|
||||||
@@ -33,4 +35,18 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
min-height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
padding: 0 10px;
|
||||||
|
max-width: 50%;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,28 @@ import { useAccessStore } from "../store";
|
|||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
|
||||||
import BotIcon from "../icons/bot.svg";
|
import BotIcon from "../icons/bot.svg";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getClientConfig } from "../config/client";
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const access = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
const goHome = () => navigate(Path.Home);
|
const goHome = () => navigate(Path.Home);
|
||||||
|
const goChat = () => navigate(Path.Chat);
|
||||||
|
const resetAccessCode = () => {
|
||||||
|
accessStore.update((access) => {
|
||||||
|
access.openaiApiKey = "";
|
||||||
|
access.accessCode = "";
|
||||||
|
});
|
||||||
|
}; // Reset access code to empty string
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (getClientConfig()?.isApp) {
|
||||||
|
navigate(Path.Settings);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["auth-page"]}>
|
<div className={styles["auth-page"]}>
|
||||||
@@ -27,19 +43,54 @@ export function AuthPage() {
|
|||||||
className={styles["auth-input"]}
|
className={styles["auth-input"]}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={Locale.Auth.Input}
|
placeholder={Locale.Auth.Input}
|
||||||
value={access.accessCode}
|
value={accessStore.accessCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
access.updateCode(e.currentTarget.value);
|
accessStore.update(
|
||||||
|
(access) => (access.accessCode = e.currentTarget.value),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{!accessStore.hideUserApiKey ? (
|
||||||
|
<>
|
||||||
|
<div className={styles["auth-tips"]}>{Locale.Auth.SubTips}</div>
|
||||||
|
<input
|
||||||
|
className={styles["auth-input"]}
|
||||||
|
type="password"
|
||||||
|
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||||
|
value={accessStore.openaiApiKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.openaiApiKey = e.currentTarget.value),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={styles["auth-input"]}
|
||||||
|
type="password"
|
||||||
|
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||||
|
value={accessStore.googleApiKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.googleApiKey = e.currentTarget.value),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className={styles["auth-actions"]}>
|
<div className={styles["auth-actions"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
text={Locale.Auth.Confirm}
|
text={Locale.Auth.Confirm}
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={goHome}
|
onClick={goChat}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
text={Locale.Auth.Later}
|
||||||
|
onClick={() => {
|
||||||
|
resetAccessCode();
|
||||||
|
goHome();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton text={Locale.Auth.Later} onClick={goHome} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
import { useChatStore } from "../store";
|
import { useChatStore } from "../store";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Path } from "../constant";
|
import { Path } from "../constant";
|
||||||
import { MaskAvatar } from "./mask";
|
import { MaskAvatar } from "./mask";
|
||||||
import { Mask } from "../store/mask";
|
import { Mask } from "../store/mask";
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
|
import { useMobileScreen } from "../utils";
|
||||||
|
|
||||||
export function ChatItem(props: {
|
export function ChatItem(props: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -39,12 +40,16 @@ export function ChatItem(props: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [props.selected]);
|
}, [props.selected]);
|
||||||
|
|
||||||
|
const { pathname: currentPath } = useLocation();
|
||||||
return (
|
return (
|
||||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
className={`${styles["chat-item"]} ${
|
className={`${styles["chat-item"]} ${
|
||||||
props.selected && styles["chat-item-selected"]
|
props.selected &&
|
||||||
|
(currentPath === Path.Chat || currentPath === Path.Home) &&
|
||||||
|
styles["chat-item-selected"]
|
||||||
}`}
|
}`}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
ref={(ele) => {
|
ref={(ele) => {
|
||||||
@@ -60,7 +65,10 @@ export function ChatItem(props: {
|
|||||||
{props.narrow ? (
|
{props.narrow ? (
|
||||||
<div className={styles["chat-item-narrow"]}>
|
<div className={styles["chat-item-narrow"]}>
|
||||||
<div className={styles["chat-item-avatar"] + " no-dark"}>
|
<div className={styles["chat-item-avatar"] + " no-dark"}>
|
||||||
<MaskAvatar mask={props.mask} />
|
<MaskAvatar
|
||||||
|
avatar={props.mask.avatar}
|
||||||
|
model={props.mask.modelConfig.model}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["chat-item-narrow-count"]}>
|
<div className={styles["chat-item-narrow-count"]}>
|
||||||
{props.count}
|
{props.count}
|
||||||
@@ -80,7 +88,11 @@ export function ChatItem(props: {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles["chat-item-delete"]}
|
className={styles["chat-item-delete"]}
|
||||||
onClickCapture={props.onDelete}
|
onClickCapture={(e) => {
|
||||||
|
props.onDelete?.();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</div>
|
</div>
|
||||||
@@ -101,6 +113,7 @@ export function ChatList(props: { narrow?: boolean }) {
|
|||||||
);
|
);
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
|
||||||
const onDragEnd: OnDragEndResponder = (result) => {
|
const onDragEnd: OnDragEndResponder = (result) => {
|
||||||
const { destination, source } = result;
|
const { destination, source } = result;
|
||||||
@@ -142,7 +155,7 @@ export function ChatList(props: { narrow?: boolean }) {
|
|||||||
}}
|
}}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (
|
if (
|
||||||
!props.narrow ||
|
(!props.narrow && !isMobileScreen) ||
|
||||||
(await showConfirm(Locale.Home.DeleteChat))
|
(await showConfirm(Locale.Home.DeleteChat))
|
||||||
) {
|
) {
|
||||||
chatStore.deleteSession(i);
|
chatStore.deleteSession(i);
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
@import "../styles/animation.scss";
|
@import "../styles/animation.scss";
|
||||||
|
|
||||||
|
.attach-images {
|
||||||
|
position: absolute;
|
||||||
|
left: 30px;
|
||||||
|
bottom: 32px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-image {
|
||||||
|
cursor: default;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
|
.attach-image-mask {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-image-mask:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-image {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
float: right;
|
||||||
|
background-color: var(--white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-actions {
|
.chat-input-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -189,12 +231,10 @@
|
|||||||
|
|
||||||
animation: slide-in ease 0.3s;
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
$linear: linear-gradient(
|
$linear: linear-gradient(to right,
|
||||||
to right,
|
rgba(0, 0, 0, 0),
|
||||||
rgba(0, 0, 0, 0),
|
rgba(0, 0, 0, 1),
|
||||||
rgba(0, 0, 0, 1),
|
rgba(0, 0, 0, 0));
|
||||||
rgba(0, 0, 0, 0)
|
|
||||||
);
|
|
||||||
mask-image: $linear;
|
mask-image: $linear;
|
||||||
|
|
||||||
@mixin show {
|
@mixin show {
|
||||||
@@ -327,7 +367,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-user > .chat-message-container {
|
.chat-message-user>.chat-message-container {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +389,15 @@
|
|||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Specific styles for iOS devices */
|
||||||
|
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.chat-message-edit {
|
||||||
|
top: -8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-status {
|
.chat-message-status {
|
||||||
@@ -373,6 +422,64 @@
|
|||||||
transition: all ease 0.3s;
|
transition: all ease 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-images {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
justify-content: left;
|
||||||
|
grid-gap: 10px;
|
||||||
|
grid-template-columns: repeat(var(--image-count), auto);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
object-fit: cover;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image,
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
$calc-image-width: calc(100vw/3*2/var(--image-count));
|
||||||
|
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
width: $calc-image-width;
|
||||||
|
height: $calc-image-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image {
|
||||||
|
max-width: calc(100vw/3*2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
||||||
|
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
||||||
|
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
width: $image-width;
|
||||||
|
height: $image-width;
|
||||||
|
max-width: $max-image-width;
|
||||||
|
max-height: $max-image-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image {
|
||||||
|
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message-action-date {
|
.chat-message-action-date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
@@ -387,7 +494,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-user > .chat-message-container > .chat-message-item {
|
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||||
background-color: var(--second);
|
background-color: var(--second);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -452,6 +559,7 @@
|
|||||||
|
|
||||||
@include single-line();
|
@include single-line();
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint-content {
|
.hint-content {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
@@ -466,15 +574,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-panel-inner {
|
.chat-input-panel-inner {
|
||||||
|
cursor: text;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-panel-inner-attach {
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-panel-inner:has(.chat-input:focus) {
|
||||||
|
border: 1px solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.chat-input {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: var(--border-in-light);
|
border: none;
|
||||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
||||||
background-color: var(--white);
|
background-color: var(--white);
|
||||||
color: var(--black);
|
color: var(--black);
|
||||||
@@ -486,9 +605,7 @@
|
|||||||
min-height: 68px;
|
min-height: 68px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input:focus {
|
.chat-input:focus {}
|
||||||
border: 1px solid var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-send {
|
.chat-input-send {
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useCallback,
|
useCallback,
|
||||||
Fragment,
|
Fragment,
|
||||||
|
RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import SendWhiteIcon from "../icons/send-white.svg";
|
import SendWhiteIcon from "../icons/send-white.svg";
|
||||||
@@ -15,6 +16,7 @@ import ExportIcon from "../icons/share.svg";
|
|||||||
import ReturnIcon from "../icons/return.svg";
|
import ReturnIcon from "../icons/return.svg";
|
||||||
import CopyIcon from "../icons/copy.svg";
|
import CopyIcon from "../icons/copy.svg";
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
|
import LoadingButtonIcon from "../icons/loading.svg";
|
||||||
import PromptIcon from "../icons/prompt.svg";
|
import PromptIcon from "../icons/prompt.svg";
|
||||||
import MaskIcon from "../icons/mask.svg";
|
import MaskIcon from "../icons/mask.svg";
|
||||||
import MaxIcon from "../icons/max.svg";
|
import MaxIcon from "../icons/max.svg";
|
||||||
@@ -27,6 +29,7 @@ import PinIcon from "../icons/pin.svg";
|
|||||||
import EditIcon from "../icons/rename.svg";
|
import EditIcon from "../icons/rename.svg";
|
||||||
import ConfirmIcon from "../icons/confirm.svg";
|
import ConfirmIcon from "../icons/confirm.svg";
|
||||||
import CancelIcon from "../icons/cancel.svg";
|
import CancelIcon from "../icons/cancel.svg";
|
||||||
|
import ImageIcon from "../icons/image.svg";
|
||||||
|
|
||||||
import LightIcon from "../icons/light.svg";
|
import LightIcon from "../icons/light.svg";
|
||||||
import DarkIcon from "../icons/dark.svg";
|
import DarkIcon from "../icons/dark.svg";
|
||||||
@@ -53,8 +56,13 @@ import {
|
|||||||
selectOrCopy,
|
selectOrCopy,
|
||||||
autoGrowTextArea,
|
autoGrowTextArea,
|
||||||
useMobileScreen,
|
useMobileScreen,
|
||||||
|
getMessageTextContent,
|
||||||
|
getMessageImages,
|
||||||
|
isVisionModel,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
|
import { compressImage } from "@/app/utils/chat";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import { ChatControllerPool } from "../client/controller";
|
import { ChatControllerPool } from "../client/controller";
|
||||||
@@ -73,8 +81,14 @@ import {
|
|||||||
showPrompt,
|
showPrompt,
|
||||||
showToast,
|
showToast,
|
||||||
} from "./ui-lib";
|
} from "./ui-lib";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
|
import {
|
||||||
|
CHAT_PAGE_SIZE,
|
||||||
|
LAST_INPUT_KEY,
|
||||||
|
Path,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
UNFINISHED_INPUT,
|
||||||
|
} from "../constant";
|
||||||
import { Avatar } from "./emoji";
|
import { Avatar } from "./emoji";
|
||||||
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
||||||
import { useMaskStore } from "../store/mask";
|
import { useMaskStore } from "../store/mask";
|
||||||
@@ -82,6 +96,8 @@ import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
|
|||||||
import { prettyObject } from "../utils/format";
|
import { prettyObject } from "../utils/format";
|
||||||
import { ExportMessageModal } from "./exporter";
|
import { ExportMessageModal } from "./exporter";
|
||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
|
import { useAllModels } from "../utils/hooks";
|
||||||
|
import { MultimodalContent } from "../client/api";
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
@@ -137,6 +153,7 @@ export function SessionConfigModel(props: { onClose: () => void }) {
|
|||||||
extraListItems={
|
extraListItems={
|
||||||
session.mask.modelConfig.sendMemory ? (
|
session.mask.modelConfig.sendMemory ? (
|
||||||
<ListItem
|
<ListItem
|
||||||
|
className="copyable"
|
||||||
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
||||||
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
||||||
></ListItem>
|
></ListItem>
|
||||||
@@ -203,6 +220,8 @@ function useSubmitHandler() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Fix Chinese input method "Enter" on Safari
|
||||||
|
if (e.keyCode == 229) return false;
|
||||||
if (e.key !== "Enter") return false;
|
if (e.key !== "Enter") return false;
|
||||||
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
|
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
|
||||||
return false;
|
return false;
|
||||||
@@ -367,44 +386,47 @@ function ChatAction(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useScrollToBottom() {
|
function useScrollToBottom(
|
||||||
|
scrollRef: RefObject<HTMLDivElement>,
|
||||||
|
detach: boolean = false,
|
||||||
|
) {
|
||||||
// for auto-scroll
|
// for auto-scroll
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const autoScroll = useRef(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const scrollToBottom = useCallback(() => {
|
function scrollDomToBottom() {
|
||||||
const dom = scrollRef.current;
|
const dom = scrollRef.current;
|
||||||
if (dom) {
|
if (dom) {
|
||||||
requestAnimationFrame(() => dom.scrollTo(0, dom.scrollHeight));
|
requestAnimationFrame(() => {
|
||||||
|
setAutoScroll(true);
|
||||||
|
dom.scrollTo(0, dom.scrollHeight);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
const setAutoScroll = (enable: boolean) => {
|
|
||||||
autoScroll.current = enable;
|
|
||||||
};
|
|
||||||
|
|
||||||
// auto scroll
|
// auto scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intervalId = setInterval(() => {
|
if (autoScroll && !detach) {
|
||||||
if (autoScroll.current) {
|
scrollDomToBottom();
|
||||||
scrollToBottom();
|
}
|
||||||
}
|
});
|
||||||
}, 30);
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollRef,
|
scrollRef,
|
||||||
autoScroll,
|
autoScroll,
|
||||||
setAutoScroll,
|
setAutoScroll,
|
||||||
scrollToBottom,
|
scrollDomToBottom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatActions(props: {
|
export function ChatActions(props: {
|
||||||
|
uploadImage: () => void;
|
||||||
|
setAttachImages: (images: string[]) => void;
|
||||||
|
setUploading: (uploading: boolean) => void;
|
||||||
showPromptModal: () => void;
|
showPromptModal: () => void;
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
showPromptHints: () => void;
|
showPromptHints: () => void;
|
||||||
hitBottom: boolean;
|
hitBottom: boolean;
|
||||||
|
uploading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -426,15 +448,46 @@ export function ChatActions(props: {
|
|||||||
|
|
||||||
// switch model
|
// switch model
|
||||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||||
const models = useMemo(
|
const allModels = useAllModels();
|
||||||
() =>
|
const models = useMemo(() => {
|
||||||
config
|
const filteredModels = allModels.filter((m) => m.available);
|
||||||
.allModels()
|
const defaultModel = filteredModels.find((m) => m.isDefault);
|
||||||
.filter((m) => m.available)
|
|
||||||
.map((m) => m.name),
|
if (defaultModel) {
|
||||||
[config],
|
const arr = [
|
||||||
);
|
defaultModel,
|
||||||
|
...filteredModels.filter((m) => m !== defaultModel),
|
||||||
|
];
|
||||||
|
return arr;
|
||||||
|
} else {
|
||||||
|
return filteredModels;
|
||||||
|
}
|
||||||
|
}, [allModels]);
|
||||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||||
|
const [showUploadImage, setShowUploadImage] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const show = isVisionModel(currentModel);
|
||||||
|
setShowUploadImage(show);
|
||||||
|
if (!show) {
|
||||||
|
props.setAttachImages([]);
|
||||||
|
props.setUploading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if current model is not available
|
||||||
|
// switch to first available model
|
||||||
|
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
||||||
|
if (isUnavaliableModel && models.length > 0) {
|
||||||
|
// show next model to default model if exist
|
||||||
|
let nextModel: ModelType = (
|
||||||
|
models.find((model) => model.isDefault) || models[0]
|
||||||
|
).name;
|
||||||
|
chatStore.updateCurrentSession(
|
||||||
|
(session) => (session.mask.modelConfig.model = nextModel),
|
||||||
|
);
|
||||||
|
showToast(nextModel);
|
||||||
|
}
|
||||||
|
}, [chatStore, currentModel, models]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["chat-input-actions"]}>
|
<div className={styles["chat-input-actions"]}>
|
||||||
@@ -460,6 +513,13 @@ export function ChatActions(props: {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showUploadImage && (
|
||||||
|
<ChatAction
|
||||||
|
onClick={props.uploadImage}
|
||||||
|
text={Locale.Chat.InputActions.UploadImage}
|
||||||
|
icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ChatAction
|
<ChatAction
|
||||||
onClick={nextTheme}
|
onClick={nextTheme}
|
||||||
text={Locale.Chat.InputActions.Theme[theme]}
|
text={Locale.Chat.InputActions.Theme[theme]}
|
||||||
@@ -515,8 +575,8 @@ export function ChatActions(props: {
|
|||||||
<Selector
|
<Selector
|
||||||
defaultSelectedValue={currentModel}
|
defaultSelectedValue={currentModel}
|
||||||
items={models.map((m) => ({
|
items={models.map((m) => ({
|
||||||
title: m,
|
title: m.displayName,
|
||||||
value: m,
|
value: m.name,
|
||||||
}))}
|
}))}
|
||||||
onClose={() => setShowModelSelector(false)}
|
onClose={() => setShowModelSelector(false)}
|
||||||
onSelection={(s) => {
|
onSelection={(s) => {
|
||||||
@@ -595,7 +655,15 @@ export function EditMessageModal(props: { onClose: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chat() {
|
export function DeleteImageButton(props: { deleteImage: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className={styles["delete-image"]} onClick={props.deleteImage}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _Chat() {
|
||||||
type RenderMessage = ChatMessage & { preview?: boolean };
|
type RenderMessage = ChatMessage & { preview?: boolean };
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
@@ -609,20 +677,22 @@ export function Chat() {
|
|||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { submitKey, shouldSubmit } = useSubmitHandler();
|
const { submitKey, shouldSubmit } = useSubmitHandler();
|
||||||
const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isScrolledToBottom = scrollRef?.current
|
||||||
|
? Math.abs(
|
||||||
|
scrollRef.current.scrollHeight -
|
||||||
|
(scrollRef.current.scrollTop + scrollRef.current.clientHeight),
|
||||||
|
) <= 1
|
||||||
|
: false;
|
||||||
|
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
|
||||||
|
scrollRef,
|
||||||
|
isScrolledToBottom,
|
||||||
|
);
|
||||||
const [hitBottom, setHitBottom] = useState(true);
|
const [hitBottom, setHitBottom] = useState(true);
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [attachImages, setAttachImages] = useState<string[]>([]);
|
||||||
const lastBodyScroolTop = useRef(0);
|
const [uploading, setUploading] = useState(false);
|
||||||
const onChatBodyScroll = (e: HTMLElement) => {
|
|
||||||
const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 10;
|
|
||||||
setHitBottom(isTouchBottom);
|
|
||||||
|
|
||||||
// only enable auto scroll when scroll down and touched bottom
|
|
||||||
setAutoScroll(e.scrollTop >= lastBodyScroolTop.current && isTouchBottom);
|
|
||||||
lastBodyScroolTop.current = e.scrollTop;
|
|
||||||
};
|
|
||||||
|
|
||||||
// prompt hints
|
// prompt hints
|
||||||
const promptStore = usePromptStore();
|
const promptStore = usePromptStore();
|
||||||
@@ -700,7 +770,10 @@ export function Chat() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
|
chatStore
|
||||||
|
.onUserInput(userInput, attachImages)
|
||||||
|
.then(() => setIsLoading(false));
|
||||||
|
setAttachImages([]);
|
||||||
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setPromptHints([]);
|
setPromptHints([]);
|
||||||
@@ -778,9 +851,9 @@ export function Chat() {
|
|||||||
};
|
};
|
||||||
const onRightClick = (e: any, message: ChatMessage) => {
|
const onRightClick = (e: any, message: ChatMessage) => {
|
||||||
// copy to clipboard
|
// copy to clipboard
|
||||||
if (selectOrCopy(e.currentTarget, message.content)) {
|
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
|
||||||
if (userInput.length === 0) {
|
if (userInput.length === 0) {
|
||||||
setUserInput(message.content);
|
setUserInput(getMessageTextContent(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -809,7 +882,7 @@ export function Chat() {
|
|||||||
(m) => m.id === message.id,
|
(m) => m.id === message.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resendingIndex <= 0 || resendingIndex >= session.messages.length) {
|
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
|
||||||
console.error("[Chat] failed to find resending message", message);
|
console.error("[Chat] failed to find resending message", message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -848,7 +921,9 @@ export function Chat() {
|
|||||||
|
|
||||||
// resend the message
|
// resend the message
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false));
|
const textContent = getMessageTextContent(userMessage);
|
||||||
|
const images = getMessageImages(userMessage);
|
||||||
|
chatStore.onUserInput(textContent, images).then(() => setIsLoading(false));
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -865,10 +940,9 @@ export function Chat() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const context: RenderMessage[] = session.mask.hideContext
|
const context: RenderMessage[] = useMemo(() => {
|
||||||
? []
|
return session.mask.hideContext ? [] : session.mask.context.slice();
|
||||||
: session.mask.context.slice();
|
}, [session.mask.context, session.mask.hideContext]);
|
||||||
|
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -882,50 +956,98 @@ export function Chat() {
|
|||||||
context.push(copiedHello);
|
context.push(copiedHello);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preview messages
|
||||||
|
const renderMessages = useMemo(() => {
|
||||||
|
return 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
config.sendPreviewBubble,
|
||||||
|
context,
|
||||||
|
isLoading,
|
||||||
|
session.messages,
|
||||||
|
userInput,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [msgRenderIndex, _setMsgRenderIndex] = useState(
|
||||||
|
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
|
||||||
|
);
|
||||||
|
function setMsgRenderIndex(newIndex: number) {
|
||||||
|
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
|
||||||
|
newIndex = Math.max(0, newIndex);
|
||||||
|
_setMsgRenderIndex(newIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = useMemo(() => {
|
||||||
|
const endRenderIndex = Math.min(
|
||||||
|
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
|
||||||
|
renderMessages.length,
|
||||||
|
);
|
||||||
|
return renderMessages.slice(msgRenderIndex, endRenderIndex);
|
||||||
|
}, [msgRenderIndex, renderMessages]);
|
||||||
|
|
||||||
|
const onChatBodyScroll = (e: HTMLElement) => {
|
||||||
|
const bottomHeight = e.scrollTop + e.clientHeight;
|
||||||
|
const edgeThreshold = e.clientHeight;
|
||||||
|
|
||||||
|
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
|
||||||
|
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
|
||||||
|
const isHitBottom =
|
||||||
|
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
|
||||||
|
|
||||||
|
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
|
||||||
|
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
|
||||||
|
|
||||||
|
if (isTouchTopEdge && !isTouchBottomEdge) {
|
||||||
|
setMsgRenderIndex(prevPageMsgIndex);
|
||||||
|
} else if (isTouchBottomEdge) {
|
||||||
|
setMsgRenderIndex(nextPageMsgIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHitBottom(isHitBottom);
|
||||||
|
setAutoScroll(isHitBottom);
|
||||||
|
};
|
||||||
|
function scrollToBottom() {
|
||||||
|
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
|
||||||
|
scrollDomToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
// clear context index = context length + index in messages
|
// clear context index = context length + index in messages
|
||||||
const clearContextIndex =
|
const clearContextIndex =
|
||||||
(session.clearContextIndex ?? -1) >= 0
|
(session.clearContextIndex ?? -1) >= 0
|
||||||
? session.clearContextIndex! + context.length
|
? session.clearContextIndex! + context.length - msgRenderIndex
|
||||||
: -1;
|
: -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 [showPromptModal, setShowPromptModal] = useState(false);
|
||||||
|
|
||||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||||
|
|
||||||
const location = useLocation();
|
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
|
||||||
const isChat = location.pathname === Path.Chat;
|
|
||||||
|
|
||||||
const autoFocus = !isMobileScreen || isChat; // only focus in chat page
|
|
||||||
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
|
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
|
||||||
|
|
||||||
useCommand({
|
useCommand({
|
||||||
@@ -934,14 +1056,17 @@ export function Chat() {
|
|||||||
doSubmit(text);
|
doSubmit(text);
|
||||||
},
|
},
|
||||||
code: (text) => {
|
code: (text) => {
|
||||||
|
if (accessStore.disableFastLink) return;
|
||||||
console.log("[Command] got code from url: ", text);
|
console.log("[Command] got code from url: ", text);
|
||||||
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
|
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
|
||||||
if (res) {
|
if (res) {
|
||||||
accessStore.updateCode(text);
|
accessStore.update((access) => (access.accessCode = text));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
settings: (text) => {
|
settings: (text) => {
|
||||||
|
if (accessStore.disableFastLink) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(text) as {
|
const payload = JSON.parse(text) as {
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -957,11 +1082,14 @@ export function Chat() {
|
|||||||
).then((res) => {
|
).then((res) => {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
if (payload.key) {
|
if (payload.key) {
|
||||||
accessStore.updateToken(payload.key);
|
accessStore.update(
|
||||||
|
(access) => (access.openaiApiKey = payload.key!),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (payload.url) {
|
if (payload.url) {
|
||||||
accessStore.updateOpenAiUrl(payload.url);
|
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
||||||
}
|
}
|
||||||
|
accessStore.update((access) => (access.useCustomConfig = true));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -973,6 +1101,111 @@ export function Chat() {
|
|||||||
// edit / insert message modal
|
// edit / insert message modal
|
||||||
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||||
|
|
||||||
|
// remember unfinished input
|
||||||
|
useEffect(() => {
|
||||||
|
// try to load from local storage
|
||||||
|
const key = UNFINISHED_INPUT(session.id);
|
||||||
|
const mayBeUnfinishedInput = localStorage.getItem(key);
|
||||||
|
if (mayBeUnfinishedInput && userInput.length === 0) {
|
||||||
|
setUserInput(mayBeUnfinishedInput);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dom = inputRef.current;
|
||||||
|
return () => {
|
||||||
|
localStorage.setItem(key, dom?.value ?? "");
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||||
|
if (!isVisionModel(currentModel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = (event.clipboardData || window.clipboardData).items;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||||
|
event.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const images: string[] = [];
|
||||||
|
images.push(...attachImages);
|
||||||
|
images.push(
|
||||||
|
...(await new Promise<string[]>((res, rej) => {
|
||||||
|
setUploading(true);
|
||||||
|
const imagesData: string[] = [];
|
||||||
|
compressImage(file, 256 * 1024)
|
||||||
|
.then((dataUrl) => {
|
||||||
|
imagesData.push(dataUrl);
|
||||||
|
setUploading(false);
|
||||||
|
res(imagesData);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setUploading(false);
|
||||||
|
rej(e);
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const imagesLength = images.length;
|
||||||
|
|
||||||
|
if (imagesLength > 3) {
|
||||||
|
images.splice(3, imagesLength - 3);
|
||||||
|
}
|
||||||
|
setAttachImages(images);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attachImages, chatStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function uploadImage() {
|
||||||
|
const images: string[] = [];
|
||||||
|
images.push(...attachImages);
|
||||||
|
|
||||||
|
images.push(
|
||||||
|
...(await new Promise<string[]>((res, rej) => {
|
||||||
|
const fileInput = document.createElement("input");
|
||||||
|
fileInput.type = "file";
|
||||||
|
fileInput.accept =
|
||||||
|
"image/png, image/jpeg, image/webp, image/heic, image/heif";
|
||||||
|
fileInput.multiple = true;
|
||||||
|
fileInput.onchange = (event: any) => {
|
||||||
|
setUploading(true);
|
||||||
|
const files = event.target.files;
|
||||||
|
const imagesData: string[] = [];
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = event.target.files[i];
|
||||||
|
compressImage(file, 256 * 1024)
|
||||||
|
.then((dataUrl) => {
|
||||||
|
imagesData.push(dataUrl);
|
||||||
|
if (
|
||||||
|
imagesData.length === 3 ||
|
||||||
|
imagesData.length === files.length
|
||||||
|
) {
|
||||||
|
setUploading(false);
|
||||||
|
res(imagesData);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setUploading(false);
|
||||||
|
rej(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileInput.click();
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const imagesLength = images.length;
|
||||||
|
if (imagesLength > 3) {
|
||||||
|
images.splice(3, imagesLength - 3);
|
||||||
|
}
|
||||||
|
setAttachImages(images);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.chat} key={session.id}>
|
<div className={styles.chat} key={session.id}>
|
||||||
<div className="window-header" data-tauri-drag-region>
|
<div className="window-header" data-tauri-drag-region>
|
||||||
@@ -1064,7 +1297,7 @@ export function Chat() {
|
|||||||
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={i}>
|
<Fragment key={message.id}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isUser ? styles["chat-message-user"] : styles["chat-message"]
|
isUser ? styles["chat-message-user"] : styles["chat-message"]
|
||||||
@@ -1079,15 +1312,29 @@ export function Chat() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const newMessage = await showPrompt(
|
const newMessage = await showPrompt(
|
||||||
Locale.Chat.Actions.Edit,
|
Locale.Chat.Actions.Edit,
|
||||||
message.content,
|
getMessageTextContent(message),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
let newContent: string | MultimodalContent[] =
|
||||||
|
newMessage;
|
||||||
|
const images = getMessageImages(message);
|
||||||
|
if (images.length > 0) {
|
||||||
|
newContent = [{ type: "text", text: newMessage }];
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
newContent.push({
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: images[i],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
chatStore.updateCurrentSession((session) => {
|
chatStore.updateCurrentSession((session) => {
|
||||||
const m = session.messages.find(
|
const m = session.mask.context
|
||||||
(m) => m.id === message.id,
|
.concat(session.messages)
|
||||||
);
|
.find((m) => m.id === message.id);
|
||||||
if (m) {
|
if (m) {
|
||||||
m.content = newMessage;
|
m.content = newContent;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -1096,7 +1343,18 @@ export function Chat() {
|
|||||||
{isUser ? (
|
{isUser ? (
|
||||||
<Avatar avatar={config.avatar} />
|
<Avatar avatar={config.avatar} />
|
||||||
) : (
|
) : (
|
||||||
<MaskAvatar mask={session.mask} />
|
<>
|
||||||
|
{["system"].includes(message.role) ? (
|
||||||
|
<Avatar avatar="2699-fe0f" />
|
||||||
|
) : (
|
||||||
|
<MaskAvatar
|
||||||
|
avatar={session.mask.avatar}
|
||||||
|
model={
|
||||||
|
message.model || session.mask.modelConfig.model
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1131,7 +1389,11 @@ export function Chat() {
|
|||||||
<ChatAction
|
<ChatAction
|
||||||
text={Locale.Chat.Actions.Copy}
|
text={Locale.Chat.Actions.Copy}
|
||||||
icon={<CopyIcon />}
|
icon={<CopyIcon />}
|
||||||
onClick={() => copyToClipboard(message.content)}
|
onClick={() =>
|
||||||
|
copyToClipboard(
|
||||||
|
getMessageTextContent(message),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1146,20 +1408,51 @@ export function Chat() {
|
|||||||
)}
|
)}
|
||||||
<div className={styles["chat-message-item"]}>
|
<div className={styles["chat-message-item"]}>
|
||||||
<Markdown
|
<Markdown
|
||||||
content={message.content}
|
content={getMessageTextContent(message)}
|
||||||
loading={
|
loading={
|
||||||
(message.preview || message.content.length === 0) &&
|
(message.preview || message.streaming) &&
|
||||||
|
message.content.length === 0 &&
|
||||||
!isUser
|
!isUser
|
||||||
}
|
}
|
||||||
onContextMenu={(e) => onRightClick(e, message)}
|
onContextMenu={(e) => onRightClick(e, message)}
|
||||||
onDoubleClickCapture={() => {
|
onDoubleClickCapture={() => {
|
||||||
if (!isMobileScreen) return;
|
if (!isMobileScreen) return;
|
||||||
setUserInput(message.content);
|
setUserInput(getMessageTextContent(message));
|
||||||
}}
|
}}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
parentRef={scrollRef}
|
parentRef={scrollRef}
|
||||||
defaultShow={i >= messages.length - 6}
|
defaultShow={i >= messages.length - 6}
|
||||||
/>
|
/>
|
||||||
|
{getMessageImages(message).length == 1 && (
|
||||||
|
<img
|
||||||
|
className={styles["chat-message-item-image"]}
|
||||||
|
src={getMessageImages(message)[0]}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{getMessageImages(message).length > 1 && (
|
||||||
|
<div
|
||||||
|
className={styles["chat-message-item-images"]}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--image-count": getMessageImages(message).length,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getMessageImages(message).map((image, index) => {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={
|
||||||
|
styles["chat-message-item-image-multi"]
|
||||||
|
}
|
||||||
|
key={index}
|
||||||
|
src={image}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles["chat-message-action-date"]}>
|
<div className={styles["chat-message-action-date"]}>
|
||||||
@@ -1179,9 +1472,13 @@ export function Chat() {
|
|||||||
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
||||||
|
|
||||||
<ChatActions
|
<ChatActions
|
||||||
|
uploadImage={uploadImage}
|
||||||
|
setAttachImages={setAttachImages}
|
||||||
|
setUploading={setUploading}
|
||||||
showPromptModal={() => setShowPromptModal(true)}
|
showPromptModal={() => setShowPromptModal(true)}
|
||||||
scrollToBottom={scrollToBottom}
|
scrollToBottom={scrollToBottom}
|
||||||
hitBottom={hitBottom}
|
hitBottom={hitBottom}
|
||||||
|
uploading={uploading}
|
||||||
showPromptHints={() => {
|
showPromptHints={() => {
|
||||||
// Click again to close
|
// Click again to close
|
||||||
if (promptHints.length > 0) {
|
if (promptHints.length > 0) {
|
||||||
@@ -1194,21 +1491,54 @@ export function Chat() {
|
|||||||
onSearch("");
|
onSearch("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={styles["chat-input-panel-inner"]}>
|
<label
|
||||||
|
className={`${styles["chat-input-panel-inner"]} ${
|
||||||
|
attachImages.length != 0
|
||||||
|
? styles["chat-input-panel-inner-attach"]
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
htmlFor="chat-input"
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="chat-input"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={styles["chat-input"]}
|
className={styles["chat-input"]}
|
||||||
placeholder={Locale.Chat.Input(submitKey)}
|
placeholder={Locale.Chat.Input(submitKey)}
|
||||||
onInput={(e) => onInput(e.currentTarget.value)}
|
onInput={(e) => onInput(e.currentTarget.value)}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onKeyDown={onInputKeyDown}
|
onKeyDown={onInputKeyDown}
|
||||||
onFocus={() => setAutoScroll(true)}
|
onFocus={scrollToBottom}
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
onPaste={handlePaste}
|
||||||
rows={inputRows}
|
rows={inputRows}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
style={{
|
style={{
|
||||||
fontSize: config.fontSize,
|
fontSize: config.fontSize,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{attachImages.length != 0 && (
|
||||||
|
<div className={styles["attach-images"]}>
|
||||||
|
{attachImages.map((image, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={styles["attach-image"]}
|
||||||
|
style={{ backgroundImage: `url("${image}")` }}
|
||||||
|
>
|
||||||
|
<div className={styles["attach-image-mask"]}>
|
||||||
|
<DeleteImageButton
|
||||||
|
deleteImage={() => {
|
||||||
|
setAttachImages(
|
||||||
|
attachImages.filter((_, i) => i !== index),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<SendWhiteIcon />}
|
icon={<SendWhiteIcon />}
|
||||||
text={Locale.Chat.Send}
|
text={Locale.Chat.Send}
|
||||||
@@ -1216,7 +1546,7 @@ export function Chat() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => doSubmit(userInput)}
|
onClick={() => doSubmit(userInput)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showExport && (
|
{showExport && (
|
||||||
@@ -1233,3 +1563,9 @@ export function Chat() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Chat() {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const sessionIndex = chatStore.currentSessionIndex;
|
||||||
|
return <_Chat key={sessionIndex}></_Chat>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import BotIcon from "../icons/bot.svg";
|
|||||||
import BlackBotIcon from "../icons/black-bot.svg";
|
import BlackBotIcon from "../icons/black-bot.svg";
|
||||||
|
|
||||||
export function getEmojiUrl(unified: string, style: EmojiStyle) {
|
export function getEmojiUrl(unified: string, style: EmojiStyle) {
|
||||||
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
|
// Whoever owns this Content Delivery Network (CDN), I am using your CDN to serve emojis
|
||||||
|
// Old CDN broken, so I had to switch to this one
|
||||||
|
// Author: https://github.com/H0llyW00dzZ
|
||||||
|
return `https://fastly.jsdelivr.net/npm/emoji-datasource-apple/img/${style}/64/${unified}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AvatarPicker(props: {
|
export function AvatarPicker(props: {
|
||||||
@@ -18,6 +21,7 @@ export function AvatarPicker(props: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
|
width={"100%"}
|
||||||
lazyLoadEmojis
|
lazyLoadEmojis
|
||||||
theme={EmojiTheme.AUTO}
|
theme={EmojiTheme.AUTO}
|
||||||
getEmojiUrl={getEmojiUrl}
|
getEmojiUrl={getEmojiUrl}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import GithubIcon from "../icons/github.svg";
|
|||||||
import ResetIcon from "../icons/reload.svg";
|
import ResetIcon from "../icons/reload.svg";
|
||||||
import { ISSUE_URL } from "../constant";
|
import { ISSUE_URL } from "../constant";
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { downloadAs } from "../utils";
|
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
|
import { useSyncStore } from "../store/sync";
|
||||||
|
|
||||||
interface IErrorBoundaryState {
|
interface IErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -26,10 +26,7 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
|||||||
|
|
||||||
clearAndSaveData() {
|
clearAndSaveData() {
|
||||||
try {
|
try {
|
||||||
downloadAs(
|
useSyncStore.getState().export();
|
||||||
JSON.stringify(localStorage),
|
|
||||||
"chatgpt-next-web-snapshot.json",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
location.reload();
|
location.reload();
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
&-body {
|
&-body {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-content {
|
.export-content {
|
||||||
@@ -94,6 +97,7 @@
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
@@ -186,9 +190,63 @@
|
|||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
border: var(--border-in-light);
|
border: var(--border-in-light);
|
||||||
|
|
||||||
*:not(li) {
|
code,
|
||||||
|
pre {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-image {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-images {
|
||||||
|
display: grid;
|
||||||
|
justify-content: left;
|
||||||
|
grid-gap: 10px;
|
||||||
|
grid-template-columns: repeat(var(--image-count), auto);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
$image-width: calc(calc(100vw/2)/var(--image-count));
|
||||||
|
|
||||||
|
.message-image-multi {
|
||||||
|
width: $image-width;
|
||||||
|
height: $image-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image {
|
||||||
|
max-width: calc(100vw/3*2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
$max-image-width: calc(900px/3*2/var(--image-count));
|
||||||
|
$image-width: calc(80vw/3*2/var(--image-count));
|
||||||
|
|
||||||
|
.message-image-multi {
|
||||||
|
width: $image-width;
|
||||||
|
height: $image-width;
|
||||||
|
max-width: $max-image-width;
|
||||||
|
max-height: $max-image-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image {
|
||||||
|
max-width: calc(100vw/3*2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image-multi {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image,
|
||||||
|
.message-image-multi {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-assistant {
|
&-assistant {
|
||||||
@@ -212,6 +270,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-theme {
|
.default-theme {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
import { ChatMessage, ModelType, useAppConfig, useChatStore } from "../store";
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import styles from "./exporter.module.scss";
|
import styles from "./exporter.module.scss";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
} from "./ui-lib";
|
} from "./ui-lib";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
downloadAs,
|
||||||
|
getMessageImages,
|
||||||
|
useMobileScreen,
|
||||||
|
} from "../utils";
|
||||||
|
|
||||||
import CopyIcon from "../icons/copy.svg";
|
import CopyIcon from "../icons/copy.svg";
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
@@ -27,12 +32,15 @@ import { Avatar } from "./emoji";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
|
|
||||||
import { toBlob, toJpeg, toPng } from "html-to-image";
|
import { toBlob, toPng } from "html-to-image";
|
||||||
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
||||||
import { api } from "../client/api";
|
|
||||||
import { prettyObject } from "../utils/format";
|
import { prettyObject } from "../utils/format";
|
||||||
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
|
||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
|
import { ClientApi } from "../client/api";
|
||||||
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
@@ -41,7 +49,22 @@ const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
|||||||
export function ExportMessageModal(props: { onClose: () => void }) {
|
export function ExportMessageModal(props: { onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="modal-mask">
|
<div className="modal-mask">
|
||||||
<Modal title={Locale.Export.Title} onClose={props.onClose}>
|
<Modal
|
||||||
|
title={Locale.Export.Title}
|
||||||
|
onClose={props.onClose}
|
||||||
|
footer={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Locale.Exporter.Description.Title}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div style={{ minHeight: "40vh" }}>
|
<div style={{ minHeight: "40vh" }}>
|
||||||
<MessageExporter />
|
<MessageExporter />
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +150,7 @@ export function MessageExporter() {
|
|||||||
];
|
];
|
||||||
const { currentStep, setCurrentStepIndex, currentStepIndex } =
|
const { currentStep, setCurrentStepIndex, currentStepIndex } =
|
||||||
useSteps(steps);
|
useSteps(steps);
|
||||||
const formats = ["text", "image"] as const;
|
const formats = ["text", "image", "json"] as const;
|
||||||
type ExportFormat = (typeof formats)[number];
|
type ExportFormat = (typeof formats)[number];
|
||||||
|
|
||||||
const [exportConfig, setExportConfig] = useState({
|
const [exportConfig, setExportConfig] = useState({
|
||||||
@@ -149,7 +172,7 @@ export function MessageExporter() {
|
|||||||
if (exportConfig.includeContext) {
|
if (exportConfig.includeContext) {
|
||||||
ret.push(...session.mask.context);
|
ret.push(...session.mask.context);
|
||||||
}
|
}
|
||||||
ret.push(...session.messages.filter((m, i) => selection.has(m.id)));
|
ret.push(...session.messages.filter((m) => selection.has(m.id)));
|
||||||
return ret;
|
return ret;
|
||||||
}, [
|
}, [
|
||||||
exportConfig.includeContext,
|
exportConfig.includeContext,
|
||||||
@@ -157,7 +180,21 @@ export function MessageExporter() {
|
|||||||
session.mask.context,
|
session.mask.context,
|
||||||
selection,
|
selection,
|
||||||
]);
|
]);
|
||||||
|
function preview() {
|
||||||
|
if (exportConfig.format === "text") {
|
||||||
|
return (
|
||||||
|
<MarkdownPreviewer messages={selectedMessages} topic={session.topic} />
|
||||||
|
);
|
||||||
|
} else if (exportConfig.format === "json") {
|
||||||
|
return (
|
||||||
|
<JsonPreviewer messages={selectedMessages} topic={session.topic} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<ImagePreviewer messages={selectedMessages} topic={session.topic} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Steps
|
<Steps
|
||||||
@@ -212,16 +249,7 @@ export function MessageExporter() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{currentStep.value === "preview" && (
|
{currentStep.value === "preview" && (
|
||||||
<div className={styles["message-exporter-body"]}>
|
<div className={styles["message-exporter-body"]}>{preview()}</div>
|
||||||
{exportConfig.format === "text" ? (
|
|
||||||
<MarkdownPreviewer
|
|
||||||
messages={selectedMessages}
|
|
||||||
topic={session.topic}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ImagePreviewer messages={selectedMessages} topic={session.topic} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -255,7 +283,8 @@ export function RenderExport(props: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
props.onRender(renderMsgs);
|
props.onRender(renderMsgs);
|
||||||
});
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={domRef}>
|
<div ref={domRef}>
|
||||||
@@ -265,7 +294,7 @@ export function RenderExport(props: {
|
|||||||
id={`${m.role}:${i}`}
|
id={`${m.role}:${i}`}
|
||||||
className={EXPORT_MESSAGE_CLASS_NAME}
|
className={EXPORT_MESSAGE_CLASS_NAME}
|
||||||
>
|
>
|
||||||
<Markdown content={m.content} defaultShow />
|
<Markdown content={getMessageTextContent(m)} defaultShow />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,10 +309,19 @@ export function PreviewActions(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [shouldExport, setShouldExport] = useState(false);
|
const [shouldExport, setShouldExport] = useState(false);
|
||||||
|
const config = useAppConfig();
|
||||||
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
||||||
setShouldExport(false);
|
setShouldExport(false);
|
||||||
|
|
||||||
|
var api: ClientApi;
|
||||||
|
if (config.modelConfig.model.startsWith("gemini")) {
|
||||||
|
api = new ClientApi(ModelProvider.GeminiPro);
|
||||||
|
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||||
|
api = new ClientApi(ModelProvider.Claude);
|
||||||
|
} else {
|
||||||
|
api = new ClientApi(ModelProvider.GPT);
|
||||||
|
}
|
||||||
|
|
||||||
api
|
api
|
||||||
.share(msgs)
|
.share(msgs)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -378,7 +416,7 @@ export function PreviewActions(props: {
|
|||||||
function ExportAvatar(props: { avatar: string }) {
|
function ExportAvatar(props: { avatar: string }) {
|
||||||
if (props.avatar === DEFAULT_MASK_AVATAR) {
|
if (props.avatar === DEFAULT_MASK_AVATAR) {
|
||||||
return (
|
return (
|
||||||
<NextImage
|
<img
|
||||||
src={BotIcon.src}
|
src={BotIcon.src}
|
||||||
width={30}
|
width={30}
|
||||||
height={30}
|
height={30}
|
||||||
@@ -388,7 +426,7 @@ function ExportAvatar(props: { avatar: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Avatar avatar={props.avatar}></Avatar>;
|
return <Avatar avatar={props.avatar} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImagePreviewer(props: {
|
export function ImagePreviewer(props: {
|
||||||
@@ -417,6 +455,7 @@ export function ImagePreviewer(props: {
|
|||||||
])
|
])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast(Locale.Copy.Success);
|
showToast(Locale.Copy.Success);
|
||||||
|
refreshPreview();
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Copy Image] ", e);
|
console.error("[Copy Image] ", e);
|
||||||
@@ -427,24 +466,62 @@ export function ImagePreviewer(props: {
|
|||||||
|
|
||||||
const isMobile = useMobileScreen();
|
const isMobile = useMobileScreen();
|
||||||
|
|
||||||
const download = () => {
|
const download = async () => {
|
||||||
showToast(Locale.Export.Image.Toast);
|
showToast(Locale.Export.Image.Toast);
|
||||||
const dom = previewRef.current;
|
const dom = previewRef.current;
|
||||||
if (!dom) return;
|
if (!dom) return;
|
||||||
toPng(dom)
|
|
||||||
.then((blob) => {
|
|
||||||
if (!blob) return;
|
|
||||||
|
|
||||||
if (isMobile || getClientConfig()?.isApp) {
|
const isApp = getClientConfig()?.isApp;
|
||||||
showImageModal(blob);
|
|
||||||
|
try {
|
||||||
|
const blob = await toPng(dom);
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
if (isMobile || (isApp && window.__TAURI__)) {
|
||||||
|
if (isApp && window.__TAURI__) {
|
||||||
|
const result = await window.__TAURI__.dialog.save({
|
||||||
|
defaultPath: `${props.topic}.png`,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "PNG Files",
|
||||||
|
extensions: ["png"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "All Files",
|
||||||
|
extensions: ["*"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result !== null) {
|
||||||
|
const response = await fetch(blob);
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(buffer);
|
||||||
|
await window.__TAURI__.fs.writeBinaryFile(result, uint8Array);
|
||||||
|
showToast(Locale.Download.Success);
|
||||||
|
} else {
|
||||||
|
showToast(Locale.Download.Failed);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const link = document.createElement("a");
|
showImageModal(blob);
|
||||||
link.download = `${props.topic}.png`;
|
|
||||||
link.href = blob;
|
|
||||||
link.click();
|
|
||||||
}
|
}
|
||||||
})
|
} else {
|
||||||
.catch((e) => console.log("[Export Image] ", e));
|
const link = document.createElement("a");
|
||||||
|
link.download = `${props.topic}.png`;
|
||||||
|
link.href = blob;
|
||||||
|
link.click();
|
||||||
|
refreshPreview();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(Locale.Download.Failed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPreview = () => {
|
||||||
|
const dom = previewRef.current;
|
||||||
|
if (dom) {
|
||||||
|
dom.innerHTML = dom.innerHTML; // Refresh the content of the preview by resetting its HTML for fix a bug glitching
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -470,7 +547,7 @@ export function ImagePreviewer(props: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className={styles["main-title"]}>ChatGPT Next Web</div>
|
<div className={styles["main-title"]}>NextChat</div>
|
||||||
<div className={styles["sub-title"]}>
|
<div className={styles["sub-title"]}>
|
||||||
github.com/Yidadaa/ChatGPT-Next-Web
|
github.com/Yidadaa/ChatGPT-Next-Web
|
||||||
</div>
|
</div>
|
||||||
@@ -512,10 +589,37 @@ export function ImagePreviewer(props: {
|
|||||||
|
|
||||||
<div className={styles["body"]}>
|
<div className={styles["body"]}>
|
||||||
<Markdown
|
<Markdown
|
||||||
content={m.content}
|
content={getMessageTextContent(m)}
|
||||||
fontSize={config.fontSize}
|
fontSize={config.fontSize}
|
||||||
defaultShow
|
defaultShow
|
||||||
/>
|
/>
|
||||||
|
{getMessageImages(m).length == 1 && (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={getMessageImages(m)[0]}
|
||||||
|
alt="message"
|
||||||
|
className={styles["message-image"]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{getMessageImages(m).length > 1 && (
|
||||||
|
<div
|
||||||
|
className={styles["message-images"]}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--image-count": getMessageImages(m).length,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getMessageImages(m).map((src, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={src}
|
||||||
|
alt="message"
|
||||||
|
className={styles["message-image-multi"]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -534,8 +638,10 @@ export function MarkdownPreviewer(props: {
|
|||||||
props.messages
|
props.messages
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
return m.role === "user"
|
return m.role === "user"
|
||||||
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
|
? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}`
|
||||||
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
|
: `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent(
|
||||||
|
m,
|
||||||
|
).trim()}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
@@ -545,12 +651,12 @@ export function MarkdownPreviewer(props: {
|
|||||||
const download = () => {
|
const download = () => {
|
||||||
downloadAs(mdText, `${props.topic}.md`);
|
downloadAs(mdText, `${props.topic}.md`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PreviewActions
|
<PreviewActions
|
||||||
copy={copy}
|
copy={copy}
|
||||||
download={download}
|
download={download}
|
||||||
|
showCopy={true}
|
||||||
messages={props.messages}
|
messages={props.messages}
|
||||||
/>
|
/>
|
||||||
<div className="markdown-body">
|
<div className="markdown-body">
|
||||||
@@ -559,3 +665,44 @@ export function MarkdownPreviewer(props: {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function JsonPreviewer(props: {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
topic: string;
|
||||||
|
}) {
|
||||||
|
const msgs = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `${Locale.FineTuned.Sysmessage} ${props.topic}`,
|
||||||
|
},
|
||||||
|
...props.messages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const mdText = "```json\n" + JSON.stringify(msgs, null, 2) + "\n```";
|
||||||
|
const minifiedJson = JSON.stringify(msgs);
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
copyToClipboard(minifiedJson);
|
||||||
|
};
|
||||||
|
const download = () => {
|
||||||
|
downloadAs(JSON.stringify(msgs), `${props.topic}.json`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PreviewActions
|
||||||
|
copy={copy}
|
||||||
|
download={download}
|
||||||
|
showCopy={false}
|
||||||
|
messages={props.messages}
|
||||||
|
/>
|
||||||
|
<div className="markdown-body" onClick={copy}>
|
||||||
|
<Markdown content={mdText} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
color: var(--black);
|
color: var(--black);
|
||||||
background-color: var(--white);
|
background-color: var(--white);
|
||||||
min-width: 600px;
|
min-width: 600px;
|
||||||
min-height: 480px;
|
min-height: 370px;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import LoadingIcon from "../icons/three-dots.svg";
|
|||||||
import { getCSSVar, useMobileScreen } from "../utils";
|
import { getCSSVar, useMobileScreen } from "../utils";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Path, SlotID } from "../constant";
|
import { ModelProvider, Path, SlotID } from "../constant";
|
||||||
import { ErrorBoundary } from "./error";
|
import { ErrorBoundary } from "./error";
|
||||||
|
|
||||||
import { getLang } from "../locales";
|
import { getISOLang, getLang } from "../locales";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HashRouter as Router,
|
HashRouter as Router,
|
||||||
@@ -27,8 +27,9 @@ import { SideBar } from "./sidebar";
|
|||||||
import { useAppConfig } from "../store/config";
|
import { useAppConfig } from "../store/config";
|
||||||
import { AuthPage } from "./auth";
|
import { AuthPage } from "./auth";
|
||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import { api } from "../client/api";
|
import { ClientApi } from "../client/api";
|
||||||
import { useAccessStore } from "../store";
|
import { useAccessStore } from "../store";
|
||||||
|
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||||
|
|
||||||
export function Loading(props: { noLogo?: boolean }) {
|
export function Loading(props: { noLogo?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -86,6 +87,17 @@ export function useSwitchTheme() {
|
|||||||
}, [config.theme]);
|
}, [config.theme]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useHtmlLang() {
|
||||||
|
useEffect(() => {
|
||||||
|
const lang = getISOLang();
|
||||||
|
const htmlLang = document.documentElement.lang;
|
||||||
|
|
||||||
|
if (lang !== htmlLang) {
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
const useHasHydrated = () => {
|
const useHasHydrated = () => {
|
||||||
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -105,7 +117,9 @@ const loadAsyncGoogleFont = () => {
|
|||||||
linkEl.rel = "stylesheet";
|
linkEl.rel = "stylesheet";
|
||||||
linkEl.href =
|
linkEl.href =
|
||||||
googleFontUrl +
|
googleFontUrl +
|
||||||
"/css2?family=Noto+Sans+SC:wght@300;400;700;900&display=swap";
|
"/css2?family=" +
|
||||||
|
encodeURIComponent("Noto Sans:wght@300;400;700;900") +
|
||||||
|
"&display=swap";
|
||||||
document.head.appendChild(linkEl);
|
document.head.appendChild(linkEl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +129,8 @@ function Screen() {
|
|||||||
const isHome = location.pathname === Path.Home;
|
const isHome = location.pathname === Path.Home;
|
||||||
const isAuth = location.pathname === Path.Auth;
|
const isAuth = location.pathname === Path.Auth;
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const shouldTightBorder =
|
||||||
|
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAsyncGoogleFont();
|
loadAsyncGoogleFont();
|
||||||
@@ -124,11 +140,9 @@ function Screen() {
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
styles.container +
|
styles.container +
|
||||||
` ${
|
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
|
||||||
config.tightBorder && !isMobileScreen
|
getLang() === "ar" ? styles["rtl-screen"] : ""
|
||||||
? styles["tight-container"]
|
}`
|
||||||
: styles.container
|
|
||||||
} ${getLang() === "ar" ? styles["rtl-screen"] : ""}`
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isAuth ? (
|
{isAuth ? (
|
||||||
@@ -157,6 +171,14 @@ function Screen() {
|
|||||||
export function useLoadData() {
|
export function useLoadData() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
var api: ClientApi;
|
||||||
|
if (config.modelConfig.model.startsWith("gemini")) {
|
||||||
|
api = new ClientApi(ModelProvider.GeminiPro);
|
||||||
|
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||||
|
api = new ClientApi(ModelProvider.Claude);
|
||||||
|
} else {
|
||||||
|
api = new ClientApi(ModelProvider.GPT);
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const models = await api.llm.models();
|
const models = await api.llm.models();
|
||||||
@@ -169,6 +191,7 @@ export function useLoadData() {
|
|||||||
export function Home() {
|
export function Home() {
|
||||||
useSwitchTheme();
|
useSwitchTheme();
|
||||||
useLoadData();
|
useLoadData();
|
||||||
|
useHtmlLang();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[Config] got config from build time", getClientConfig());
|
console.log("[Config] got config from build time", getClientConfig());
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import RemarkBreaks from "remark-breaks";
|
|||||||
import RehypeKatex from "rehype-katex";
|
import RehypeKatex from "rehype-katex";
|
||||||
import RemarkGfm from "remark-gfm";
|
import RemarkGfm from "remark-gfm";
|
||||||
import RehypeHighlight from "rehype-highlight";
|
import RehypeHighlight from "rehype-highlight";
|
||||||
import { useRef, useState, RefObject, useEffect } from "react";
|
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
||||||
import { copyToClipboard } from "../utils";
|
import { copyToClipboard } from "../utils";
|
||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
|
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDebouncedCallback, useThrottledCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { showImageModal } from "./ui-lib";
|
import { showImageModal } from "./ui-lib";
|
||||||
|
|
||||||
export function Mermaid(props: { code: string }) {
|
export function Mermaid(props: { code: string }) {
|
||||||
@@ -38,12 +38,6 @@ export function Mermaid(props: { code: string }) {
|
|||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
const text = new XMLSerializer().serializeToString(svg);
|
const text = new XMLSerializer().serializeToString(svg);
|
||||||
const blob = new Blob([text], { type: "image/svg+xml" });
|
const blob = new Blob([text], { type: "image/svg+xml" });
|
||||||
console.log(blob);
|
|
||||||
// const url = URL.createObjectURL(blob);
|
|
||||||
// const win = window.open(url);
|
|
||||||
// if (win) {
|
|
||||||
// win.onload = () => URL.revokeObjectURL(url);
|
|
||||||
// }
|
|
||||||
showImageModal(URL.createObjectURL(blob));
|
showImageModal(URL.createObjectURL(blob));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +99,46 @@ export function PreCode(props: { children: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeDollarNumber(text: string) {
|
||||||
|
let escapedText = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
let char = text[i];
|
||||||
|
const nextChar = text[i + 1] || " ";
|
||||||
|
|
||||||
|
if (char === "$" && nextChar >= "0" && nextChar <= "9") {
|
||||||
|
char = "\\$";
|
||||||
|
}
|
||||||
|
|
||||||
|
escapedText += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
return escapedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeBrackets(text: string) {
|
||||||
|
const pattern =
|
||||||
|
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
|
||||||
|
return text.replace(
|
||||||
|
pattern,
|
||||||
|
(match, codeBlock, squareBracket, roundBracket) => {
|
||||||
|
if (codeBlock) {
|
||||||
|
return codeBlock;
|
||||||
|
} else if (squareBracket) {
|
||||||
|
return `$$${squareBracket}$$`;
|
||||||
|
} else if (roundBracket) {
|
||||||
|
return `$${roundBracket}$`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function _MarkDownContent(props: { content: string }) {
|
function _MarkDownContent(props: { content: string }) {
|
||||||
|
const escapedContent = useMemo(() => {
|
||||||
|
return escapeBrackets(escapeDollarNumber(props.content));
|
||||||
|
}, [props.content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||||
@@ -121,6 +154,7 @@ function _MarkDownContent(props: { content: string }) {
|
|||||||
]}
|
]}
|
||||||
components={{
|
components={{
|
||||||
pre: PreCode,
|
pre: PreCode,
|
||||||
|
p: (pProps) => <p {...pProps} dir="auto" />,
|
||||||
a: (aProps) => {
|
a: (aProps) => {
|
||||||
const href = aProps.href || "";
|
const href = aProps.href || "";
|
||||||
const isInternal = /^\/#/i.test(href);
|
const isInternal = /^\/#/i.test(href);
|
||||||
@@ -129,7 +163,7 @@ function _MarkDownContent(props: { content: string }) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.content}
|
{escapedContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,73 +177,27 @@ export function Markdown(
|
|||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
parentRef?: RefObject<HTMLDivElement>;
|
parentRef?: RefObject<HTMLDivElement>;
|
||||||
defaultShow?: boolean;
|
defaultShow?: boolean;
|
||||||
|
className?: string;
|
||||||
} & React.DOMAttributes<HTMLDivElement>,
|
} & React.DOMAttributes<HTMLDivElement>,
|
||||||
) {
|
) {
|
||||||
const mdRef = useRef<HTMLDivElement>(null);
|
const mdRef = useRef<HTMLDivElement>(null);
|
||||||
const renderedHeight = useRef(0);
|
|
||||||
const renderedWidth = useRef(0);
|
|
||||||
const inView = useRef(!!props.defaultShow);
|
|
||||||
const [_, triggerRender] = useState(0);
|
|
||||||
const checkInView = useThrottledCallback(
|
|
||||||
() => {
|
|
||||||
const parent = props.parentRef?.current;
|
|
||||||
const md = mdRef.current;
|
|
||||||
if (parent && md && !props.defaultShow) {
|
|
||||||
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;
|
|
||||||
triggerRender(Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inView.current && md) {
|
|
||||||
const rect = md.getBoundingClientRect();
|
|
||||||
renderedHeight.current = Math.max(renderedHeight.current, rect.height);
|
|
||||||
renderedWidth.current = Math.max(renderedWidth.current, rect.width);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
},
|
|
||||||
300,
|
|
||||||
{
|
|
||||||
leading: true,
|
|
||||||
trailing: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
props.parentRef?.current?.addEventListener("scroll", checkInView);
|
|
||||||
checkInView();
|
|
||||||
return () =>
|
|
||||||
props.parentRef?.current?.removeEventListener("scroll", checkInView);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getSize = (x: number) => (!inView.current && x > 0 ? x : "auto");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="markdown-body"
|
className={`markdown-body ${props.className}`}
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${props.fontSize ?? 14}px`,
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
height: getSize(renderedHeight.current),
|
|
||||||
width: getSize(renderedWidth.current),
|
|
||||||
direction: /[\u0600-\u06FF]/.test(props.content) ? "rtl" : "ltr",
|
|
||||||
}}
|
}}
|
||||||
ref={mdRef}
|
ref={mdRef}
|
||||||
onContextMenu={props.onContextMenu}
|
onContextMenu={props.onContextMenu}
|
||||||
onDoubleClickCapture={props.onDoubleClickCapture}
|
onDoubleClickCapture={props.onDoubleClickCapture}
|
||||||
|
dir="auto"
|
||||||
>
|
>
|
||||||
{inView.current &&
|
{props.loading ? (
|
||||||
(props.loading ? (
|
<LoadingIcon />
|
||||||
<LoadingIcon />
|
) : (
|
||||||
) : (
|
<MarkdownContent content={props.content} />
|
||||||
<MarkdownContent content={props.content} />
|
)}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.mask-page-body {
|
.mask-page-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
import { ErrorBoundary } from "./error";
|
|
||||||
|
|
||||||
import styles from "./mask.module.scss";
|
import styles from "./mask.module.scss";
|
||||||
|
|
||||||
@@ -18,10 +17,11 @@ import {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
createMessage,
|
createMessage,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
|
ModelType,
|
||||||
useAppConfig,
|
useAppConfig,
|
||||||
useChatStore,
|
useChatStore,
|
||||||
} from "../store";
|
} from "../store";
|
||||||
import { ROLES } from "../client/api";
|
import { MultimodalContent, ROLES } from "../client/api";
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
List,
|
List,
|
||||||
@@ -37,7 +37,12 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import chatStyle from "./chat.module.scss";
|
import chatStyle from "./chat.module.scss";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { copyToClipboard, downloadAs, readFromFile } from "../utils";
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
downloadAs,
|
||||||
|
getMessageImages,
|
||||||
|
readFromFile,
|
||||||
|
} from "../utils";
|
||||||
import { Updater } from "../typing";
|
import { Updater } from "../typing";
|
||||||
import { ModelConfigList } from "./model-config";
|
import { ModelConfigList } from "./model-config";
|
||||||
import { FileName, Path } from "../constant";
|
import { FileName, Path } from "../constant";
|
||||||
@@ -49,6 +54,8 @@ import {
|
|||||||
Draggable,
|
Draggable,
|
||||||
OnDragEndResponder,
|
OnDragEndResponder,
|
||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
|
||||||
// drag and drop helper function
|
// drag and drop helper function
|
||||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||||
@@ -58,11 +65,11 @@ function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaskAvatar(props: { mask: Mask }) {
|
export function MaskAvatar(props: { avatar: string; model?: ModelType }) {
|
||||||
return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
|
return props.avatar !== DEFAULT_MASK_AVATAR ? (
|
||||||
<Avatar avatar={props.mask.avatar} />
|
<Avatar avatar={props.avatar} />
|
||||||
) : (
|
) : (
|
||||||
<Avatar model={props.mask.modelConfig.model} />
|
<Avatar model={props.model} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +130,10 @@ export function MaskConfig(props: {
|
|||||||
onClick={() => setShowPicker(true)}
|
onClick={() => setShowPicker(true)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<MaskAvatar mask={props.mask} />
|
<MaskAvatar
|
||||||
|
avatar={props.mask.avatar}
|
||||||
|
model={props.mask.modelConfig.model}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -240,7 +250,7 @@ function ContextPromptItem(props: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
value={props.prompt.content}
|
value={getMessageTextContent(props.prompt)}
|
||||||
type="text"
|
type="text"
|
||||||
className={chatStyle["context-content"]}
|
className={chatStyle["context-content"]}
|
||||||
rows={focusingInput ? 5 : 1}
|
rows={focusingInput ? 5 : 1}
|
||||||
@@ -285,7 +295,18 @@ export function ContextPrompts(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
|
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
|
||||||
props.updateContext((context) => (context[i] = prompt));
|
props.updateContext((context) => {
|
||||||
|
const images = getMessageImages(context[i]);
|
||||||
|
context[i] = prompt;
|
||||||
|
if (images.length > 0) {
|
||||||
|
const text = getMessageTextContent(context[i]);
|
||||||
|
const newContext: MultimodalContent[] = [{ type: "text", text }];
|
||||||
|
for (const img of images) {
|
||||||
|
newContext.push({ type: "image_url", image_url: { url: img } });
|
||||||
|
}
|
||||||
|
context[i].content = newContext;
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd: OnDragEndResponder = (result) => {
|
const onDragEnd: OnDragEndResponder = (result) => {
|
||||||
@@ -377,13 +398,22 @@ export function ContextPrompts(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaskPage() {
|
export function MaskPage(props: { className?: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
const [filterLang, setFilterLang] = useState<Lang>();
|
const [filterLang, setFilterLang] = useState<Lang | undefined>(
|
||||||
|
() => localStorage.getItem("Mask-language") as Lang | undefined,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterLang) {
|
||||||
|
localStorage.setItem("Mask-language", filterLang);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("Mask-language");
|
||||||
|
}
|
||||||
|
}, [filterLang]);
|
||||||
|
|
||||||
const allMasks = maskStore
|
const allMasks = maskStore
|
||||||
.getAll()
|
.getAll()
|
||||||
@@ -393,11 +423,13 @@ export function MaskPage() {
|
|||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const masks = searchText.length > 0 ? searchMasks : allMasks;
|
const masks = searchText.length > 0 ? searchMasks : allMasks;
|
||||||
|
|
||||||
// simple search, will refactor later
|
// refactored already, now it accurate
|
||||||
const onSearch = (text: string) => {
|
const onSearch = (text: string) => {
|
||||||
setSearchText(text);
|
setSearchText(text);
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
const result = allMasks.filter((m) => m.name.includes(text));
|
const result = allMasks.filter((m) =>
|
||||||
|
m.name.toLowerCase().includes(text.toLowerCase()),
|
||||||
|
);
|
||||||
setSearchMasks(result);
|
setSearchMasks(result);
|
||||||
} else {
|
} else {
|
||||||
setSearchMasks(allMasks);
|
setSearchMasks(allMasks);
|
||||||
@@ -410,7 +442,7 @@ export function MaskPage() {
|
|||||||
const closeMaskModal = () => setEditingMaskId(undefined);
|
const closeMaskModal = () => setEditingMaskId(undefined);
|
||||||
|
|
||||||
const downloadAll = () => {
|
const downloadAll = () => {
|
||||||
downloadAs(JSON.stringify(masks), FileName.Masks);
|
downloadAs(JSON.stringify(masks.filter((v) => !v.builtin)), FileName.Masks);
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFromFile = () => {
|
const importFromFile = () => {
|
||||||
@@ -434,8 +466,13 @@ export function MaskPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<>
|
||||||
<div className={styles["mask-page"]}>
|
<div
|
||||||
|
className={`
|
||||||
|
${styles["mask-page"]}
|
||||||
|
${props.className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div className="window-header">
|
<div className="window-header">
|
||||||
<div className="window-header-title">
|
<div className="window-header-title">
|
||||||
<div className="window-header-main-title">
|
<div className="window-header-main-title">
|
||||||
@@ -452,11 +489,13 @@ export function MaskPage() {
|
|||||||
icon={<DownloadIcon />}
|
icon={<DownloadIcon />}
|
||||||
bordered
|
bordered
|
||||||
onClick={downloadAll}
|
onClick={downloadAll}
|
||||||
|
text={Locale.UI.Export}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="window-action-button">
|
<div className="window-action-button">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<UploadIcon />}
|
icon={<UploadIcon />}
|
||||||
|
text={Locale.UI.Import}
|
||||||
bordered
|
bordered
|
||||||
onClick={() => importFromFile()}
|
onClick={() => importFromFile()}
|
||||||
/>
|
/>
|
||||||
@@ -519,7 +558,7 @@ export function MaskPage() {
|
|||||||
<div className={styles["mask-item"]} key={m.id}>
|
<div className={styles["mask-item"]} key={m.id}>
|
||||||
<div className={styles["mask-header"]}>
|
<div className={styles["mask-header"]}>
|
||||||
<div className={styles["mask-icon"]}>
|
<div className={styles["mask-icon"]}>
|
||||||
<MaskAvatar mask={m} />
|
<MaskAvatar avatar={m.avatar} model={m.modelConfig.model} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["mask-title"]}>
|
<div className={styles["mask-title"]}>
|
||||||
<div className={styles["mask-name"]}>{m.name}</div>
|
<div className={styles["mask-name"]}>{m.name}</div>
|
||||||
@@ -604,13 +643,13 @@ export function MaskPage() {
|
|||||||
<MaskConfig
|
<MaskConfig
|
||||||
mask={editingMask}
|
mask={editingMask}
|
||||||
updateMask={(updater) =>
|
updateMask={(updater) =>
|
||||||
maskStore.update(editingMaskId!, updater)
|
maskStore.updateMask(editingMaskId!, updater)
|
||||||
}
|
}
|
||||||
readonly={editingMask.builtin}
|
readonly={editingMask.builtin}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ErrorBoundary>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
flex-grow: 1;
|
flex: 1;
|
||||||
max-width: calc(100% - 40px);
|
max-width: calc(100% - 80px);
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -71,6 +71,12 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
||||||
import { Updater } from "../typing";
|
import { Updater } from "../typing";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
@@ -7,6 +7,7 @@ import { MaskAvatar } from "./mask";
|
|||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
|
||||||
import styles from "./message-selector.module.scss";
|
import styles from "./message-selector.module.scss";
|
||||||
|
import { getMessageTextContent } from "../utils";
|
||||||
|
|
||||||
function useShiftRange() {
|
function useShiftRange() {
|
||||||
const [startIndex, setStartIndex] = useState<number>();
|
const [startIndex, setStartIndex] = useState<number>();
|
||||||
@@ -73,11 +74,23 @@ export function MessageSelector(props: {
|
|||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const session = chatStore.currentSession();
|
const session = chatStore.currentSession();
|
||||||
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
|
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
|
||||||
const messages = session.messages.filter(
|
const allMessages = useMemo(() => {
|
||||||
(m, i) =>
|
let startIndex = Math.max(0, session.clearContextIndex ?? 0);
|
||||||
m.id && // message must have id
|
if (startIndex === session.messages.length - 1) {
|
||||||
isValid(m) &&
|
startIndex = 0;
|
||||||
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
|
}
|
||||||
|
return session.messages.slice(startIndex);
|
||||||
|
}, [session.messages, session.clearContextIndex]);
|
||||||
|
|
||||||
|
const messages = useMemo(
|
||||||
|
() =>
|
||||||
|
allMessages.filter(
|
||||||
|
(m, i) =>
|
||||||
|
m.id && // message must have id
|
||||||
|
isValid(m) &&
|
||||||
|
(i >= allMessages.length - 1 || isValid(allMessages[i + 1])),
|
||||||
|
),
|
||||||
|
[allMessages],
|
||||||
);
|
);
|
||||||
const messageCount = messages.length;
|
const messageCount = messages.length;
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
@@ -91,7 +104,9 @@ export function MessageSelector(props: {
|
|||||||
const searchResults = new Set<string>();
|
const searchResults = new Set<string>();
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
messages.forEach((m) =>
|
messages.forEach((m) =>
|
||||||
m.content.includes(text) ? searchResults.add(m.id!) : null,
|
getMessageTextContent(m).includes(text)
|
||||||
|
? searchResults.add(m.id!)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setSearchIds(searchResults);
|
setSearchIds(searchResults);
|
||||||
@@ -176,6 +191,8 @@ export function MessageSelector(props: {
|
|||||||
<div className={styles["messages"]}>
|
<div className={styles["messages"]}>
|
||||||
{messages.map((m, i) => {
|
{messages.map((m, i) => {
|
||||||
if (!isInSearchResult(m.id!)) return null;
|
if (!isInSearchResult(m.id!)) return null;
|
||||||
|
const id = m.id ?? i;
|
||||||
|
const isSelected = props.selection.has(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -185,7 +202,6 @@ export function MessageSelector(props: {
|
|||||||
key={i}
|
key={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.updateSelection((selection) => {
|
props.updateSelection((selection) => {
|
||||||
const id = m.id ?? i;
|
|
||||||
selection.has(id) ? selection.delete(id) : selection.add(id);
|
selection.has(id) ? selection.delete(id) : selection.add(id);
|
||||||
});
|
});
|
||||||
onClickIndex(i);
|
onClickIndex(i);
|
||||||
@@ -195,7 +211,10 @@ export function MessageSelector(props: {
|
|||||||
{m.role === "user" ? (
|
{m.role === "user" ? (
|
||||||
<Avatar avatar={config.avatar}></Avatar>
|
<Avatar avatar={config.avatar}></Avatar>
|
||||||
) : (
|
) : (
|
||||||
<MaskAvatar mask={session.mask} />
|
<MaskAvatar
|
||||||
|
avatar={session.mask.avatar}
|
||||||
|
model={m.model || session.mask.modelConfig.model}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["body"]}>
|
<div className={styles["body"]}>
|
||||||
@@ -203,9 +222,13 @@ export function MessageSelector(props: {
|
|||||||
{new Date(m.date).toLocaleString()}
|
{new Date(m.date).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className={`${styles["content"]} one-line`}>
|
<div className={`${styles["content"]} one-line`}>
|
||||||
{m.content}
|
{getMessageTextContent(m)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["checkbox"]}>
|
||||||
|
<input type="checkbox" checked={isSelected} readOnly></input>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { ModalConfigValidator, ModelConfig, useAppConfig } from "../store";
|
import { ModalConfigValidator, ModelConfig } from "../store";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { InputRange } from "./input-range";
|
import { InputRange } from "./input-range";
|
||||||
import { ListItem, Select } from "./ui-lib";
|
import { ListItem, Select } from "./ui-lib";
|
||||||
|
import { useAllModels } from "../utils/hooks";
|
||||||
|
|
||||||
export function ModelConfigList(props: {
|
export function ModelConfigList(props: {
|
||||||
modelConfig: ModelConfig;
|
modelConfig: ModelConfig;
|
||||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||||
}) {
|
}) {
|
||||||
const config = useAppConfig();
|
const allModels = useAllModels();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -24,11 +25,13 @@ export function ModelConfigList(props: {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{config.allModels().map((v, i) => (
|
{allModels
|
||||||
<option value={v.name} key={i} disabled={!v.available}>
|
.filter((v) => v.available)
|
||||||
{v.name}
|
.map((v, i) => (
|
||||||
</option>
|
<option value={v.name} key={i}>
|
||||||
))}
|
{v.displayName}({v.provider?.providerName})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -75,8 +78,8 @@ export function ModelConfigList(props: {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={100}
|
min={1024}
|
||||||
max={100000}
|
max={512000}
|
||||||
value={props.modelConfig.max_tokens}
|
value={props.modelConfig.max_tokens}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
props.updateConfig(
|
props.updateConfig(
|
||||||
@@ -88,79 +91,84 @@ export function ModelConfigList(props: {
|
|||||||
}
|
}
|
||||||
></input>
|
></input>
|
||||||
</ListItem>
|
</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
|
{props.modelConfig.model.startsWith("gemini") ? null : (
|
||||||
title={Locale.Settings.FrequencyPenalty.Title}
|
<>
|
||||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
<ListItem
|
||||||
>
|
title={Locale.Settings.PresencePenalty.Title}
|
||||||
<InputRange
|
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||||
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
>
|
||||||
min="-2"
|
<InputRange
|
||||||
max="2"
|
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||||
step="0.1"
|
min="-2"
|
||||||
onChange={(e) => {
|
max="2"
|
||||||
props.updateConfig(
|
step="0.1"
|
||||||
(config) =>
|
onChange={(e) => {
|
||||||
(config.frequency_penalty =
|
props.updateConfig(
|
||||||
ModalConfigValidator.frequency_penalty(
|
(config) =>
|
||||||
e.currentTarget.valueAsNumber,
|
(config.presence_penalty =
|
||||||
)),
|
ModalConfigValidator.presence_penalty(
|
||||||
);
|
e.currentTarget.valueAsNumber,
|
||||||
}}
|
)),
|
||||||
></InputRange>
|
);
|
||||||
</ListItem>
|
}}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Settings.InjectSystemPrompts.Title}
|
title={Locale.Settings.FrequencyPenalty.Title}
|
||||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<InputRange
|
||||||
type="checkbox"
|
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
||||||
checked={props.modelConfig.enableInjectSystemPrompts}
|
min="-2"
|
||||||
onChange={(e) =>
|
max="2"
|
||||||
props.updateConfig(
|
step="0.1"
|
||||||
(config) =>
|
onChange={(e) => {
|
||||||
(config.enableInjectSystemPrompts = e.currentTarget.checked),
|
props.updateConfig(
|
||||||
)
|
(config) =>
|
||||||
}
|
(config.frequency_penalty =
|
||||||
></input>
|
ModalConfigValidator.frequency_penalty(
|
||||||
</ListItem>
|
e.currentTarget.valueAsNumber,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Settings.InputTemplate.Title}
|
title={Locale.Settings.InjectSystemPrompts.Title}
|
||||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="checkbox"
|
||||||
value={props.modelConfig.template}
|
checked={props.modelConfig.enableInjectSystemPrompts}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
props.updateConfig(
|
props.updateConfig(
|
||||||
(config) => (config.template = e.currentTarget.value),
|
(config) =>
|
||||||
)
|
(config.enableInjectSystemPrompts =
|
||||||
}
|
e.currentTarget.checked),
|
||||||
></input>
|
)
|
||||||
</ListItem>
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.InputTemplate.Title}
|
||||||
|
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={props.modelConfig.template}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.template = e.currentTarget.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Settings.HistoryCount.Title}
|
title={Locale.Settings.HistoryCount.Title}
|
||||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.mask-header {
|
.mask-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -16,22 +16,15 @@ import { MaskAvatar } from "./mask";
|
|||||||
import { useCommand } from "../command";
|
import { useCommand } from "../command";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { BUILTIN_MASK_STORE } from "../masks";
|
import { BUILTIN_MASK_STORE } from "../masks";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
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 }) {
|
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles["mask"]} onClick={props.onClick}>
|
<div className={styles["mask"]} onClick={props.onClick}>
|
||||||
<MaskAvatar mask={props.mask} />
|
<MaskAvatar
|
||||||
|
avatar={props.mask.avatar}
|
||||||
|
model={props.mask.modelConfig.model}
|
||||||
|
/>
|
||||||
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -79,7 +72,7 @@ function useMaskGroup(masks: Mask[]) {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewChat() {
|
export function NewChat(props: { className?: string }) {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
|
|
||||||
@@ -118,8 +111,15 @@ export function NewChat() {
|
|||||||
}
|
}
|
||||||
}, [groups]);
|
}, [groups]);
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["new-chat"]}>
|
<div
|
||||||
|
className={`
|
||||||
|
${styles["new-chat"]}
|
||||||
|
${props.className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div className={styles["mask-header"]}>
|
<div className={styles["mask-header"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<LeftIcon />}
|
icon={<LeftIcon />}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-prompt-modal {
|
.edit-prompt-modal {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user