mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-25 03:13:41 +08:00
Compare commits
1664 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
463fa743e9 | ||
|
|
cda4494cec | ||
|
|
87d85c10c3 | ||
|
|
22f83c9e11 | ||
|
|
7f454cbcec | ||
|
|
426269d795 | ||
|
|
370f143157 | ||
|
|
103106bb93 | ||
|
|
2419083adf | ||
|
|
c25903bfb4 | ||
|
|
e34c266438 | ||
|
|
8c39a687b5 | ||
|
|
592f62005b | ||
|
|
12e7caa209 | ||
|
|
b016771555 | ||
|
|
a84383f919 | ||
|
|
7f68fb1ff2 | ||
|
|
8d2003fe68 | ||
|
|
9961b513cc | ||
|
|
819238acaf | ||
|
|
ad49916b1c | ||
|
|
d18bd8a48a | ||
|
|
4a1319f2c0 | ||
|
|
8fd843d228 | ||
|
|
6792d6e475 | ||
|
|
c139038e01 | ||
|
|
4a7fd3a380 | ||
|
|
c98dc31cdf | ||
|
|
bd43af3a8d | ||
|
|
be98aa2078 | ||
|
|
a0d4a04192 | ||
|
|
bd9de4dc4d | ||
|
|
2eebfcf6fe | ||
|
|
c5074f0aa4 | ||
|
|
ba58018a15 | ||
|
|
63ab83c3c8 | ||
|
|
268cf3b606 | ||
|
|
fbc68fa776 | ||
|
|
4ae34ea3ee | ||
|
|
96273fd75e | ||
|
|
3e63d405c1 | ||
|
|
19b42aac5d | ||
|
|
b67a23200e | ||
|
|
1dac02e4d6 | ||
|
|
acad5b1d08 | ||
|
|
4e9bb51d2f | ||
|
|
c0c8cdbbf3 | ||
|
|
cbdc611b54 | ||
|
|
93ca303b6c | ||
|
|
a925b424a8 | ||
|
|
5b4d423b58 | ||
|
|
6c1cbe120c | ||
|
|
77a58bc4b0 | ||
|
|
7d55a6d0e4 | ||
|
|
8ad63a6c25 | ||
|
|
acf9fa36f9 | ||
|
|
461154bb03 | ||
|
|
cd75461f9e | ||
|
|
2bac174e6f | ||
|
|
65f80f81ad | ||
|
|
450766a44b | ||
|
|
05e6e4bffb | ||
|
|
fbb66a4a5d | ||
|
|
d51d31a559 | ||
|
|
919ee51dca | ||
|
|
9c577ad9d5 | ||
|
|
953114041b | ||
|
|
d830c23dab | ||
|
|
fd3568c459 | ||
|
|
3029dcb2f6 | ||
|
|
35e03e1bca | ||
|
|
cea5b91f96 | ||
|
|
d2984db6e7 | ||
|
|
deb215ccd1 | ||
|
|
7173cf2184 | ||
|
|
0c697e123d | ||
|
|
edfa6d14ee | ||
|
|
b6d9ba93fa | ||
|
|
6293b95a3b | ||
|
|
ef4665cd8b | ||
|
|
8030e71a5a | ||
|
|
f42488d4cb | ||
|
|
af49ed4fdc | ||
|
|
b174a40634 | ||
|
|
3c01738c29 | ||
|
|
9be58f3eb4 | ||
|
|
a50c282d01 | ||
|
|
5141145e4d | ||
|
|
b5f6e5a598 | ||
|
|
7df308d655 | ||
|
|
f5ad51a35e | ||
|
|
f9d4105170 | ||
|
|
9e6ee50fa6 | ||
|
|
dd77ad5d74 | ||
|
|
3898c507c4 | ||
|
|
fcba50f041 | ||
|
|
452fc86ad1 | ||
|
|
5bdf411399 | ||
|
|
2d920f7ccc | ||
|
|
d84d51b475 | ||
|
|
f9d6f4f9da | ||
|
|
a13bd624e8 | ||
|
|
8fb019b2e2 | ||
|
|
2f3457e73d | ||
|
|
c6ebd6e73c | ||
|
|
2333a47c55 | ||
|
|
b35895b551 | ||
|
|
19c4ed4463 | ||
|
|
22aa1698b4 | ||
|
|
07d089a2bd | ||
|
|
870ad913cc | ||
|
|
3fb389551b | ||
|
|
d12a4adfb5 | ||
|
|
702e17c96b | ||
|
|
93ff7d26cc | ||
|
|
13777786c4 | ||
|
|
6655c64e55 | ||
|
|
7f3f6f1aaf | ||
|
|
ea04595c5e | ||
|
|
13c68bd810 | ||
|
|
1d2f44fba8 | ||
|
|
68702bfb1f | ||
|
|
e83f61e74d | ||
|
|
10d472e79e | ||
|
|
a6b920d9af | ||
|
|
b7892b58f5 | ||
|
|
77f037a3c4 | ||
|
|
248d27680d | ||
|
|
4c84182e7a | ||
|
|
e8581c8f3c | ||
|
|
fe4cba8baf | ||
|
|
9bbd7d3185 | ||
|
|
dbabb2c403 | ||
|
|
6c37d04591 | ||
|
|
649c5be64e | ||
|
|
fc0042a799 | ||
|
|
269d064e0a | ||
|
|
6c8143b7de | ||
|
|
f9f99639db | ||
|
|
6d5bf490ab | ||
|
|
46fc2a5012 | ||
|
|
90e7b5aecf | ||
|
|
ed20fd2962 | ||
|
|
4c3fd55a75 | ||
|
|
d95d509046 | ||
|
|
c15c852668 | ||
|
|
4a60512ae7 | ||
|
|
0e210cf8de | ||
|
|
35aa2c7270 | ||
|
|
518e0d90a5 | ||
|
|
51f7b02b27 | ||
|
|
23f2b6213c | ||
|
|
3a969054e3 | ||
|
|
4d1f9e49d4 | ||
|
|
702f5bd362 | ||
|
|
2474d5b6d2 | ||
|
|
9858d1f958 | ||
|
|
62efab987b | ||
|
|
c75d9e3de4 | ||
|
|
df222ded12 | ||
|
|
7dc0f81d3f | ||
|
|
23793e834d | ||
|
|
f4f3c6ad5a | ||
|
|
775794e0e4 | ||
|
|
212d15fdd0 | ||
|
|
065f015f7b | ||
|
|
b5ba05dd83 | ||
|
|
e4fda6cacf | ||
|
|
accb526cd6 | ||
|
|
2f0d94a46b | ||
|
|
8dc24403d8 | ||
|
|
a8c70d84a9 | ||
|
|
10d7a64f88 | ||
|
|
d51bbb4a81 | ||
|
|
848f794149 | ||
|
|
7f1b44befe | ||
|
|
9ddd5a0566 | ||
|
|
a3b664763e | ||
|
|
fd47bc1dc3 | ||
|
|
dfaafe3adb | ||
|
|
b4dc4d34eb | ||
|
|
3ae8ec1af6 | ||
|
|
5c34666334 | ||
|
|
212605a7e3 | ||
|
|
4ddfa9af8d | ||
|
|
36a0c7b8a3 | ||
|
|
9e1e0a7252 | ||
|
|
027e5adf67 | ||
|
|
e986088bec | ||
|
|
63ffd473d5 | ||
|
|
9e5d92dc58 | ||
|
|
8ac9141a29 | ||
|
|
313c942350 | ||
|
|
26c3edd023 | ||
|
|
9a5a3d4ce4 | ||
|
|
6e79b9a7a2 | ||
|
|
b32d82e6c1 | ||
|
|
a3585685df | ||
|
|
f379865e2c | ||
|
|
4eb4c31438 | ||
|
|
84a7afcd94 | ||
|
|
fa48ace39b | ||
|
|
1b869d9305 | ||
|
|
93bc2f5870 | ||
|
|
37c0cfe1e9 | ||
|
|
fc27441561 | ||
|
|
79cfbac11f | ||
|
|
df62736ff6 | ||
|
|
6a464b3e5f | ||
|
|
57fcda80df | ||
|
|
db39fbc419 | ||
|
|
3dabe47c78 | ||
|
|
affc194cde | ||
|
|
03fa580a55 | ||
|
|
d0dce654bf | ||
|
|
169323e238 | ||
|
|
71df415b14 | ||
|
|
6bb01bc564 | ||
|
|
07c6fe5975 | ||
|
|
5964181cb2 | ||
|
|
4b8288a2b2 | ||
|
|
88b1c1c6a5 | ||
|
|
1234deabfa | ||
|
|
ebaeb5a0d5 | ||
|
|
45306bbb6c | ||
|
|
18e2403b01 | ||
|
|
e578c5f3ad | ||
|
|
61245e3d7e | ||
|
|
7804182d0d | ||
|
|
f2195154f6 | ||
|
|
35f77f45a2 | ||
|
|
992c3a5d3a | ||
|
|
d51d7b6797 | ||
|
|
23ac2efd89 | ||
|
|
daeffb2dc6 | ||
|
|
db58ca6c1d | ||
|
|
2ff292cbfa | ||
|
|
5a81393863 | ||
|
|
116a73d398 | ||
|
|
cf0c057164 | ||
|
|
fe5a4f4447 | ||
|
|
c1b74201e4 | ||
|
|
27828d9ca8 | ||
|
|
2bd799fac6 | ||
|
|
9275f2d753 | ||
|
|
7455978ee5 | ||
|
|
7c0acc7b77 | ||
|
|
f32dd69acf | ||
|
|
80b8f956a9 | ||
|
|
caf50b6e6c | ||
|
|
b590d0857c | ||
|
|
982019307c | ||
|
|
09aec7b22e | ||
|
|
f9a047aad4 | ||
|
|
85704570f3 | ||
|
|
53dcae9e9c | ||
|
|
04e1ab63bb | ||
|
|
ed9aae531e | ||
|
|
6ab6b3dbca | ||
|
|
7180ed9a60 | ||
|
|
0a5522d28c | ||
|
|
c7bc93b32b | ||
|
|
d30351e7b0 | ||
|
|
886ffc0af8 | ||
|
|
4fdd997108 | ||
|
|
236736deea | ||
|
|
2b317f60c8 | ||
|
|
3ec67f9f47 | ||
|
|
6435e7a30e | ||
|
|
078305f5ac | ||
|
|
801b62543a | ||
|
|
877668b629 | ||
|
|
f652f73260 | ||
|
|
b2965e1deb | ||
|
|
2214689920 | ||
|
|
9326ff9d08 | ||
|
|
271f58d9cf | ||
|
|
cac99e3908 | ||
|
|
97a4a910e0 | ||
|
|
19c7a84548 | ||
|
|
571ce11e53 | ||
|
|
d2cb984ced | ||
|
|
7fc0d11931 | ||
|
|
341a52a615 | ||
|
|
d58b99d602 | ||
|
|
f7a5f836db | ||
|
|
d212df8b95 | ||
|
|
f3f6dc57c3 | ||
|
|
29b5cd9436 | ||
|
|
f5209fc344 | ||
|
|
c5168c2132 | ||
|
|
318e0989a2 | ||
|
|
d8b1781d7b | ||
|
|
ed5aea0521 | ||
|
|
e9f90a4d82 | ||
|
|
f86b220c92 | ||
|
|
b6bb1673d4 | ||
|
|
93f1762e6c | ||
|
|
2f410fc09f | ||
|
|
7b6fe66f2a | ||
|
|
c2fc0b4979 | ||
|
|
0b758941a4 | ||
|
|
492b55c893 | ||
|
|
4060e367ad | ||
|
|
c99cd31b6b | ||
|
|
718782f5b1 | ||
|
|
0c3fb5b2ce | ||
|
|
4ec6b067e7 | ||
|
|
1748dd6a3b | ||
|
|
95332e50ed | ||
|
|
56eb9d1430 | ||
|
|
8496980cf9 | ||
|
|
ffe32694b0 | ||
|
|
3d5b21154b | ||
|
|
4b9697e336 | ||
|
|
b0e9a542ba | ||
|
|
8b67536c23 | ||
|
|
cd49c12181 | ||
|
|
a6b14c7910 | ||
|
|
e275abdb9c | ||
|
|
09a90665d5 | ||
|
|
6649fbdfd0 | ||
|
|
64a0ffee7b | ||
|
|
b529118f31 | ||
|
|
39d7d9f13a | ||
|
|
fcd55df969 | ||
|
|
1e59948358 | ||
|
|
1102ef6e6b | ||
|
|
7ce2e8f4c4 | ||
|
|
fd1c656bdd | ||
|
|
82298a760a | ||
|
|
b84bb72e07 | ||
|
|
495b321d0a | ||
|
|
8a38cdc1d7 | ||
|
|
e210db7bd9 | ||
|
|
f64763b74b | ||
|
|
e033bef544 | ||
|
|
d88cc575e5 | ||
|
|
e3f499be0c | ||
|
|
87325fad74 | ||
|
|
86220573b6 | ||
|
|
65ed6b02a4 | ||
|
|
98093a1f31 | ||
|
|
00990dc195 | ||
|
|
122aa94c9f | ||
|
|
3da5284a07 | ||
|
|
cd920364f8 | ||
|
|
e2e8a45104 | ||
|
|
fb5fc13f72 | ||
|
|
edb92f7bfb | ||
|
|
1980f43b9f | ||
|
|
c3c3dd5154 | ||
|
|
356155fd30 | ||
|
|
6f75ef8f0a | ||
|
|
ca865a80dc | ||
|
|
8f759d1c3e | ||
|
|
44787637f2 | ||
|
|
cf1c8e8f2a | ||
|
|
d948be2372 | ||
|
|
cc28aef625 | ||
|
|
036358de7c | ||
|
|
0958b9ee12 | ||
|
|
aff1d7ecd6 | ||
|
|
42fdbd9bb8 | ||
|
|
034c82e514 | ||
|
|
14ff46b5cd | ||
|
|
c9099ca0a5 | ||
|
|
58b144b345 | ||
|
|
af21c57e77 | ||
|
|
624e4dbaaf | ||
|
|
9bbb4f396b | ||
|
|
1287e39cc6 | ||
|
|
1ef2aa35e9 | ||
|
|
b2c1644d69 | ||
|
|
5629f842da | ||
|
|
f900283b09 | ||
|
|
b667eff6bd | ||
|
|
54fdf40f5a | ||
|
|
690542145d | ||
|
|
94c4cf0624 | ||
|
|
3da717d9fc | ||
|
|
0902efc719 | ||
|
|
d7e2ee63d8 | ||
|
|
7deb36ee1f | ||
|
|
bfe4e88246 | ||
|
|
9ab45c3969 | ||
|
|
fec80c6c51 | ||
|
|
a6b7432358 | ||
|
|
3486954e07 | ||
|
|
150fc84b9b | ||
|
|
b023a00445 | ||
|
|
d0e296adf8 | ||
|
|
aa40015e9b | ||
|
|
141ce2c99a | ||
|
|
4a95dcb6e9 | ||
|
|
1610675c8f | ||
|
|
724c814bfe | ||
|
|
764c0cb865 | ||
|
|
8a4b8a84d6 | ||
|
|
8ec6acc55a | ||
|
|
b6a022b0ef | ||
|
|
716899c030 | ||
|
|
d9e407fd2b | ||
|
|
deb140de73 | ||
|
|
3c1e5e7978 | ||
|
|
4a8e85c28a | ||
|
|
8498cadae8 | ||
|
|
8c83fe23a1 | ||
|
|
a8c65e3d27 | ||
|
|
324d30bef9 | ||
|
|
46cb48023e | ||
|
|
1c24ca58c7 | ||
|
|
9193a9a0e0 | ||
|
|
957244ba2e | ||
|
|
ac599aa47c | ||
|
|
67a90ffb76 | ||
|
|
feaa6f9bf0 | ||
|
|
753bf3b924 | ||
|
|
b3219f57c8 | ||
|
|
a17df037af | ||
|
|
dfc36e5210 | ||
|
|
c359b92ddc | ||
|
|
e1d6131f13 | ||
|
|
6a0bda00f5 | ||
|
|
f85ec95877 | ||
|
|
a024980c03 | ||
|
|
fd9e94e078 | ||
|
|
f6a6c51d15 | ||
|
|
966db1e4be | ||
|
|
b8bbc37b8e | ||
|
|
40cbabc330 | ||
|
|
04a4e1b39a | ||
|
|
99f3160aa2 | ||
|
|
8cb72d8452 | ||
|
|
c9eb9f3eda | ||
|
|
64c3dcd732 | ||
|
|
d49ececcc5 | ||
|
|
90e1fadb1e | ||
|
|
071391ddff | ||
|
|
d70d46b4d5 | ||
|
|
3ef596b215 | ||
|
|
35c5518668 | ||
|
|
8b513537b7 | ||
|
|
b27f394995 | ||
|
|
3f9f556e1c | ||
|
|
1772d5c4b6 | ||
|
|
715d1dc02f | ||
|
|
6737f016f5 | ||
|
|
f2d2622172 | ||
|
|
72d6f97024 | ||
|
|
a0f0b4ff9e | ||
|
|
c27ef6ffbf | ||
|
|
f5499ff699 | ||
|
|
c4334d4e5f | ||
|
|
51e8f0440d | ||
|
|
5ec0311f84 | ||
|
|
556d563ba0 | ||
|
|
6a083b24c4 | ||
|
|
825929fdc8 | ||
|
|
941a03ed6c | ||
|
|
cf63619182 | ||
|
|
5c04d3c5ea | ||
|
|
46a47db2d8 | ||
|
|
21ef9a4567 | ||
|
|
6f0846b2af | ||
|
|
ecd78b3bdd | ||
|
|
d8afd1af88 | ||
|
|
7c1bc1f1a1 | ||
|
|
763fc89b29 | ||
|
|
47b33f2b17 | ||
|
|
9f0e16b045 | ||
|
|
2efedb1736 | ||
|
|
044116c14c | ||
|
|
b4bf11d648 | ||
|
|
6cc0a5a1a4 | ||
|
|
8f14de5108 | ||
|
|
8f6e5d73a2 | ||
|
|
ab9f5382b2 | ||
|
|
fd441d9303 | ||
|
|
e31bec3aff | ||
|
|
2a1c05a028 | ||
|
|
421bf33c0e | ||
|
|
9c7bacc65e | ||
|
|
3935c725c9 | ||
|
|
908ee0060f | ||
|
|
82e6fd7bb5 | ||
|
|
6b98b14179 | ||
|
|
1ecefd88f7 | ||
|
|
2e9e20ce7c | ||
|
|
fb60fbb217 | ||
|
|
4199e17da0 | ||
|
|
86b8bfcb1f | ||
|
|
dfd089132d | ||
|
|
3a10f58b28 | ||
|
|
9d55adbaf2 | ||
|
|
00be2be24f | ||
|
|
5b126c7e52 | ||
|
|
1943f3b53f | ||
|
|
4a0bef9afb | ||
|
|
dfd2a53129 | ||
|
|
aa4e855012 | ||
|
|
d6089e6309 | ||
|
|
038e6df8f0 | ||
|
|
2fd68bcac3 | ||
|
|
e468fecf12 | ||
|
|
fc31d8e5d1 | ||
|
|
115f357a07 | ||
|
|
ac04a1cac8 | ||
|
|
87a286ef07 | ||
|
|
622d8a4edb | ||
|
|
b44086f0dc | ||
|
|
0236e13187 | ||
|
|
a3d4a7253f | ||
|
|
e079f1b31a | ||
|
|
9a78a72eb3 | ||
|
|
862c2e8810 | ||
|
|
12cad4c418 | ||
|
|
89b9d3a7f7 | ||
|
|
57831d4880 | ||
|
|
052004d70e | ||
|
|
a765237441 | ||
|
|
ac470a6d07 | ||
|
|
7237d33be3 | ||
|
|
1610b480af | ||
|
|
287fa0a39c | ||
|
|
afa1a4303b | ||
|
|
28cedb1493 | ||
|
|
a280e25ee7 | ||
|
|
8464ca8931 | ||
|
|
44340f277d | ||
|
|
74bf99f9ea | ||
|
|
9caf820758 | ||
|
|
26c2598f56 | ||
|
|
6d9abf261c | ||
|
|
b16d0185dd | ||
|
|
ca51c2e93d | ||
|
|
da254975cd | ||
|
|
86bae6be3a | ||
|
|
fc8c7ef18d | ||
|
|
f654629c6a | ||
|
|
d4a87c561a | ||
|
|
d8872d48b3 | ||
|
|
ed16c2c18d | ||
|
|
0478a6ce3b | ||
|
|
68b60e82ba | ||
|
|
8edc0989e2 | ||
|
|
fbf3551bbe | ||
|
|
238d3122c4 | ||
|
|
ee22fba448 | ||
|
|
f8a2a28bff | ||
|
|
b3cfaf1420 | ||
|
|
2e9f701bb7 | ||
|
|
9aabc4ad6a | ||
|
|
5dc731bc77 | ||
|
|
32d05c9855 | ||
|
|
5a0d0c0b75 | ||
|
|
17d4a8fb26 | ||
|
|
348c1a7d5f | ||
|
|
49151dabf5 | ||
|
|
eb7c7cdcb6 | ||
|
|
ec95292209 | ||
|
|
4b84fb328c | ||
|
|
47d27c1f41 | ||
|
|
5267ad46da | ||
|
|
94bc880b7f | ||
|
|
bab3e0bc9b | ||
|
|
b3a324b6f5 | ||
|
|
a1117cd4ee | ||
|
|
6ece818d69 | ||
|
|
5df09d5e2a | ||
|
|
33450ce429 | ||
|
|
e2f0206d88 | ||
|
|
3767b2c7f9 | ||
|
|
1779f1f3da | ||
|
|
b9d1dca65d | ||
|
|
8e4d26163a | ||
|
|
53c1176cbf | ||
|
|
46d3e7884b | ||
|
|
a0290b0c1b | ||
|
|
b4ae706914 | ||
|
|
476bdac717 | ||
|
|
831627268d | ||
|
|
9b97dca601 | ||
|
|
4ea8c0802a | ||
|
|
9203870df5 | ||
|
|
e8088d6e38 | ||
|
|
59d9bcdd27 | ||
|
|
9d1b13ba73 | ||
|
|
dd1030139b | ||
|
|
30ca2117bb | ||
|
|
89024a8dc8 | ||
|
|
728c38396a | ||
|
|
d61cb98ac7 | ||
|
|
a7ceb61e27 | ||
|
|
74b915a790 | ||
|
|
01ea690421 | ||
|
|
17cc9284a0 | ||
|
|
498d0f0b8b | ||
|
|
89049e1a22 | ||
|
|
5e7254e8dc | ||
|
|
f8c2732fdc | ||
|
|
fec36eb298 | ||
|
|
2299a4156d | ||
|
|
32b82b9cb3 | ||
|
|
ba6039fc8b | ||
|
|
6885812d21 | ||
|
|
844025ec14 | ||
|
|
94bc91c554 | ||
|
|
044c16da4c | ||
|
|
cd4784c54a | ||
|
|
814aaa4a69 | ||
|
|
e3b3a4fefa | ||
|
|
3fcbb3010d | ||
|
|
7573a19dc9 | ||
|
|
3628d68d9a | ||
|
|
23872086fa | ||
|
|
bb349a03da | ||
|
|
82be426f78 | ||
|
|
9d2a633f5e | ||
|
|
1149d45589 | ||
|
|
9d7e19cebf | ||
|
|
b3023543d6 | ||
|
|
c229d2c3ce | ||
|
|
47ea383ddd | ||
|
|
f2a35f1114 | ||
|
|
147fc9a35a | ||
|
|
93a03f8fe4 | ||
|
|
230e3823a9 | ||
|
|
b14a0f24ae | ||
|
|
5295802720 | ||
|
|
fadd7f6eb4 | ||
|
|
011b76e4e7 | ||
|
|
f68cd2c5c0 | ||
|
|
6ac9789a1c | ||
|
|
2b0153807c | ||
|
|
34ab37f31e | ||
|
|
71af2628eb | ||
|
|
15f028abfb | ||
|
|
9bdd37bb63 | ||
|
|
d726c71141 | ||
|
|
1caa61f4c0 | ||
|
|
f3e3f08377 | ||
|
|
2ec8b7a804 | ||
|
|
9f7d137b05 | ||
|
|
7218f13783 | ||
|
|
fa31e7802c | ||
|
|
9b3b4494ba | ||
|
|
785d3748e1 | ||
|
|
5e0657ce55 | ||
|
|
700b06f9c5 | ||
|
|
b58bbf8eb4 | ||
|
|
2d1f522aaf | ||
|
|
0b2863dfab | ||
|
|
70907ead8a | ||
|
|
6dc4844c12 | ||
|
|
14bc1b6aac | ||
|
|
183ad2a34b | ||
|
|
d9758be3ae | ||
|
|
6b1b530443 | ||
|
|
1c20137b0e | ||
|
|
c4a6c933f8 | ||
|
|
31d9444264 | ||
|
|
8cb204e22e | ||
|
|
97aa72ec5b | ||
|
|
a68341eae6 | ||
|
|
aa08183439 | ||
|
|
7a5596b909 | ||
|
|
b9ffd50992 | ||
|
|
14f2a8f370 | ||
|
|
e7b16bfbc0 | ||
|
|
a16725ac17 | ||
|
|
2803a91673 | ||
|
|
cf2fce7666 | ||
|
|
1609abd166 | ||
|
|
88c74ae18d | ||
|
|
78e2b41e0c | ||
|
|
501f8b028b | ||
|
|
54401162bd | ||
|
|
7fde9327a2 | ||
|
|
bbbf59c74a | ||
|
|
c4ad66f745 | ||
|
|
69974d5651 | ||
|
|
ce3b6a04c2 | ||
|
|
37e2517dac | ||
|
|
d65ddead11 | ||
|
|
34034be0e3 | ||
|
|
d21481173e | ||
|
|
fa6ebadc7b | ||
|
|
a51fb24f36 | ||
|
|
c359b30763 | ||
|
|
95e3b156c0 | ||
|
|
b972a0d081 | ||
|
|
20749355da | ||
|
|
dad122199a | ||
|
|
9fb8fbcc65 | ||
|
|
78e7ea72dc | ||
|
|
4640060891 | ||
|
|
6efe4fb734 | ||
|
|
74986803db | ||
|
|
9b0a705055 | ||
|
|
163fc9e3a3 | ||
|
|
24bf7950d8 | ||
|
|
b6735bffe4 | ||
|
|
1d8fd480ca | ||
|
|
da2e2372aa | ||
|
|
f3b972e573 | ||
|
|
bf3bc3c7e9 | ||
|
|
38664487a0 | ||
|
|
de1111286c | ||
|
|
d89a12aa05 | ||
|
|
754acd7c26 | ||
|
|
c3e2f3b714 | ||
|
|
22ef3d3a46 | ||
|
|
7f3516f44f | ||
|
|
bfdb47a7ed | ||
|
|
f55f04ab4f | ||
|
|
01c9dbc1fd | ||
|
|
0aa807df19 | ||
|
|
48d44ece58 | ||
|
|
e58cb2b0db | ||
|
|
bffd9d9173 | ||
|
|
8688842984 | ||
|
|
cf29a8f2c8 | ||
|
|
1e00c89988 | ||
|
|
0eccb547b5 | ||
|
|
4789a7f6a9 | ||
|
|
0bf758afd4 | ||
|
|
6612550c06 | ||
|
|
d411159124 | ||
|
|
cf635a5e6f | ||
|
|
3a007e4f3d | ||
|
|
9faab960f6 | ||
|
|
5df8b1d183 | ||
|
|
ef5f910f19 | ||
|
|
fffbee80e8 | ||
|
|
6b30e167e1 | ||
|
|
8ec721259a | ||
|
|
9d7ce207b6 | ||
|
|
2d1f0c9f57 | ||
|
|
d3131d2f55 | ||
|
|
c10447df79 | ||
|
|
212ae76d76 | ||
|
|
cd48f7eff4 | ||
|
|
3513c6801e | ||
|
|
864529cbf6 | ||
|
|
58c0d3e12d | ||
|
|
a1493bfb4e | ||
|
|
b3e856df1d | ||
|
|
8ef2617eec | ||
|
|
1da7d81122 | ||
|
|
a103582346 | ||
|
|
7b61d05e88 | ||
|
|
6fc7c50f19 | ||
|
|
9d728ec3c5 | ||
|
|
9cd3358e4e | ||
|
|
4cd94370e8 | ||
|
|
52312dbd23 | ||
|
|
b2e8a1eaa2 | ||
|
|
506c17a093 | ||
|
|
69642fba52 | ||
|
|
7d647c981f | ||
|
|
9aec3b714e | ||
|
|
dd4648ed9a | ||
|
|
1cd0beb231 | ||
|
|
c96e4b7966 | ||
|
|
b7aab3c102 | ||
|
|
fcb1a657e3 | ||
|
|
9b2cb1e1c3 | ||
|
|
fb8b8d28da | ||
|
|
ad80153bbb | ||
|
|
9564b261d5 | ||
|
|
1e2a662fa6 | ||
|
|
51f7daaeaf | ||
|
|
f742a7ec4e | ||
|
|
e2c0d2a07b | ||
|
|
d112dc41b2 | ||
|
|
2322851ac4 | ||
|
|
aa084ea09a | ||
|
|
6520f9b7eb | ||
|
|
fd8d0a1746 | ||
|
|
af3ebacee6 | ||
|
|
55d7014301 | ||
|
|
b72d7fbeda | ||
|
|
ee15c14049 | ||
|
|
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 | ||
|
|
4d6b981a54 | ||
|
|
a4e4286e04 | ||
|
|
6dd7a6a171 | ||
|
|
8e554a87b0 | ||
|
|
f1b4c083a4 | ||
|
|
90af4e3b77 | ||
|
|
e8d76a513d | ||
|
|
29e03b88c7 | ||
|
|
ebbd870150 | ||
|
|
5bf402710f | ||
|
|
c0c54e5709 | ||
|
|
3ba984d09e | ||
|
|
f274683d46 | ||
|
|
2053db4cfc | ||
|
|
e20ce8e335 | ||
|
|
9fd750511c | ||
|
|
028957fcdc | ||
|
|
a4c54cae60 | ||
|
|
754303e7c7 | ||
|
|
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 | ||
|
|
081d84f848 | ||
|
|
e381add944 | ||
|
|
75d4eca722 | ||
|
|
56904ad6b2 | ||
|
|
b5ef552c25 | ||
|
|
cbabb9392c | ||
|
|
531b3dcf9e | ||
|
|
d975daf3f0 | ||
|
|
6137d551fe | ||
|
|
cf625e3542 | ||
|
|
e354fca4a4 | ||
|
|
129e7afc16 | ||
|
|
e83e0f6a33 | ||
|
|
cf4f928b25 | ||
|
|
e4d955e3f9 | ||
|
|
13576087f4 | ||
|
|
e74f6f3183 | ||
|
|
8302d1d3c1 | ||
|
|
b7320e6834 | ||
|
|
3193fe0235 | ||
|
|
7c2fa9f8a4 | ||
|
|
e5f6133127 | ||
|
|
0198c5b781 | ||
|
|
f535073ae4 | ||
|
|
b9895ecadd | ||
|
|
322eb66fdf | ||
|
|
f0abdc80eb | ||
|
|
70a4f94c81 | ||
|
|
f7e4b36746 | ||
|
|
4e6b71ace5 | ||
|
|
c916cd1a87 | ||
|
|
442a529a72 | ||
|
|
e1243f3d59 | ||
|
|
62f8cd1db6 | ||
|
|
af5f67d459 | ||
|
|
13424a893e | ||
|
|
30473ec41e | ||
|
|
24d382c70d | ||
|
|
3ddedc903e | ||
|
|
e3c279b8be | ||
|
|
d909b676c5 | ||
|
|
08a8ac9971 | ||
|
|
7073fd2f3b | ||
|
|
a9f67a48a1 | ||
|
|
fd058cc693 | ||
|
|
c98df33003 | ||
|
|
3f8b14f5f8 | ||
|
|
1c335a68e0 | ||
|
|
fb98050d9f | ||
|
|
91cb7aa13d | ||
|
|
ab0f7cc0c9 | ||
|
|
b51f7f9a25 | ||
|
|
d275e32e70 | ||
|
|
22cf78d506 | ||
|
|
c7c318b31e | ||
|
|
a4d012828c | ||
|
|
72cfebd2bc | ||
|
|
a832cfb343 | ||
|
|
9ef680db57 | ||
|
|
2c09f4ef90 | ||
|
|
c44454bf74 | ||
|
|
ad1b9b7f6d | ||
|
|
15e063e1b5 | ||
|
|
c00a63e4c3 | ||
|
|
dbda27b1d6 | ||
|
|
9eef879dbe | ||
|
|
cb29423636 | ||
|
|
d754eecf07 | ||
|
|
1e308782ee | ||
|
|
69139cd85b | ||
|
|
f59235bd5a | ||
|
|
2930ba0457 | ||
|
|
1513881eed | ||
|
|
5e361f6748 | ||
|
|
b182543a21 | ||
|
|
cbce8b444f | ||
|
|
38e92cfc62 | ||
|
|
a7764dc6d5 | ||
|
|
c74552c4c5 | ||
|
|
7a0b437626 | ||
|
|
c7e9f13d2e | ||
|
|
51e816ad6d | ||
|
|
dd047fd58f | ||
|
|
300f023d2a | ||
|
|
a282a80a6e | ||
|
|
e668e17655 | ||
|
|
a913d9728c | ||
|
|
15862d393b | ||
|
|
48a6cdd50a | ||
|
|
09b05cde7f | ||
|
|
da294038a8 | ||
|
|
332f3fb8e8 | ||
|
|
8e4743e719 | ||
|
|
4a663e539a | ||
|
|
98ac7ee277 | ||
|
|
28c457730a | ||
|
|
9b61fe9df2 | ||
|
|
b55b01cb13 | ||
|
|
90d8f3117f | ||
|
|
c7e976c8c5 | ||
|
|
6014b765f4 | ||
|
|
ca295588c4 | ||
|
|
437334355f | ||
|
|
3432d4df29 | ||
|
|
05fcb96181 | ||
|
|
6bb27f90a4 | ||
|
|
c10e8382a9 | ||
|
|
6653a31eb7 | ||
|
|
42561d04de | ||
|
|
9a285ab935 | ||
|
|
81771568be | ||
|
|
795b43f174 | ||
|
|
52203b50eb | ||
|
|
0373b2c9dd | ||
|
|
fe2c1c4ec6 | ||
|
|
596b6542e8 | ||
|
|
6897bf1254 | ||
|
|
8a6a13e583 | ||
|
|
b718285125 | ||
|
|
e5ab918ef9 | ||
|
|
6c6a2d08db | ||
|
|
9e6617e3ca | ||
|
|
94a50f92e1 | ||
|
|
5c8be2a8f6 | ||
|
|
1dcf2d80b4 | ||
|
|
1197521921 | ||
|
|
3863cfe786 | ||
|
|
54bd07702c | ||
|
|
a75e2b0c0e | ||
|
|
29fd9b23fe | ||
|
|
089e3b8946 | ||
|
|
9c36fcec81 | ||
|
|
74fa065266 | ||
|
|
38f2495cf6 | ||
|
|
4131fccbe0 | ||
|
|
f2d748cfe4 | ||
|
|
197ec0c29c | ||
|
|
0a2af9335c | ||
|
|
a52fa28ed1 | ||
|
|
78ed24dbf6 | ||
|
|
cda074fe24 | ||
|
|
823032617d | ||
|
|
5963459499 | ||
|
|
0bc2c71b0c | ||
|
|
b4e350e189 | ||
|
|
941e46490f | ||
|
|
4df92e903a | ||
|
|
6ba02d0d50 | ||
|
|
2dc122831b | ||
|
|
f3f84e523a | ||
|
|
0cdee25b5b | ||
|
|
92b0314c14 | ||
|
|
ad2bc7da96 | ||
|
|
d8b606dc83 | ||
|
|
5ce53dbcf4 | ||
|
|
c5c1a9ab3c | ||
|
|
564709aa98 | ||
|
|
475158a145 | ||
|
|
829df56733 | ||
|
|
ee55f8790e | ||
|
|
6d19fb3909 | ||
|
|
9057712c8f | ||
|
|
5d6e7de667 | ||
|
|
8f6f70879c | ||
|
|
3120087992 | ||
|
|
0ec4cc223f | ||
|
|
60c7be31b6 | ||
|
|
2388f853c9 | ||
|
|
1e8d4763bb | ||
|
|
be4834688d | ||
|
|
3937dad6a6 | ||
|
|
463251dcc1 | ||
|
|
3adca26808 | ||
|
|
97a8bb52d6 | ||
|
|
7748a980f9 | ||
|
|
b044e274aa | ||
|
|
6c3d4a11cc | ||
|
|
98afd5516b | ||
|
|
ea6926cad3 | ||
|
|
3298961748 | ||
|
|
0140f771c6 | ||
|
|
64c4f512ce | ||
|
|
5b1d45c1a9 | ||
|
|
efbd1c15a9 | ||
|
|
bce74890dc | ||
|
|
eb93a43d13 | ||
|
|
1dd75b63de | ||
|
|
6caf79121b | ||
|
|
5cdd34a388 | ||
|
|
38c8ee8cd2 | ||
|
|
d5c33a1183 | ||
|
|
058e28911a | ||
|
|
25a3bb351d | ||
|
|
1607640bed | ||
|
|
5f0cda829f | ||
|
|
b003a374b8 | ||
|
|
5f7c262759 | ||
|
|
f3ab6b27c9 | ||
|
|
d62eb56886 | ||
|
|
c2724c6aad | ||
|
|
0cde1683b6 | ||
|
|
128f596f93 | ||
|
|
27fae8eed0 | ||
|
|
82ec4474c2 | ||
|
|
0d85dae239 | ||
|
|
7893693706 | ||
|
|
25ce6af36e | ||
|
|
c0269254e8 | ||
|
|
2f2aefd48e | ||
|
|
c05de45d99 | ||
|
|
8f66da1128 | ||
|
|
b5eaa8272b | ||
|
|
7ee062e1de | ||
|
|
8915af9b6d | ||
|
|
ae1ef3215b | ||
|
|
5d06fa217c | ||
|
|
50b1f7db12 | ||
|
|
fb82806956 | ||
|
|
0658a93962 | ||
|
|
f33be37070 | ||
|
|
9278d7f851 | ||
|
|
35ba898c97 | ||
|
|
15f8d13d81 | ||
|
|
946e508e8f | ||
|
|
35b4125b98 | ||
|
|
91d8f9d73e | ||
|
|
c636993989 | ||
|
|
65a24c16bc | ||
|
|
15ce114440 | ||
|
|
1722f75dcb | ||
|
|
be597a551d | ||
|
|
56bc945335 | ||
|
|
fa9ceb5875 | ||
|
|
0ccf5a16c7 | ||
|
|
93c666b03d | ||
|
|
d5c28b506c | ||
|
|
3318f01aa1 | ||
|
|
1641019091 | ||
|
|
3572969866 | ||
|
|
ad1fe8762b | ||
|
|
c9c3e32a78 | ||
|
|
f4c99c9cf7 | ||
|
|
57447c24c8 |
14
.babelrc
Normal file
14
.babelrc
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"next/babel",
|
||||||
|
{
|
||||||
|
"preset-env": {
|
||||||
|
"targets": {
|
||||||
|
"browsers": ["> 0.25%, not dead"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
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
|
||||||
@@ -1,13 +1,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. (optional)
|
||||||
PROXY_URL=http://localhost:7890
|
PROXY_URL=http://localhost:7890
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: Empty
|
||||||
|
# Google Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: https://generativelanguage.googleapis.com/
|
||||||
|
# Google 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,9 +24,13 @@ 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 use GPT-4, set this value to 1.
|
||||||
|
DISABLE_GPT4=
|
||||||
|
|
||||||
# (optional)
|
# (optional)
|
||||||
# Default: Empty
|
# Default: Empty
|
||||||
# If you do not want users to input their own API key, set this value to 1.
|
# If you do not want users to input their own API key, set this value to 1.
|
||||||
@@ -25,5 +38,32 @@ 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 want users to query balance, set this value to 1.
|
||||||
DISABLE_GPT4=
|
ENABLE_BALANCE_QUERY=
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: Empty
|
||||||
|
# If you want to disable parse settings from url, set this value to 1.
|
||||||
|
DISABLE_FAST_LINK=
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: Empty
|
||||||
|
# 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.
|
||||||
|
CUSTOM_MODELS=
|
||||||
|
|
||||||
|
# (optional)
|
||||||
|
# Default: Empty
|
||||||
|
# Change default model
|
||||||
|
DEFAULT_MODEL=
|
||||||
|
|
||||||
|
# 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_WEBDAV_ENDPOINTS=
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"plugins": ["prettier"]
|
"plugins": ["prettier", "unused-imports"],
|
||||||
|
"rules": {
|
||||||
|
"unused-imports/no-unused-imports": "warn"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
80
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
name: '🐛 Bug Report'
|
||||||
|
description: 'Report an bug'
|
||||||
|
title: '[Bug] '
|
||||||
|
labels: ['bug']
|
||||||
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: '📦 Deployment Method'
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Official installation package'
|
||||||
|
- 'Vercel'
|
||||||
|
- 'Zeabur'
|
||||||
|
- 'Sealos'
|
||||||
|
- 'Netlify'
|
||||||
|
- 'Docker'
|
||||||
|
- 'Other'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: '📌 Version'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: '💻 Operating System'
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Windows'
|
||||||
|
- 'macOS'
|
||||||
|
- 'Ubuntu'
|
||||||
|
- 'Other Linux'
|
||||||
|
- 'iOS'
|
||||||
|
- 'iPad OS'
|
||||||
|
- 'Android'
|
||||||
|
- 'Other'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: '📌 System Version'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: '🌐 Browser'
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Chrome'
|
||||||
|
- 'Edge'
|
||||||
|
- 'Safari'
|
||||||
|
- 'Firefox'
|
||||||
|
- 'Other'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: '📌 Browser Version'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🐛 Bug Description'
|
||||||
|
description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '📷 Recurrence Steps'
|
||||||
|
description: A clear and concise description of how to recurrence.
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🚦 Expected Behavior'
|
||||||
|
description: A clear and concise description of what you expected to happen.
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '📝 Additional Information'
|
||||||
|
description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here.
|
||||||
80
.github/ISSUE_TEMPLATE/1_bug_report_cn.yml
vendored
Normal file
80
.github/ISSUE_TEMPLATE/1_bug_report_cn.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
name: '🐛 反馈缺陷'
|
||||||
|
description: '反馈一个问题/缺陷'
|
||||||
|
title: '[Bug] '
|
||||||
|
labels: ['bug']
|
||||||
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: '📦 部署方式'
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- '官方安装包'
|
||||||
|
- 'Vercel'
|
||||||
|
- 'Zeabur'
|
||||||
|
- 'Sealos'
|
||||||
|
- 'Netlify'
|
||||||
|
- 'Docker'
|
||||||
|
- 'Other'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: '📌 软件版本'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: '💻 系统环境'
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Windows'
|
||||||
|
- 'macOS'
|
||||||
|
- 'Ubuntu'
|
||||||
|
- 'Other Linux'
|
||||||
|
- 'iOS'
|
||||||
|
- 'iPad OS'
|
||||||
|
- 'Android'
|
||||||
|
- 'Other'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: '📌 系统版本'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: '🌐 浏览器'
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Chrome'
|
||||||
|
- 'Edge'
|
||||||
|
- 'Safari'
|
||||||
|
- 'Firefox'
|
||||||
|
- 'Other'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: '📌 浏览器版本'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🐛 问题描述'
|
||||||
|
description: 请提供一个清晰且简洁的问题描述,若上述选项为`Other`,也请详细说明。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '📷 复现步骤'
|
||||||
|
description: 请提供一个清晰且简洁的描述,说明如何复现问题。
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🚦 期望结果'
|
||||||
|
description: 请提供一个清晰且简洁的描述,说明您期望发生什么。
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '📝 补充信息'
|
||||||
|
description: 如果您的问题需要进一步说明,或者您遇到的问题无法在一个简单的示例中复现,请在这里添加更多信息。
|
||||||
21
.github/ISSUE_TEMPLATE/2_feature_request.yml
vendored
Normal file
21
.github/ISSUE_TEMPLATE/2_feature_request.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: '🌠 Feature Request'
|
||||||
|
description: 'Suggest an idea'
|
||||||
|
title: '[Feature Request] '
|
||||||
|
labels: ['enhancement']
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🥰 Feature Description'
|
||||||
|
description: Please add a clear and concise description of the problem you are seeking to solve with this feature request.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🧐 Proposed Solution'
|
||||||
|
description: Describe the solution you'd like in a clear and concise manner.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '📝 Additional Information'
|
||||||
|
description: Add any other context about the problem here.
|
||||||
21
.github/ISSUE_TEMPLATE/2_feature_request_cn.yml
vendored
Normal file
21
.github/ISSUE_TEMPLATE/2_feature_request_cn.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: '🌠 功能需求'
|
||||||
|
description: '提出需求或建议'
|
||||||
|
title: '[Feature Request] '
|
||||||
|
labels: ['enhancement']
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🥰 需求描述'
|
||||||
|
description: 请添加一个清晰且简洁的问题描述,阐述您希望通过这个功能需求解决的问题。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '🧐 解决方案'
|
||||||
|
description: 请清晰且简洁地描述您想要的解决方案。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '📝 补充信息'
|
||||||
|
description: 在这里添加关于问题的任何其他背景信息。
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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 或者服务器部署]
|
|
||||||
28
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
28
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#### 💻 变更类型 | Change Type
|
||||||
|
|
||||||
|
<!-- For change type, change [ ] to [x]. -->
|
||||||
|
|
||||||
|
- [ ] feat <!-- 引入新功能 | Introduce new features -->
|
||||||
|
- [ ] fix <!-- 修复 Bug | Fix a bug -->
|
||||||
|
- [ ] refactor <!-- 重构代码(既不修复 Bug 也不添加新功能) | Refactor code that neither fixes a bug nor adds a feature -->
|
||||||
|
- [ ] perf <!-- 提升性能的代码变更 | A code change that improves performance -->
|
||||||
|
- [ ] style <!-- 添加或更新不影响代码含义的样式文件 | Add or update style files that do not affect the meaning of the code -->
|
||||||
|
- [ ] test <!-- 添加缺失的测试或纠正现有的测试 | Adding missing tests or correcting existing tests -->
|
||||||
|
- [ ] docs <!-- 仅文档更新 | Documentation only changes -->
|
||||||
|
- [ ] ci <!-- 修改持续集成配置文件和脚本 | Changes to our CI configuration files and scripts -->
|
||||||
|
- [ ] chore <!-- 其他不修改 src 或 test 文件的变更 | Other changes that don’t modify src or test files -->
|
||||||
|
- [ ] build <!-- 进行架构变更 | Make architectural changes -->
|
||||||
|
|
||||||
|
#### 🔀 变更说明 | Description of Change
|
||||||
|
|
||||||
|
<!--
|
||||||
|
感谢您的 Pull Request ,请提供此 Pull Request 的变更说明
|
||||||
|
Thank you for your Pull Request. Please provide a description above.
|
||||||
|
-->
|
||||||
|
|
||||||
|
#### 📝 补充信息 | Additional Information
|
||||||
|
|
||||||
|
<!--
|
||||||
|
请添加与此 Pull Request 相关的补充信息
|
||||||
|
Add any other context about the Pull Request here.
|
||||||
|
-->
|
||||||
36
.github/workflows/app.yml
vendored
36
.github/workflows/app.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
create-release:
|
create-release:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
release_id: ${{ steps.create-release.outputs.result }}
|
release_id: ${{ steps.create-release.outputs.result }}
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -39,19 +39,34 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [macos-latest, ubuntu-20.04, windows-latest]
|
config:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
arch: x86_64
|
||||||
|
rust_target: x86_64-unknown-linux-gnu
|
||||||
|
- os: macos-latest
|
||||||
|
arch: aarch64
|
||||||
|
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||||
|
- os: windows-latest
|
||||||
|
arch: x86_64
|
||||||
|
rust_target: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.config.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- 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:
|
||||||
|
targets: ${{ matrix.config.rust_target }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
key: ${{ matrix.config.os }}
|
||||||
- name: install dependencies (ubuntu only)
|
- name: install dependencies (ubuntu only)
|
||||||
if: matrix.platform == 'ubuntu-20.04'
|
if: matrix.config.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
@@ -62,13 +77,20 @@ 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:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
needs: [create-release, build-tauri]
|
needs: [create-release, build-tauri]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
82
.github/workflows/deploy_preview.yml
vendored
Normal file
82
.github/workflows/deploy_preview.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: VercelPreviewDeployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- review_requested
|
||||||
|
|
||||||
|
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@v4
|
||||||
|
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
|
||||||
|
|||||||
39
.github/workflows/test.yml
vendored
Normal file
39
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Run Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "!*"
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- review_requested
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: "yarn"
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node_modules-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Run Jest tests
|
||||||
|
run: yarn test:ci
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ dev
|
|||||||
|
|
||||||
*.key
|
*.key
|
||||||
*.key.pub
|
*.key.pub
|
||||||
|
|
||||||
|
masks.json
|
||||||
|
|||||||
@@ -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,7 +43,7 @@ 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="0.0.0.0"; \
|
||||||
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); \
|
||||||
|
|||||||
88
LICENSE
88
LICENSE
@@ -1,75 +1,21 @@
|
|||||||
版权所有(c)<2023><Zhang Yifei>
|
MIT License
|
||||||
|
|
||||||
反996许可证版本1.0
|
Copyright (c) 2023-2024 Zhang Yifei
|
||||||
|
|
||||||
在符合下列条件的情况下,
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
特此免费向任何得到本授权作品的副本(包括源代码、文件和/或相关内容,以下统称为“授权作品”
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
)的个人和法人实体授权:被授权个人或法人实体有权以任何目的处置授权作品,包括但不限于使
|
in the Software without restriction, including without limitation the rights
|
||||||
用、复制,修改,衍生利用、散布,发布和再许可:
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
1. 个人或法人实体必须在许可作品的每个再散布或衍生副本上包含以上版权声明和本许可证,不
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
得自行修改。
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
2. 个人或法人实体必须严格遵守与个人实际所在地或个人出生地或归化地、或法人实体注册地或
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
经营地(以较严格者为准)的司法管辖区所有适用的与劳动和就业相关法律、法规、规则和
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
标准。如果该司法管辖区没有此类法律、法规、规章和标准或其法律、法规、规章和标准不可
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
执行,则个人或法人实体必须遵守国际劳工标准的核心公约。
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
3. 个人或法人不得以任何方式诱导或强迫其全职或兼职员工或其独立承包人以口头或书面形式同
|
SOFTWARE.
|
||||||
意直接或间接限制、削弱或放弃其所拥有的,受相关与劳动和就业有关的法律、法规、规则和
|
|
||||||
标准保护的权利或补救措施,无论该等书面或口头协议是否被该司法管辖区的法律所承认,该
|
|
||||||
等个人或法人实体也不得以任何方法限制其雇员或独立承包人向版权持有人或监督许可证合规
|
|
||||||
情况的有关当局报告或投诉上述违反许可证的行为的权利。
|
|
||||||
|
|
||||||
该授权作品是"按原样"提供,不做任何明示或暗示的保证,包括但不限于对适销性、特定用途适用
|
|
||||||
性和非侵权性的保证。在任何情况下,无论是在合同诉讼、侵权诉讼或其他诉讼中,版权持有人均
|
|
||||||
不承担因本软件或本软件的使用或其他交易而产生、引起或与之相关的任何索赔、损害或其他责任。
|
|
||||||
|
|
||||||
|
|
||||||
------------------------- ENGLISH ------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
Copyright (c) <2023> <Zhang Yifei>
|
|
||||||
|
|
||||||
Anti 996 License Version 1.0 (Draft)
|
|
||||||
|
|
||||||
Permission is hereby granted to any individual or legal entity obtaining a copy
|
|
||||||
of this licensed work (including the source code, documentation and/or related
|
|
||||||
items, hereinafter collectively referred to as the "licensed work"), free of
|
|
||||||
charge, to deal with the licensed work for any purpose, including without
|
|
||||||
limitation, the rights to use, reproduce, modify, prepare derivative works of,
|
|
||||||
publish, distribute and sublicense the licensed work, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
1. The individual or the legal entity must conspicuously display, without
|
|
||||||
modification, this License on each redistributed or derivative copy of the
|
|
||||||
Licensed Work.
|
|
||||||
|
|
||||||
2. The individual or the legal entity must strictly comply with all applicable
|
|
||||||
laws, regulations, rules and standards of the jurisdiction relating to
|
|
||||||
labor and employment where the individual is physically located or where
|
|
||||||
the individual was born or naturalized; or where the legal entity is
|
|
||||||
registered or is operating (whichever is stricter). In case that the
|
|
||||||
jurisdiction has no such laws, regulations, rules and standards or its
|
|
||||||
laws, regulations, rules and standards are unenforceable, the individual
|
|
||||||
or the legal entity are required to comply with Core International Labor
|
|
||||||
Standards.
|
|
||||||
|
|
||||||
3. The individual or the legal entity shall not induce or force its
|
|
||||||
employee(s), whether full-time or part-time, or its independent
|
|
||||||
contractor(s), in any methods, to agree in oral or written form,
|
|
||||||
to directly or indirectly restrict, weaken or relinquish his or
|
|
||||||
her rights or remedies under such laws, regulations, rules and
|
|
||||||
standards relating to labor and employment as mentioned above,
|
|
||||||
no matter whether such written or oral agreement are enforceable
|
|
||||||
under the laws of the said jurisdiction, nor shall such individual
|
|
||||||
or the legal entity limit, in any methods, the rights of its employee(s)
|
|
||||||
or independent contractor(s) from reporting or complaining to the copyright
|
|
||||||
holder or relevant authorities monitoring the compliance of the license
|
|
||||||
about its violation(s) of the said license.
|
|
||||||
|
|
||||||
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT
|
|
||||||
HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN ANY WAY CONNECTION
|
|
||||||
WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK.
|
|
||||||
|
|||||||
294
README.md
294
README.md
@@ -1,56 +1,89 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/images/icon.svg" alt="icon"/>
|
|
||||||
|
|
||||||
<h1 align="center">ChatGPT Next Web</h1>
|
<a href='#企业版'>
|
||||||
|
<img src="./docs/images/ent.svg" alt="icon"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<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 模型。
|
||||||
|
|
||||||
|
[![Saas][Saas-image]][saas-url]
|
||||||
[![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) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
[NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [Web App](https://app.nextchat.dev) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Enterprise Edition](#enterprise-edition) / [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)
|
[NextChatAI](https://nextchat.dev/chat) / [网页版](https://app.nextchat.dev) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
|
||||||
|
|
||||||
[web-url]: https://chatgpt.nextweb.fun
|
|
||||||
|
|
||||||
|
[saas-url]: https://nextchat.dev/chat?utm_source=readme
|
||||||
|
[saas-image]: https://img.shields.io/badge/NextChat-Saas-green?logo=microsoftedge
|
||||||
|
[web-url]: https://app.nextchat.dev/
|
||||||
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
|
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
|
||||||
|
|
||||||
[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge
|
[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge
|
||||||
|
|
||||||
[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows
|
[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows
|
||||||
|
|
||||||
[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)
|
[<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](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) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
[<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp)
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## Enterprise Edition
|
||||||
|
|
||||||
|
Meeting Your Company's Privatization and Customization Deployment Requirements:
|
||||||
|
- **Brand Customization**: Tailored VI/UI to seamlessly align with your corporate brand image.
|
||||||
|
- **Resource Integration**: Unified configuration and management of dozens of AI resources by company administrators, ready for use by team members.
|
||||||
|
- **Permission Control**: Clearly defined member permissions, resource permissions, and knowledge base permissions, all controlled via a corporate-grade Admin Panel.
|
||||||
|
- **Knowledge Integration**: Combining your internal knowledge base with AI capabilities, making it more relevant to your company's specific business needs compared to general AI.
|
||||||
|
- **Security Auditing**: Automatically intercept sensitive inquiries and trace all historical conversation records, ensuring AI adherence to corporate information security standards.
|
||||||
|
- **Private Deployment**: Enterprise-level private deployment supporting various mainstream private cloud solutions, ensuring data security and privacy protection.
|
||||||
|
- **Continuous Updates**: Ongoing updates and upgrades in cutting-edge capabilities like multimodal AI, ensuring consistent innovation and advancement.
|
||||||
|
|
||||||
|
For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||||
|
|
||||||
|
## 企业版
|
||||||
|
|
||||||
|
满足企业用户私有化部署和个性化定制需求:
|
||||||
|
- **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合
|
||||||
|
- **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用
|
||||||
|
- **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制
|
||||||
|
- **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求
|
||||||
|
- **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范
|
||||||
|
- **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护
|
||||||
|
- **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进
|
||||||
|
|
||||||
|
企业版咨询: **business@nextchat.dev**
|
||||||
|
|
||||||
|
<img width="300" src="https://github.com/user-attachments/assets/3d4305ac-6e95-489e-884b-51d51db5f692">
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -60,13 +93,21 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
|
|||||||
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||||
- [x] Desktop App with tauri
|
- [x] Desktop App with tauri
|
||||||
- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
|
- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
|
||||||
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||||
|
- [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||||
|
- [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||||
|
- [ ] local knowledge base
|
||||||
|
|
||||||
## 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.15.4 The Application supports using Tauri fetch LLM API, MORE SECURITY! [#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379)
|
||||||
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
|
- 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
|
||||||
|
- 🚀 v2.14.0 Now supports Artifacts & SD
|
||||||
|
- 🚀 v2.10.1 support Google Gemini Pro model.
|
||||||
|
- 🚀 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/).
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
@@ -79,7 +120,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
|
||||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
||||||
|
|
||||||
## 开发计划
|
## 开发计划
|
||||||
@@ -89,15 +130,23 @@ One-Click to get well-designed cross-platform ChatGPT web UI.
|
|||||||
- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
||||||
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||||
- [x] 使用 tauri 打包桌面应用
|
- [x] 使用 tauri 打包桌面应用
|
||||||
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等
|
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
|
||||||
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||||
|
- [x] 插件机制,支持`联网搜索`、`计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||||
|
- [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||||
|
- [ ] 本地知识库
|
||||||
|
|
||||||
## 最新动态
|
## 最新动态
|
||||||
|
|
||||||
|
- 🚀 v2.15.4 客户端支持Tauri本地直接调用大模型API,更安全
|
||||||
|
- 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
|
||||||
|
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
|
||||||
|
- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
|
||||||
|
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
||||||
|
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
||||||
|
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||||
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
||||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
||||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
|
||||||
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
@@ -128,7 +177,7 @@ We recommend that you follow the steps below to re-deploy:
|
|||||||
|
|
||||||
### Enable Automatic Updates
|
### Enable Automatic Updates
|
||||||
|
|
||||||
> If you encounter a failure of Upstream Sync execution, please manually sync fork once.
|
> If you encounter a failure of Upstream Sync execution, please [manually update code](./README.md#manually-updating-code).
|
||||||
|
|
||||||
After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
|
After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
|
||||||
|
|
||||||
@@ -140,7 +189,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
|
||||||
|
|
||||||
@@ -158,13 +207,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)
|
||||||
|
|
||||||
@@ -178,6 +227,80 @@ Override openai api request base url.
|
|||||||
|
|
||||||
Specify OpenAI organization ID.
|
Specify OpenAI organization ID.
|
||||||
|
|
||||||
|
### `AZURE_URL` (optional)
|
||||||
|
|
||||||
|
> Example: https://{azure-resource-url}/openai
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `BAIDU_API_KEY` (optional)
|
||||||
|
|
||||||
|
Baidu Api Key.
|
||||||
|
|
||||||
|
### `BAIDU_SECRET_KEY` (optional)
|
||||||
|
|
||||||
|
Baidu Secret Key.
|
||||||
|
|
||||||
|
### `BAIDU_URL` (optional)
|
||||||
|
|
||||||
|
Baidu Api Url.
|
||||||
|
|
||||||
|
### `BYTEDANCE_API_KEY` (optional)
|
||||||
|
|
||||||
|
ByteDance Api Key.
|
||||||
|
|
||||||
|
### `BYTEDANCE_URL` (optional)
|
||||||
|
|
||||||
|
ByteDance Api Url.
|
||||||
|
|
||||||
|
### `ALIBABA_API_KEY` (optional)
|
||||||
|
|
||||||
|
Alibaba Cloud Api Key.
|
||||||
|
|
||||||
|
### `ALIBABA_URL` (optional)
|
||||||
|
|
||||||
|
Alibaba Cloud Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_URL` (Optional)
|
||||||
|
|
||||||
|
iflytek Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_KEY` (Optional)
|
||||||
|
|
||||||
|
iflytek Api Key.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_SECRET` (Optional)
|
||||||
|
|
||||||
|
iflytek Api Secret.
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (optional)
|
### `HIDE_USER_API_KEY` (optional)
|
||||||
|
|
||||||
> Default: Empty
|
> Default: Empty
|
||||||
@@ -190,6 +313,61 @@ 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.
|
||||||
|
|
||||||
|
### `ENABLE_BALANCE_QUERY` (optional)
|
||||||
|
|
||||||
|
> Default: Empty
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
For Azure: use `modelName@Azure=deploymentName` to customize model name and deployment name.
|
||||||
|
> Example: `+gpt-3.5-turbo@Azure=gpt35` will show option `gpt35(Azure)` in model list.
|
||||||
|
> If you only can use Azure model, `-all,+gpt-3.5-turbo@Azure=gpt35` will `gpt35(Azure)` the only option in model list.
|
||||||
|
|
||||||
|
For ByteDance: use `modelName@bytedance=deploymentName` to customize model name and deployment name.
|
||||||
|
> Example: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` will show option `Doubao-lite-4k(ByteDance)` in model list.
|
||||||
|
|
||||||
|
### `DEFAULT_MODEL` (optional)
|
||||||
|
|
||||||
|
Change default model
|
||||||
|
|
||||||
|
### `WHITE_WEBDAV_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.
|
||||||
|
|
||||||
|
### `STABILITY_API_KEY` (optional)
|
||||||
|
|
||||||
|
Stability API key.
|
||||||
|
|
||||||
|
### `STABILITY_URL` (optional)
|
||||||
|
|
||||||
|
Customize Stability API url.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
NodeJS >= 18, Docker >= 20
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
> [简体中文 > 如何进行二次开发](./README_CN.md#开发)
|
> [简体中文 > 如何进行二次开发](./README_CN.md#开发)
|
||||||
@@ -225,8 +403,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -234,24 +412,48 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your proxy needs password, use:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
-e PROXY_URL="http://127.0.0.1:7890 user pass"
|
||||||
|
```
|
||||||
|
|
||||||
### Shell
|
### Shell
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Translation
|
||||||
|
|
||||||
|
If you want to add a new translation, read this [document](./docs/translation.md).
|
||||||
|
|
||||||
## Donation
|
## Donation
|
||||||
|
|
||||||
[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
||||||
@@ -283,11 +485,19 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s
|
|||||||
[@Sha1rholder](https://github.com/Sha1rholder)
|
[@Sha1rholder](https://github.com/Sha1rholder)
|
||||||
[@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)
|
||||||
|
[@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
|
||||||
|
|
||||||
[Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
[MIT](https://opensource.org/license/mit/)
|
||||||
|
|||||||
193
README_CN.md
193
README_CN.md
@@ -1,28 +1,48 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/images/icon.svg" alt="预览"/>
|
|
||||||
|
|
||||||
<h1 align="center">ChatGPT Next Web</h1>
|
<a href='#企业版'>
|
||||||
|
<img src="./docs/images/ent.svg" alt="icon"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
一键免费部署你的私人 ChatGPT 网页应用。
|
<h1 align="center">NextChat</h1>
|
||||||
|
|
||||||
[演示 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)
|
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||||
|
|
||||||
[](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)
|
[NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
|
||||||
|
|
||||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
[<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](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) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## 企业版
|
||||||
|
|
||||||
|
满足您公司私有化部署和定制需求
|
||||||
|
- **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合
|
||||||
|
- **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用
|
||||||
|
- **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制
|
||||||
|
- **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求
|
||||||
|
- **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范
|
||||||
|
- **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护
|
||||||
|
- **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进
|
||||||
|
|
||||||
|
企业版咨询: **business@nextchat.dev**
|
||||||
|
|
||||||
|
<img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601">
|
||||||
|
|
||||||
## 开始使用
|
## 开始使用
|
||||||
|
|
||||||
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 在某些区域被污染了,绑定自定义域名即可直连。
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## 保持更新
|
## 保持更新
|
||||||
|
|
||||||
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
||||||
@@ -34,7 +54,7 @@
|
|||||||
|
|
||||||
### 打开自动更新
|
### 打开自动更新
|
||||||
|
|
||||||
> 如果你遇到了 Upstream Sync 执行错误,请手动 Sync Fork 一次!
|
> 如果你遇到了 Upstream Sync 执行错误,请[手动 Sync Fork 一次](./README_CN.md#手动更新代码)!
|
||||||
|
|
||||||
当你 fork 项目之后,由于 Github 的限制,需要手动去你 fork 后的项目的 Actions 页面启用 Workflows,并启用 Upstream Sync Action,启用之后即可开启每小时定时自动更新:
|
当你 fork 项目之后,由于 Github 的限制,需要手动去你 fork 后的项目的 Actions 页面启用 Workflows,并启用 Upstream Sync Action,启用之后即可开启每小时定时自动更新:
|
||||||
|
|
||||||
@@ -68,7 +88,7 @@ code1,code2,code3
|
|||||||
|
|
||||||
### `OPENAI_API_KEY` (必填项)
|
### `OPENAI_API_KEY` (必填项)
|
||||||
|
|
||||||
OpanAI 密钥,你在 openai 账户页面申请的 api key。
|
OpanAI 密钥,你在 openai 账户页面申请的 api key,使用英文逗号隔开多个 key,这样可以随机轮询这些 key。
|
||||||
|
|
||||||
### `CODE` (可选)
|
### `CODE` (可选)
|
||||||
|
|
||||||
@@ -90,6 +110,82 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
|
|||||||
|
|
||||||
指定 OpenAI 中的组织 ID。
|
指定 OpenAI 中的组织 ID。
|
||||||
|
|
||||||
|
### `AZURE_URL` (可选)
|
||||||
|
|
||||||
|
> 形如:https://{azure-resource-url}/openai
|
||||||
|
|
||||||
|
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` (可选)
|
||||||
|
|
||||||
|
Google Gemini Pro 密钥.
|
||||||
|
|
||||||
|
### `GOOGLE_URL` (可选)
|
||||||
|
|
||||||
|
Google Gemini Pro Api Url.
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_KEY` (可选)
|
||||||
|
|
||||||
|
anthropic claude Api Key.
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_VERSION` (可选)
|
||||||
|
|
||||||
|
anthropic claude Api version.
|
||||||
|
|
||||||
|
### `ANTHROPIC_URL` (可选)
|
||||||
|
|
||||||
|
anthropic claude Api Url.
|
||||||
|
|
||||||
|
### `BAIDU_API_KEY` (可选)
|
||||||
|
|
||||||
|
Baidu Api Key.
|
||||||
|
|
||||||
|
### `BAIDU_SECRET_KEY` (可选)
|
||||||
|
|
||||||
|
Baidu Secret Key.
|
||||||
|
|
||||||
|
### `BAIDU_URL` (可选)
|
||||||
|
|
||||||
|
Baidu Api Url.
|
||||||
|
|
||||||
|
### `BYTEDANCE_API_KEY` (可选)
|
||||||
|
|
||||||
|
ByteDance Api Key.
|
||||||
|
|
||||||
|
### `BYTEDANCE_URL` (可选)
|
||||||
|
|
||||||
|
ByteDance Api Url.
|
||||||
|
|
||||||
|
### `ALIBABA_API_KEY` (可选)
|
||||||
|
|
||||||
|
阿里云(千问)Api Key.
|
||||||
|
|
||||||
|
### `ALIBABA_URL` (可选)
|
||||||
|
|
||||||
|
阿里云(千问)Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_URL` (可选)
|
||||||
|
|
||||||
|
讯飞星火Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_KEY` (可选)
|
||||||
|
|
||||||
|
讯飞星火Api Key.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_SECRET` (可选)
|
||||||
|
|
||||||
|
讯飞星火Api Secret.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (可选)
|
### `HIDE_USER_API_KEY` (可选)
|
||||||
|
|
||||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||||
@@ -98,6 +194,53 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
|
|||||||
|
|
||||||
如果你不想让用户使用 GPT-4,将此环境变量设置为 1 即可。
|
如果你不想让用户使用 GPT-4,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
|
### `ENABLE_BALANCE_QUERY` (可选)
|
||||||
|
|
||||||
|
如果你想启用余额查询功能,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
|
### `DISABLE_FAST_LINK` (可选)
|
||||||
|
|
||||||
|
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
|
### `WHITE_WEBDAV_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`
|
||||||
|
|
||||||
|
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
||||||
|
|
||||||
|
在Azure的模式下,支持使用`modelName@Azure=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
||||||
|
> 示例:`+gpt-3.5-turbo@Azure=gpt35`这个配置会在模型列表显示一个`gpt35(Azure)`的选项。
|
||||||
|
> 如果你只能使用Azure模式,那么设置 `-all,+gpt-3.5-turbo@Azure=gpt35` 则可以让对话的默认使用 `gpt35(Azure)`
|
||||||
|
|
||||||
|
在ByteDance的模式下,支持使用`modelName@bytedance=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
||||||
|
> 示例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx`这个配置会在模型列表显示一个`Doubao-lite-4k(ByteDance)`的选项
|
||||||
|
|
||||||
|
|
||||||
|
### `DEFAULT_MODEL` (可选)
|
||||||
|
|
||||||
|
更改默认模型
|
||||||
|
|
||||||
|
### `DEFAULT_INPUT_TEMPLATE` (可选)
|
||||||
|
|
||||||
|
自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
|
||||||
|
|
||||||
|
### `STABILITY_API_KEY` (optional)
|
||||||
|
|
||||||
|
Stability API密钥
|
||||||
|
|
||||||
|
### `STABILITY_URL` (optional)
|
||||||
|
|
||||||
|
自定义的Stability API请求地址
|
||||||
|
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
点击下方按钮,开始二次开发:
|
点击下方按钮,开始二次开发:
|
||||||
@@ -110,14 +253,14 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
### 本地开发
|
### 本地开发
|
||||||
|
|
||||||
1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT;
|
1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT;
|
||||||
2. 执行 `yarn install && yarn dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署!
|
2. 执行 `yarn install && yarn dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署!
|
||||||
3. 如果你想本地部署,请使用 `yarn install && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。
|
3. 如果你想本地部署,请使用 `yarn install && yarn build && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -131,8 +274,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -140,13 +283,19 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果你的本地代理需要账号密码,可以使用:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
-e PROXY_URL="http://127.0.0.1:7890 user password"
|
||||||
|
```
|
||||||
|
|
||||||
如果你需要指定其他环境变量,请自行在上述命令中增加 `-e 环境变量=环境变量值` 来指定。
|
如果你需要指定其他环境变量,请自行在上述命令中增加 `-e 环境变量=环境变量值` 来指定。
|
||||||
|
|
||||||
### 本地部署
|
### 本地部署
|
||||||
@@ -169,8 +318,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): 一站式大模型额度管理平台,支持市面上所有主流大语言模型
|
||||||
|
|
||||||
## 开源协议
|
## 开源协议
|
||||||
|
|
||||||
> 反对 996,从我开始。
|
[MIT](https://opensource.org/license/mit/)
|
||||||
|
|
||||||
[Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
|
||||||
|
|||||||
171
README_ES.md
171
README_ES.md
@@ -1,171 +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.
|
|
||||||
|
|
||||||
## explotación
|
|
||||||
|
|
||||||
> No se recomienda encarecidamente desarrollar o implementar localmente, debido a algunas razones técnicas, es difícil configurar el agente API de OpenAI localmente, a menos que pueda asegurarse de que puede conectarse directamente al servidor OpenAI.
|
|
||||||
|
|
||||||
Haga clic en el botón de abajo para iniciar el desarrollo secundario:
|
|
||||||
|
|
||||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
|
||||||
|
|
||||||
Antes de empezar a escribir código, debe crear uno nuevo en la raíz del proyecto `.env.local` archivo, lleno de variables de entorno:
|
|
||||||
|
|
||||||
OPENAI_API_KEY=<your api key here>
|
|
||||||
|
|
||||||
### Desarrollo local
|
|
||||||
|
|
||||||
1. Instale nodejs 18 e hilo, pregunte a ChatGPT para obtener más detalles;
|
|
||||||
2. ejecutar `yarn install && yarn dev` Enlatar. ⚠️ Nota: Este comando es solo para desarrollo local, no para implementación.
|
|
||||||
3. Úselo si desea implementar localmente `yarn install && yarn start` comando, puede cooperar con pm2 a daemon para evitar ser asesinado, pregunte a ChatGPT para obtener más detalles.
|
|
||||||
|
|
||||||
## desplegar
|
|
||||||
|
|
||||||
### Implementación de contenedores (recomendado)
|
|
||||||
|
|
||||||
> La versión de Docker debe ser 20 o posterior, de lo contrario se indicará que no se puede encontrar la imagen.
|
|
||||||
|
|
||||||
> ⚠️ Nota: Las versiones de Docker están de 1 a 2 días por detrás de la última versión la mayor parte del tiempo, por lo que es normal que sigas diciendo "La actualización existe" después de la implementación.
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker pull yidadaa/chatgpt-next-web
|
|
||||||
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY="sk-xxxx" \
|
|
||||||
-e CODE="页面访问密码" \
|
|
||||||
yidadaa/chatgpt-next-web
|
|
||||||
```
|
|
||||||
|
|
||||||
También puede especificar proxy:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY="sk-xxxx" \
|
|
||||||
-e CODE="页面访问密码" \
|
|
||||||
--net=host \
|
|
||||||
-e PROXY_URL="http://127.0.0.1:7890" \
|
|
||||||
yidadaa/chatgpt-next-web
|
|
||||||
```
|
|
||||||
|
|
||||||
Si necesita especificar otras variables de entorno, agréguelas usted mismo en el comando anterior `-e 环境变量=环境变量值` para especificar.
|
|
||||||
|
|
||||||
### Implementación local
|
|
||||||
|
|
||||||
Ejecute el siguiente comando en la consola:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ Nota: Si tiene problemas durante la instalación, utilice la implementación de Docker.
|
|
||||||
|
|
||||||
## Reconocimiento
|
|
||||||
|
|
||||||
### donante
|
|
||||||
|
|
||||||
> Ver versión en inglés.
|
|
||||||
|
|
||||||
### Colaboradores
|
|
||||||
|
|
||||||
[Ver la lista de colaboradores del proyecto](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
|
||||||
|
|
||||||
## Licencia de código abierto
|
|
||||||
|
|
||||||
> Contra 996, empezando por mí.
|
|
||||||
|
|
||||||
[Licencia Anti 996](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
|
|
||||||
310
README_JA.md
Normal file
310
README_JA.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="./docs/images/ent.svg" alt="プレビュー"/>
|
||||||
|
|
||||||
|
<h1 align="center">NextChat</h1>
|
||||||
|
|
||||||
|
ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。
|
||||||
|
|
||||||
|
[NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [企業版](#企業版) / [デモ](https://chat-gpt-next-web.vercel.app/) / [フィードバック](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discordに参加](https://discord.gg/zrhvHCr79N)
|
||||||
|
|
||||||
|
[<img src="https://vercel.com/button" alt="Zeaburでデプロイ" height="30">](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) [<img src="https://zeabur.com/button.svg" alt="Zeaburでデプロイ" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Gitpodで開く" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 企業版
|
||||||
|
|
||||||
|
あなたの会社のプライベートデプロイとカスタマイズのニーズに応える
|
||||||
|
- **ブランドカスタマイズ**:企業向けに特別に設計された VI/UI、企業ブランドイメージとシームレスにマッチ
|
||||||
|
- **リソース統合**:企業管理者が数十種類のAIリソースを統一管理、チームメンバーはすぐに使用可能
|
||||||
|
- **権限管理**:メンバーの権限、リソースの権限、ナレッジベースの権限を明確にし、企業レベルのAdmin Panelで統一管理
|
||||||
|
- **知識の統合**:企業内部のナレッジベースとAI機能を結びつけ、汎用AIよりも企業自身の業務ニーズに近づける
|
||||||
|
- **セキュリティ監査**:機密質問を自動的にブロックし、すべての履歴対話を追跡可能にし、AIも企業の情報セキュリティ基準に従わせる
|
||||||
|
- **プライベートデプロイ**:企業レベルのプライベートデプロイ、主要なプライベートクラウドデプロイをサポートし、データのセキュリティとプライバシーを保護
|
||||||
|
- **継続的な更新**:マルチモーダル、エージェントなどの最先端機能を継続的に更新し、常に最新であり続ける
|
||||||
|
|
||||||
|
企業版のお問い合わせ: **business@nextchat.dev**
|
||||||
|
|
||||||
|
|
||||||
|
## 始めに
|
||||||
|
|
||||||
|
1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する;
|
||||||
|
2. 右側のボタンをクリックしてデプロイを開始:
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください;
|
||||||
|
3. デプロイが完了したら、すぐに使用を開始できます;
|
||||||
|
4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
## 更新を維持する
|
||||||
|
|
||||||
|
もし上記の手順に従ってワンクリックでプロジェクトをデプロイした場合、「更新があります」というメッセージが常に表示されることがあります。これは、Vercel がデフォルトで新しいプロジェクトを作成するためで、本プロジェクトを fork していないことが原因です。そのため、正しく更新を検出できません。
|
||||||
|
|
||||||
|
以下の手順に従って再デプロイすることをお勧めします:
|
||||||
|
|
||||||
|
- 元のリポジトリを削除する
|
||||||
|
- ページ右上の fork ボタンを使って、本プロジェクトを fork する
|
||||||
|
- Vercel で再度選択してデプロイする、[詳細な手順はこちらを参照してください](./docs/vercel-ja.md)。
|
||||||
|
|
||||||
|
|
||||||
|
### 自動更新を開く
|
||||||
|
|
||||||
|
> Upstream Sync の実行エラーが発生した場合は、[手動で Sync Fork](./README_JA.md#手動でコードを更新する) してください!
|
||||||
|
|
||||||
|
プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
### 手動でコードを更新する
|
||||||
|
|
||||||
|
手動で即座に更新したい場合は、[GitHub のドキュメント](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)を参照して、fork したプロジェクトを上流のコードと同期する方法を確認してください。
|
||||||
|
|
||||||
|
このプロジェクトをスターまたはウォッチしたり、作者をフォローすることで、新機能の更新通知をすぐに受け取ることができます。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## ページアクセスパスワードを設定する
|
||||||
|
|
||||||
|
> パスワードを設定すると、ユーザーは設定ページでアクセスコードを手動で入力しない限り、通常のチャットができず、未承認の状態であることを示すメッセージが表示されます。
|
||||||
|
|
||||||
|
> **警告**:パスワードの桁数は十分に長く設定してください。7桁以上が望ましいです。さもないと、[ブルートフォース攻撃を受ける可能性があります](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
|
||||||
|
|
||||||
|
このプロジェクトは限られた権限管理機能を提供しています。Vercel プロジェクトのコントロールパネルで、環境変数ページに `CODE` という名前の環境変数を追加し、値をカンマで区切ったカスタムパスワードに設定してください:
|
||||||
|
|
||||||
|
```
|
||||||
|
code1,code2,code3
|
||||||
|
```
|
||||||
|
|
||||||
|
この環境変数を追加または変更した後、**プロジェクトを再デプロイ**して変更を有効にしてください。
|
||||||
|
|
||||||
|
|
||||||
|
## 環境変数
|
||||||
|
|
||||||
|
> 本プロジェクトのほとんどの設定は環境変数で行います。チュートリアル:[Vercel の環境変数を変更する方法](./docs/vercel-ja.md)。
|
||||||
|
|
||||||
|
### `OPENAI_API_KEY` (必須)
|
||||||
|
|
||||||
|
OpenAI の API キー。OpenAI アカウントページで申請したキーをカンマで区切って複数設定できます。これにより、ランダムにキーが選択されます。
|
||||||
|
|
||||||
|
### `CODE` (オプション)
|
||||||
|
|
||||||
|
アクセスパスワード。カンマで区切って複数設定可能。
|
||||||
|
|
||||||
|
**警告**:この項目を設定しないと、誰でもデプロイしたウェブサイトを利用でき、トークンが急速に消耗する可能性があるため、設定をお勧めします。
|
||||||
|
|
||||||
|
### `BASE_URL` (オプション)
|
||||||
|
|
||||||
|
> デフォルト: `https://api.openai.com`
|
||||||
|
|
||||||
|
> 例: `http://your-openai-proxy.com`
|
||||||
|
|
||||||
|
OpenAI API のプロキシ URL。手動で OpenAI API のプロキシを設定している場合はこのオプションを設定してください。
|
||||||
|
|
||||||
|
> SSL 証明書の問題がある場合は、`BASE_URL` のプロトコルを http に設定してください。
|
||||||
|
|
||||||
|
### `OPENAI_ORG_ID` (オプション)
|
||||||
|
|
||||||
|
OpenAI の組織 ID を指定します。
|
||||||
|
|
||||||
|
### `AZURE_URL` (オプション)
|
||||||
|
|
||||||
|
> 形式: https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||||
|
> `CUSTOM_MODELS` で `displayName` 形式で {deploy-name} を設定した場合、`AZURE_URL` から {deploy-name} を省略できます。
|
||||||
|
|
||||||
|
Azure のデプロイ URL。
|
||||||
|
|
||||||
|
### `AZURE_API_KEY` (オプション)
|
||||||
|
|
||||||
|
Azure の API キー。
|
||||||
|
|
||||||
|
### `AZURE_API_VERSION` (オプション)
|
||||||
|
|
||||||
|
Azure API バージョン。[Azure ドキュメント](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)で確認できます。
|
||||||
|
|
||||||
|
### `GOOGLE_API_KEY` (オプション)
|
||||||
|
|
||||||
|
Google Gemini Pro API キー。
|
||||||
|
|
||||||
|
### `GOOGLE_URL` (オプション)
|
||||||
|
|
||||||
|
Google Gemini Pro API の URL。
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_KEY` (オプション)
|
||||||
|
|
||||||
|
Anthropic Claude API キー。
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_VERSION` (オプション)
|
||||||
|
|
||||||
|
Anthropic Claude API バージョン。
|
||||||
|
|
||||||
|
### `ANTHROPIC_URL` (オプション)
|
||||||
|
|
||||||
|
Anthropic Claude API の URL。
|
||||||
|
|
||||||
|
### `BAIDU_API_KEY` (オプション)
|
||||||
|
|
||||||
|
Baidu API キー。
|
||||||
|
|
||||||
|
### `BAIDU_SECRET_KEY` (オプション)
|
||||||
|
|
||||||
|
Baidu シークレットキー。
|
||||||
|
|
||||||
|
### `BAIDU_URL` (オプション)
|
||||||
|
|
||||||
|
Baidu API の URL。
|
||||||
|
|
||||||
|
### `BYTEDANCE_API_KEY` (オプション)
|
||||||
|
|
||||||
|
ByteDance API キー。
|
||||||
|
|
||||||
|
### `BYTEDANCE_URL` (オプション)
|
||||||
|
|
||||||
|
ByteDance API の URL。
|
||||||
|
|
||||||
|
### `ALIBABA_API_KEY` (オプション)
|
||||||
|
|
||||||
|
アリババ(千问)API キー。
|
||||||
|
|
||||||
|
### `ALIBABA_URL` (オプション)
|
||||||
|
|
||||||
|
アリババ(千问)API の URL。
|
||||||
|
|
||||||
|
### `HIDE_USER_API_KEY` (オプション)
|
||||||
|
|
||||||
|
ユーザーが API キーを入力できないようにしたい場合は、この環境変数を 1 に設定します。
|
||||||
|
|
||||||
|
### `DISABLE_GPT4` (オプション)
|
||||||
|
|
||||||
|
ユーザーが GPT-4 を使用できないようにしたい場合は、この環境変数を 1 に設定します。
|
||||||
|
|
||||||
|
### `ENABLE_BALANCE_QUERY` (オプション)
|
||||||
|
|
||||||
|
バランスクエリ機能を有効にしたい場合は、この環境変数を 1 に設定します。
|
||||||
|
|
||||||
|
### `DISABLE_FAST_LINK` (オプション)
|
||||||
|
|
||||||
|
リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。
|
||||||
|
|
||||||
|
### `WHITE_WEBDAV_ENDPOINTS` (オプション)
|
||||||
|
|
||||||
|
アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件:
|
||||||
|
- 各アドレスは完全なエンドポイントでなければなりません。
|
||||||
|
> `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` のみを有効にすることを意味します。
|
||||||
|
|
||||||
|
モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。
|
||||||
|
|
||||||
|
Azure モードでは、`modelName@Azure=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
||||||
|
> 例:`+gpt-3.5-turbo@Azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。
|
||||||
|
|
||||||
|
ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
||||||
|
> 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。
|
||||||
|
|
||||||
|
### `DEFAULT_MODEL` (オプション)
|
||||||
|
|
||||||
|
デフォルトのモデルを変更します。
|
||||||
|
|
||||||
|
### `DEFAULT_INPUT_TEMPLATE` (オプション)
|
||||||
|
|
||||||
|
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。
|
||||||
|
|
||||||
|
|
||||||
|
## 開発
|
||||||
|
|
||||||
|
下のボタンをクリックして二次開発を開始してください:
|
||||||
|
|
||||||
|
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||
|
コードを書く前に、プロジェクトのルートディレクトリに `.env.local` ファイルを新規作成し、環境変数を記入します:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=<your api key here>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### ローカル開発
|
||||||
|
|
||||||
|
1. Node.js 18 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。
|
||||||
|
2. `yarn install && yarn dev` を実行します。⚠️ 注意:このコマンドはローカル開発用であり、デプロイには使用しないでください。
|
||||||
|
3. ローカルでデプロイしたい場合は、`yarn install && yarn build && yarn start` コマンドを使用してください。プロセスを守るために pm2 を使用することもできます。詳細は ChatGPT にお尋ねください。
|
||||||
|
|
||||||
|
|
||||||
|
## デプロイ
|
||||||
|
|
||||||
|
### コンテナデプロイ(推奨)
|
||||||
|
|
||||||
|
> Docker バージョンは 20 以上が必要です。それ以下だとイメージが見つからないというエラーが出ます。
|
||||||
|
|
||||||
|
> ⚠️ 注意:Docker バージョンは最新バージョンより 1~2 日遅れることが多いため、デプロイ後に「更新があります」の通知が出続けることがありますが、正常です。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker pull yidadaa/chatgpt-next-web
|
||||||
|
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e OPENAI_API_KEY=sk-xxxx \
|
||||||
|
-e CODE=ページアクセスパスワード \
|
||||||
|
yidadaa/chatgpt-next-web
|
||||||
|
```
|
||||||
|
|
||||||
|
プロキシを指定することもできます:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
ローカルプロキシがアカウントとパスワードを必要とする場合は、以下を使用できます:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
-e PROXY_URL="http://127.0.0.1:7890 user password"
|
||||||
|
```
|
||||||
|
|
||||||
|
他の環境変数を指定する必要がある場合は、上記のコマンドに `-e 環境変数=環境変数値` を追加して指定してください。
|
||||||
|
|
||||||
|
|
||||||
|
### ローカルデプロイ
|
||||||
|
|
||||||
|
コンソールで以下のコマンドを実行します:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 注意:インストール中に問題が発生した場合は、Docker を使用してデプロイしてください。
|
||||||
|
|
||||||
|
|
||||||
|
## 謝辞
|
||||||
|
|
||||||
|
### 寄付者
|
||||||
|
|
||||||
|
> 英語版をご覧ください。
|
||||||
|
|
||||||
|
### 貢献者
|
||||||
|
|
||||||
|
[プロジェクトの貢献者リストはこちら](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||||
|
|
||||||
|
### 関連プロジェクト
|
||||||
|
|
||||||
|
- [one-api](https://github.com/songquanpeng/one-api): 一つのプラットフォームで大規模モデルのクォータ管理を提供し、市場に出回っているすべての主要な大規模言語モデルをサポートします。
|
||||||
|
|
||||||
|
|
||||||
|
## オープンソースライセンス
|
||||||
|
|
||||||
|
[MIT](https://opensource.org/license/mit/)
|
||||||
70
app/api/[provider]/[...path]/route.ts
Normal file
70
app/api/[provider]/[...path]/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { ApiPath } from "@/app/constant";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { handle as openaiHandler } from "../../openai";
|
||||||
|
import { handle as azureHandler } from "../../azure";
|
||||||
|
import { handle as googleHandler } from "../../google";
|
||||||
|
import { handle as anthropicHandler } from "../../anthropic";
|
||||||
|
import { handle as baiduHandler } from "../../baidu";
|
||||||
|
import { handle as bytedanceHandler } from "../../bytedance";
|
||||||
|
import { handle as alibabaHandler } from "../../alibaba";
|
||||||
|
import { handle as moonshotHandler } from "../../moonshot";
|
||||||
|
import { handle as stabilityHandler } from "../../stability";
|
||||||
|
import { handle as iflytekHandler } from "../../iflytek";
|
||||||
|
import { handle as proxyHandler } from "../../proxy";
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { provider: string; path: string[] } },
|
||||||
|
) {
|
||||||
|
const apiPath = `/api/${params.provider}`;
|
||||||
|
console.log(`[${params.provider} Route] params `, params);
|
||||||
|
switch (apiPath) {
|
||||||
|
case ApiPath.Azure:
|
||||||
|
return azureHandler(req, { params });
|
||||||
|
case ApiPath.Google:
|
||||||
|
return googleHandler(req, { params });
|
||||||
|
case ApiPath.Anthropic:
|
||||||
|
return anthropicHandler(req, { params });
|
||||||
|
case ApiPath.Baidu:
|
||||||
|
return baiduHandler(req, { params });
|
||||||
|
case ApiPath.ByteDance:
|
||||||
|
return bytedanceHandler(req, { params });
|
||||||
|
case ApiPath.Alibaba:
|
||||||
|
return alibabaHandler(req, { params });
|
||||||
|
// case ApiPath.Tencent: using "/api/tencent"
|
||||||
|
case ApiPath.Moonshot:
|
||||||
|
return moonshotHandler(req, { params });
|
||||||
|
case ApiPath.Stability:
|
||||||
|
return stabilityHandler(req, { params });
|
||||||
|
case ApiPath.Iflytek:
|
||||||
|
return iflytekHandler(req, { params });
|
||||||
|
case ApiPath.OpenAI:
|
||||||
|
return openaiHandler(req, { params });
|
||||||
|
default:
|
||||||
|
return proxyHandler(req, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
];
|
||||||
129
app/api/alibaba.ts
Normal file
129
app/api/alibaba.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
ALIBABA_BASE_URL,
|
||||||
|
ApiPath,
|
||||||
|
ModelProvider,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/api/auth";
|
||||||
|
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Alibaba Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Qwen);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Alibaba] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(req: NextRequest) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// alibaba use base url or just remove the path
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Alibaba, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.alibabaUrl || ALIBABA_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",
|
||||||
|
Authorization: req.headers.get("Authorization") ?? "",
|
||||||
|
"X-DashScope-SSE": req.headers.get("X-DashScope-SSE") ?? "disable",
|
||||||
|
},
|
||||||
|
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 clonedBody = await req.text();
|
||||||
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.Alibaba as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Alibaba] filter`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/api/anthropic.ts
Normal file
170
app/api/anthropic.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
ANTHROPIC_BASE_URL,
|
||||||
|
Anthropic,
|
||||||
|
ApiPath,
|
||||||
|
ServiceProvider,
|
||||||
|
ModelProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||||
|
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||||
|
|
||||||
|
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
||||||
|
|
||||||
|
export 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
// try rebuild url, when using cloudflare ai gateway in server
|
||||||
|
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}${path}`);
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"anthropic-dangerous-direct-browser-access": "true",
|
||||||
|
[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 clonedBody = await req.text();
|
||||||
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.Anthropic as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/api/artifacts/route.ts
Normal file
73
app/api/artifacts/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import md5 from "spark-md5";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
|
||||||
|
async function handle(req: NextRequest, res: NextResponse) {
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
const storeUrl = () =>
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
|
||||||
|
const storeHeaders = () => ({
|
||||||
|
Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
|
||||||
|
});
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const clonedBody = await req.text();
|
||||||
|
const hashedCode = md5.hash(clonedBody).trim();
|
||||||
|
const body: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
expiration_ttl?: number;
|
||||||
|
} = {
|
||||||
|
key: hashedCode,
|
||||||
|
value: clonedBody,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
|
||||||
|
if (ttl > 60) {
|
||||||
|
body["expiration_ttl"] = ttl;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${storeUrl()}/bulk`, {
|
||||||
|
headers: {
|
||||||
|
...storeHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify([body]),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
console.log("save data", result);
|
||||||
|
if (result?.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 0, id: hashedCode, result },
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: true, msg: "Save data error" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const id = req?.nextUrl?.searchParams?.get("id");
|
||||||
|
const res = await fetch(`${storeUrl()}/values/${id}`, {
|
||||||
|
headers: storeHeaders(),
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: res.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: true, msg: "Invalid request" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = handle;
|
||||||
|
export const GET = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
@@ -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,71 @@ 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.Stability:
|
||||||
|
systemApiKey = serverConfig.stabilityApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.GeminiPro:
|
||||||
|
systemApiKey = serverConfig.googleApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Claude:
|
||||||
|
systemApiKey = serverConfig.anthropicApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Doubao:
|
||||||
|
systemApiKey = serverConfig.bytedanceApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Ernie:
|
||||||
|
systemApiKey = serverConfig.baiduApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Qwen:
|
||||||
|
systemApiKey = serverConfig.alibabaApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Moonshot:
|
||||||
|
systemApiKey = serverConfig.moonshotApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Iflytek:
|
||||||
|
systemApiKey =
|
||||||
|
serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
|
||||||
|
break;
|
||||||
|
case ModelProvider.GPT:
|
||||||
|
default:
|
||||||
|
if (req.nextUrl.pathname.includes("azure/deployments")) {
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/api/azure.ts
Normal file
32
app/api/azure.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ModelProvider } from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { requestOpenai } from "./common";
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Azure Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subpath = params.path.join("/");
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.GPT);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await requestOpenai(req);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Azure] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/api/baidu.ts
Normal file
144
app/api/baidu.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
BAIDU_BASE_URL,
|
||||||
|
ApiPath,
|
||||||
|
ModelProvider,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/api/auth";
|
||||||
|
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||||
|
import { getAccessToken } from "@/app/utils/baidu";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Baidu Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Ernie);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverConfig.baiduApiKey || !serverConfig.baiduSecretKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `missing BAIDU_API_KEY or BAIDU_SECRET_KEY in server env vars`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Baidu] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(req: NextRequest) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Baidu, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.baiduUrl || BAIDU_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 { access_token } = await getAccessToken(
|
||||||
|
serverConfig.baiduApiKey as string,
|
||||||
|
serverConfig.baiduSecretKey as string,
|
||||||
|
);
|
||||||
|
const fetchUrl = `${baseUrl}${path}?access_token=${access_token}`;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
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 clonedBody = await req.text();
|
||||||
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.Baidu as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Baidu] filter`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/api/bytedance.ts
Normal file
129
app/api/bytedance.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
BYTEDANCE_BASE_URL,
|
||||||
|
ApiPath,
|
||||||
|
ModelProvider,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/api/auth";
|
||||||
|
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[ByteDance Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Doubao);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ByteDance] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(req: NextRequest) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.ByteDance, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.bytedanceUrl || BYTEDANCE_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",
|
||||||
|
Authorization: req.headers.get("Authorization") ?? "",
|
||||||
|
},
|
||||||
|
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 clonedBody = await req.text();
|
||||||
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.ByteDance as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ByteDance] filter`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to 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,66 +1,138 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSideConfig } from "../config/server";
|
||||||
|
import { OPENAI_BASE_URL, ServiceProvider } from "../constant";
|
||||||
|
import { isModelAvailableInServer } from "../utils/model";
|
||||||
|
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
|
||||||
|
|
||||||
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(
|
|
||||||
"/api/openai/",
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
let baseUrl = BASE_URL;
|
const isAzure = req.nextUrl.pathname.includes("azure/deployments");
|
||||||
|
|
||||||
|
var authValue,
|
||||||
|
authHeaderName = "";
|
||||||
|
if (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}`.replaceAll("/api/openai/", "");
|
||||||
|
|
||||||
|
let baseUrl =
|
||||||
|
(isAzure ? 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 (isAzure) {
|
||||||
|
const azureApiVersion =
|
||||||
|
req?.nextUrl?.searchParams?.get("api-version") ||
|
||||||
|
serverConfig.azureApiVersion;
|
||||||
|
baseUrl = baseUrl.split("/deployments").shift() as string;
|
||||||
|
path = `${req.nextUrl.pathname.replaceAll(
|
||||||
|
"/api/azure/",
|
||||||
|
"",
|
||||||
|
)}?api-version=${azureApiVersion}`;
|
||||||
|
|
||||||
|
// Forward compatibility:
|
||||||
|
// if display_name(deployment_name) not set, and '{deploy-id}' in AZURE_URL
|
||||||
|
// then using default '{deploy-id}'
|
||||||
|
if (serverConfig.customModels && serverConfig.azureUrl) {
|
||||||
|
const modelName = path.split("/")[1];
|
||||||
|
let realDeployName = "";
|
||||||
|
serverConfig.customModels
|
||||||
|
.split(",")
|
||||||
|
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
|
||||||
|
.forEach((m) => {
|
||||||
|
const [fullName, displayName] = m.split("=");
|
||||||
|
const [_, providerName] = fullName.split("@");
|
||||||
|
if (providerName === "azure" && !displayName) {
|
||||||
|
const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
|
||||||
|
"deployments/",
|
||||||
|
);
|
||||||
|
if (deployId) {
|
||||||
|
realDeployName = deployId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (realDeployName) {
|
||||||
|
console.log("[Replace with DeployId", realDeployName);
|
||||||
|
path = path.replaceAll(modelName, realDeployName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
|
||||||
controller.abort();
|
console.log("fetchUrl", fetchUrl);
|
||||||
}, 10 * 60 * 1000);
|
|
||||||
|
|
||||||
const fetchUrl = `${baseUrl}/${openaiPath}`;
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: authValue,
|
"Cache-Control": "no-store",
|
||||||
...(process.env.OPENAI_ORG_ID && {
|
[authHeaderName]: authValue,
|
||||||
"OpenAI-Organization": process.env.OPENAI_ORG_ID,
|
...(serverConfig.openaiOrgId && {
|
||||||
|
"OpenAI-Organization": serverConfig.openaiOrgId,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
|
||||||
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 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 (
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.OpenAI as string,
|
||||||
|
) ||
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.Azure as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
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,13 +147,35 @@ 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 disbale ngnix 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,12 +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,
|
||||||
enableGPT4: serverConfig.enableGPT4,
|
disableGPT4: serverConfig.disableGPT4,
|
||||||
|
hideBalanceQuery: serverConfig.hideBalanceQuery,
|
||||||
|
disableFastLink: serverConfig.disableFastLink,
|
||||||
|
customModels: serverConfig.customModels,
|
||||||
|
defaultModel: serverConfig.defaultModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
133
app/api/google.ts
Normal file
133
app/api/google.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import { ApiPath, GEMINI_BASE_URL, ModelProvider } from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { provider: string; path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Google Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.GeminiPro);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearToken =
|
||||||
|
req.headers.get("x-goog-api-key") || req.headers.get("Authorization") || "";
|
||||||
|
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||||
|
|
||||||
|
const apiKey = token ? token : serverConfig.googleApiKey;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `missing GOOGLE_API_KEY in server env vars`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await request(req, apiKey);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Google] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function request(req: NextRequest, apiKey: string) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Google, "");
|
||||||
|
|
||||||
|
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}${
|
||||||
|
req?.nextUrl?.searchParams?.get("alt") === "sse" ? "?alt=sse" : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
console.log("[Fetch Url] ", fetchUrl);
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"x-goog-api-key":
|
||||||
|
req.headers.get("x-goog-api-key") ||
|
||||||
|
(req.headers.get("Authorization") ?? "").replace("Bearer ", ""),
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/api/iflytek.ts
Normal file
129
app/api/iflytek.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
IFLYTEK_BASE_URL,
|
||||||
|
ApiPath,
|
||||||
|
ModelProvider,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/api/auth";
|
||||||
|
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||||
|
// iflytek
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Iflytek Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Iflytek);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Iflytek] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(req: NextRequest) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// iflytek use base url or just remove the path
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Iflytek, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.iflytekUrl || IFLYTEK_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",
|
||||||
|
Authorization: req.headers.get("Authorization") ?? "",
|
||||||
|
},
|
||||||
|
method: req.method,
|
||||||
|
body: req.body,
|
||||||
|
redirect: "manual",
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half",
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
// try to refuse some request to some models
|
||||||
|
if (serverConfig.customModels && req.body) {
|
||||||
|
try {
|
||||||
|
const clonedBody = await req.text();
|
||||||
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.Iflytek as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Iflytek] filter`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/api/moonshot.ts
Normal file
128
app/api/moonshot.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
MOONSHOT_BASE_URL,
|
||||||
|
ApiPath,
|
||||||
|
ModelProvider,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/api/auth";
|
||||||
|
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Moonshot Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Moonshot);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Moonshot] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(req: NextRequest) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// alibaba use base url or just remove the path
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.moonshotUrl || MOONSHOT_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",
|
||||||
|
Authorization: req.headers.get("Authorization") ?? "",
|
||||||
|
},
|
||||||
|
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 clonedBody = await req.text();
|
||||||
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
|
// not undefined and is false
|
||||||
|
if (
|
||||||
|
isModelAvailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.Moonshot as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Moonshot] filter`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
|
// to prevent browser prompt for credentials
|
||||||
|
const newHeaders = new Headers(res.headers);
|
||||||
|
newHeaders.delete("www-authenticate");
|
||||||
|
// to 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/openai.ts
Normal file
74
app/api/openai.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { requestOpenai } from "./common";
|
||||||
|
|
||||||
|
const ALLOWED_PATH = new Set(Object.values(OpenaiPath));
|
||||||
|
|
||||||
|
function getModels(remoteModelRes: OpenAIListModelResponse) {
|
||||||
|
const config = getServerSideConfig();
|
||||||
|
|
||||||
|
if (config.disableGPT4) {
|
||||||
|
remoteModelRes.data = remoteModelRes.data.filter(
|
||||||
|
(m) =>
|
||||||
|
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
|
||||||
|
m.id.startsWith("gpt-4o-mini"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteModelRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[OpenAI Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subpath = params.path.join("/");
|
||||||
|
|
||||||
|
if (!ALLOWED_PATH.has(subpath)) {
|
||||||
|
console.log("[OpenAI Route] forbidden path ", subpath);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
msg: "you are not allowed to request " + subpath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.GPT);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await requestOpenai(req);
|
||||||
|
|
||||||
|
// list models
|
||||||
|
if (subpath === OpenaiPath.ListModelPath && response.status === 200) {
|
||||||
|
const resJson = (await response.json()) as OpenAIListModelResponse;
|
||||||
|
const availableModels = getModels(resJson);
|
||||||
|
return NextResponse.json(availableModels, {
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[OpenAI] ", e);
|
||||||
|
return NextResponse.json(prettyObject(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { OpenaiPath } from "@/app/constant";
|
|
||||||
import { prettyObject } from "@/app/utils/format";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { auth } from "../../auth";
|
|
||||||
import { requestOpenai } from "../../common";
|
|
||||||
|
|
||||||
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
|
||||||
|
|
||||||
async function handle(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: { path: string[] } },
|
|
||||||
) {
|
|
||||||
console.log("[OpenAI Route] params ", params);
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subpath = params.path.join("/");
|
|
||||||
|
|
||||||
if (!ALLOWD_PATH.has(subpath)) {
|
|
||||||
console.log("[OpenAI Route] forbidden path ", subpath);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: true,
|
|
||||||
msg: "you are not allowed to request " + subpath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authResult = auth(req);
|
|
||||||
if (authResult.error) {
|
|
||||||
return NextResponse.json(authResult, {
|
|
||||||
status: 401,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await requestOpenai(req);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[OpenAI] ", e);
|
|
||||||
return NextResponse.json(prettyObject(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET = handle;
|
|
||||||
export const POST = handle;
|
|
||||||
|
|
||||||
export const runtime = "edge";
|
|
||||||
75
app/api/proxy.ts
Normal file
75
app/api/proxy.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Proxy Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove path params from searchParams
|
||||||
|
req.nextUrl.searchParams.delete("path");
|
||||||
|
req.nextUrl.searchParams.delete("provider");
|
||||||
|
|
||||||
|
const subpath = params.path.join("/");
|
||||||
|
const fetchUrl = `${req.headers.get(
|
||||||
|
"x-base-url",
|
||||||
|
)}/${subpath}?${req.nextUrl.searchParams.toString()}`;
|
||||||
|
const skipHeaders = ["connection", "host", "origin", "referer", "cookie"];
|
||||||
|
const headers = new Headers(
|
||||||
|
Array.from(req.headers.entries()).filter((item) => {
|
||||||
|
if (
|
||||||
|
item[0].indexOf("x-") > -1 ||
|
||||||
|
item[0].indexOf("sec-") > -1 ||
|
||||||
|
skipHeaders.includes(item[0])
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers,
|
||||||
|
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");
|
||||||
|
|
||||||
|
// 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, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/api/stability.ts
Normal file
99
app/api/stability.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import { ModelProvider, STABILITY_BASE_URL } from "@/app/constant";
|
||||||
|
import { auth } from "@/app/api/auth";
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Stability] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.stabilityUrl || STABILITY_BASE_URL;
|
||||||
|
|
||||||
|
if (!baseUrl.startsWith("http")) {
|
||||||
|
baseUrl = `https://${baseUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll("/api/stability/", "");
|
||||||
|
|
||||||
|
console.log("[Stability Proxy] ", path);
|
||||||
|
console.log("[Stability Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Stability);
|
||||||
|
|
||||||
|
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.stabilityApiKey;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `missing STABILITY_API_KEY in server env vars`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUrl = `${baseUrl}/${path}`;
|
||||||
|
console.log("[Stability Url] ", fetchUrl);
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": req.headers.get("Content-Type") || "multipart/form-data",
|
||||||
|
Accept: req.headers.get("Accept") || "application/json",
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/api/tencent/route.ts
Normal file
117
app/api/tencent/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import { TENCENT_BASE_URL, ModelProvider } from "@/app/constant";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/api/auth";
|
||||||
|
import { getHeader } from "@/app/utils/tencent";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[Tencent Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.Hunyuan);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Tencent] ", 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",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function request(req: NextRequest) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL;
|
||||||
|
|
||||||
|
if (!baseUrl.startsWith("http")) {
|
||||||
|
baseUrl = `https://${baseUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchUrl = baseUrl;
|
||||||
|
|
||||||
|
const body = await req.text();
|
||||||
|
const headers = await getHeader(
|
||||||
|
body,
|
||||||
|
serverConfig.tencentSecretId as string,
|
||||||
|
serverConfig.tencentSecretKey as string,
|
||||||
|
);
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
headers,
|
||||||
|
method: req.method,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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";
|
||||||
167
app/api/webdav/[...path]/route.ts
Normal file
167
app/api/webdav/[...path]/route.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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.allowedWebDavEndpoints,
|
||||||
|
].filter((domain) => Boolean(domain.trim()));
|
||||||
|
|
||||||
|
const normalizeUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(url);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
|
||||||
|
|
||||||
|
// Validate the endpoint to prevent potential SSRF attacks
|
||||||
|
if (
|
||||||
|
!endpoint ||
|
||||||
|
!mergedAllowedWebDavEndpoints.some((allowedEndpoint) => {
|
||||||
|
const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
|
||||||
|
const normalizedEndpoint = normalizeUrl(endpoint as string);
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedEndpoint &&
|
||||||
|
normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
|
||||||
|
normalizedEndpoint.pathname.startsWith(
|
||||||
|
normalizedAllowedEndpoint.pathname,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
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 (
|
||||||
|
proxy_method !== "MKCOL" &&
|
||||||
|
proxy_method !== "GET" &&
|
||||||
|
proxy_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 (proxy_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 (proxy_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 (proxy_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 = proxy_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: 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";
|
||||||
@@ -1,26 +1,66 @@
|
|||||||
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,
|
||||||
import { ChatGPTApi } from "./platforms/openai";
|
ModelProvider,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "../constant";
|
||||||
|
import {
|
||||||
|
ChatMessageTool,
|
||||||
|
ChatMessage,
|
||||||
|
ModelType,
|
||||||
|
useAccessStore,
|
||||||
|
useChatStore,
|
||||||
|
} from "../store";
|
||||||
|
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
|
||||||
|
import { GeminiProApi } from "./platforms/google";
|
||||||
|
import { ClaudeApi } from "./platforms/anthropic";
|
||||||
|
import { ErnieApi } from "./platforms/baidu";
|
||||||
|
import { DoubaoApi } from "./platforms/bytedance";
|
||||||
|
import { QwenApi } from "./platforms/alibaba";
|
||||||
|
import { HunyuanApi } from "./platforms/tencent";
|
||||||
|
import { MoonshotApi } from "./platforms/moonshot";
|
||||||
|
import { SparkApi } from "./platforms/iflytek";
|
||||||
|
|
||||||
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 const TTSModels = ["tts-1", "tts-1-hd"] 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 {
|
||||||
model: string;
|
model: string;
|
||||||
|
providerName?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
presence_penalty?: number;
|
presence_penalty?: number;
|
||||||
frequency_penalty?: number;
|
frequency_penalty?: number;
|
||||||
|
size?: DalleRequestPayload["size"];
|
||||||
|
quality?: DalleRequestPayload["quality"];
|
||||||
|
style?: DalleRequestPayload["style"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechOptions {
|
||||||
|
model: string;
|
||||||
|
input: string;
|
||||||
|
voice: string;
|
||||||
|
response_format?: string;
|
||||||
|
speed?: number;
|
||||||
|
onController?: (controller: AbortController) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatOptions {
|
export interface ChatOptions {
|
||||||
@@ -31,6 +71,8 @@ export interface ChatOptions {
|
|||||||
onFinish: (message: string) => void;
|
onFinish: (message: string) => void;
|
||||||
onError?: (err: Error) => void;
|
onError?: (err: Error) => void;
|
||||||
onController?: (controller: AbortController) => void;
|
onController?: (controller: AbortController) => void;
|
||||||
|
onBeforeTool?: (tool: ChatMessageTool) => void;
|
||||||
|
onAfterTool?: (tool: ChatMessageTool) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMUsage {
|
export interface LLMUsage {
|
||||||
@@ -38,9 +80,26 @@ export interface LLMUsage {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LLMModel {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
available: boolean;
|
||||||
|
provider: LLMModelProvider;
|
||||||
|
sorted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMModelProvider {
|
||||||
|
id: string;
|
||||||
|
providerName: string;
|
||||||
|
providerType: string;
|
||||||
|
sorted: number;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class LLMApi {
|
export abstract class LLMApi {
|
||||||
abstract chat(options: ChatOptions): Promise<void>;
|
abstract chat(options: ChatOptions): Promise<void>;
|
||||||
|
abstract speech(options: SpeechOptions): Promise<ArrayBuffer>;
|
||||||
abstract usage(): Promise<LLMUsage>;
|
abstract usage(): Promise<LLMUsage>;
|
||||||
|
abstract models(): Promise<LLMModel[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderName = "openai" | "azure" | "claude" | "palm";
|
type ProviderName = "openai" | "azure" | "claude" | "palm";
|
||||||
@@ -67,9 +126,36 @@ interface ChatProvider {
|
|||||||
export class ClientApi {
|
export class ClientApi {
|
||||||
public llm: LLMApi;
|
public llm: LLMApi;
|
||||||
|
|
||||||
constructor() {
|
constructor(provider: ModelProvider = ModelProvider.GPT) {
|
||||||
|
switch (provider) {
|
||||||
|
case ModelProvider.GeminiPro:
|
||||||
|
this.llm = new GeminiProApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Claude:
|
||||||
|
this.llm = new ClaudeApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Ernie:
|
||||||
|
this.llm = new ErnieApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Doubao:
|
||||||
|
this.llm = new DoubaoApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Qwen:
|
||||||
|
this.llm = new QwenApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Hunyuan:
|
||||||
|
this.llm = new HunyuanApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Moonshot:
|
||||||
|
this.llm = new MoonshotApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.Iflytek:
|
||||||
|
this.llm = new SparkApi();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
this.llm = new ChatGPTApi();
|
this.llm = new ChatGPTApi();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
config() {}
|
config() {}
|
||||||
|
|
||||||
@@ -87,13 +173,13 @@ 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",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
||||||
// Please do not modify this message
|
// Please do not modify this message
|
||||||
|
|
||||||
console.log("[Share]", msgs);
|
console.log("[Share]", messages, msgs);
|
||||||
const clientConfig = getClientConfig();
|
const clientConfig = getClientConfig();
|
||||||
const proxyUrl = "/sharegpt";
|
const proxyUrl = "/sharegpt";
|
||||||
const rawUrl = "https://sharegpt.com/api/conversations";
|
const rawUrl = "https://sharegpt.com/api/conversations";
|
||||||
@@ -117,29 +203,132 @@ export class ClientApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ClientApi();
|
export function getBearerToken(
|
||||||
|
apiKey: string,
|
||||||
|
noBearer: boolean = false,
|
||||||
|
): string {
|
||||||
|
return validString(apiKey)
|
||||||
|
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
export function getHeaders() {
|
export function validString(x: string): boolean {
|
||||||
|
return x?.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeaders(ignoreHeaders: boolean = false) {
|
||||||
const accessStore = useAccessStore.getState();
|
const accessStore = useAccessStore.getState();
|
||||||
let headers: Record<string, string> = {
|
const chatStore = useChatStore.getState();
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
if (!ignoreHeaders) {
|
||||||
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-requested-with": "XMLHttpRequest",
|
Accept: "application/json",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const makeBearer = (token: string) => `Bearer ${token.trim()}`;
|
const clientConfig = getClientConfig();
|
||||||
const validString = (x: string) => x && x.length > 0;
|
|
||||||
|
|
||||||
// use user's api key first
|
function getConfig() {
|
||||||
if (validString(accessStore.token)) {
|
const modelConfig = chatStore.currentSession().mask.modelConfig;
|
||||||
headers.Authorization = makeBearer(accessStore.token);
|
const isGoogle = modelConfig.providerName === ServiceProvider.Google;
|
||||||
} else if (
|
const isAzure = modelConfig.providerName === ServiceProvider.Azure;
|
||||||
accessStore.enabledAccessControl() &&
|
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
|
||||||
validString(accessStore.accessCode)
|
const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
|
||||||
) {
|
const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
|
||||||
headers.Authorization = makeBearer(
|
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
|
||||||
|
const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
|
||||||
|
const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
|
||||||
|
const isEnabledAccessControl = accessStore.enabledAccessControl();
|
||||||
|
const apiKey = isGoogle
|
||||||
|
? accessStore.googleApiKey
|
||||||
|
: isAzure
|
||||||
|
? accessStore.azureApiKey
|
||||||
|
: isAnthropic
|
||||||
|
? accessStore.anthropicApiKey
|
||||||
|
: isByteDance
|
||||||
|
? accessStore.bytedanceApiKey
|
||||||
|
: isAlibaba
|
||||||
|
? accessStore.alibabaApiKey
|
||||||
|
: isMoonshot
|
||||||
|
? accessStore.moonshotApiKey
|
||||||
|
: isIflytek
|
||||||
|
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
|
||||||
|
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
|
||||||
|
: ""
|
||||||
|
: accessStore.openaiApiKey;
|
||||||
|
return {
|
||||||
|
isGoogle,
|
||||||
|
isAzure,
|
||||||
|
isAnthropic,
|
||||||
|
isBaidu,
|
||||||
|
isByteDance,
|
||||||
|
isAlibaba,
|
||||||
|
isMoonshot,
|
||||||
|
isIflytek,
|
||||||
|
apiKey,
|
||||||
|
isEnabledAccessControl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeader(): string {
|
||||||
|
return isAzure
|
||||||
|
? "api-key"
|
||||||
|
: isAnthropic
|
||||||
|
? "x-api-key"
|
||||||
|
: isGoogle
|
||||||
|
? "x-goog-api-key"
|
||||||
|
: "Authorization";
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
isGoogle,
|
||||||
|
isAzure,
|
||||||
|
isAnthropic,
|
||||||
|
isBaidu,
|
||||||
|
apiKey,
|
||||||
|
isEnabledAccessControl,
|
||||||
|
} = getConfig();
|
||||||
|
// when using baidu api in app, not set auth header
|
||||||
|
if (isBaidu && clientConfig?.isApp) return headers;
|
||||||
|
|
||||||
|
const authHeader = getAuthHeader();
|
||||||
|
|
||||||
|
const bearerToken = getBearerToken(
|
||||||
|
apiKey,
|
||||||
|
isAzure || isAnthropic || isGoogle,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bearerToken) {
|
||||||
|
headers[authHeader] = bearerToken;
|
||||||
|
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
|
||||||
|
headers["Authorization"] = getBearerToken(
|
||||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClientApi(provider: ServiceProvider): ClientApi {
|
||||||
|
switch (provider) {
|
||||||
|
case ServiceProvider.Google:
|
||||||
|
return new ClientApi(ModelProvider.GeminiPro);
|
||||||
|
case ServiceProvider.Anthropic:
|
||||||
|
return new ClientApi(ModelProvider.Claude);
|
||||||
|
case ServiceProvider.Baidu:
|
||||||
|
return new ClientApi(ModelProvider.Ernie);
|
||||||
|
case ServiceProvider.ByteDance:
|
||||||
|
return new ClientApi(ModelProvider.Doubao);
|
||||||
|
case ServiceProvider.Alibaba:
|
||||||
|
return new ClientApi(ModelProvider.Qwen);
|
||||||
|
case ServiceProvider.Tencent:
|
||||||
|
return new ClientApi(ModelProvider.Hunyuan);
|
||||||
|
case ServiceProvider.Moonshot:
|
||||||
|
return new ClientApi(ModelProvider.Moonshot);
|
||||||
|
case ServiceProvider.Iflytek:
|
||||||
|
return new ClientApi(ModelProvider.Iflytek);
|
||||||
|
default:
|
||||||
|
return new ClientApi(ModelProvider.GPT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ export const ChatControllerPool = {
|
|||||||
controllers: {} as Record<string, AbortController>,
|
controllers: {} as Record<string, AbortController>,
|
||||||
|
|
||||||
addController(
|
addController(
|
||||||
sessionIndex: number,
|
sessionId: string,
|
||||||
messageId: number,
|
messageId: string,
|
||||||
controller: AbortController,
|
controller: AbortController,
|
||||||
) {
|
) {
|
||||||
const key = this.key(sessionIndex, messageId);
|
const key = this.key(sessionId, messageId);
|
||||||
this.controllers[key] = controller;
|
this.controllers[key] = controller;
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
|
|
||||||
stop(sessionIndex: number, messageId: number) {
|
stop(sessionId: string, messageId: string) {
|
||||||
const key = this.key(sessionIndex, messageId);
|
const key = this.key(sessionId, messageId);
|
||||||
const controller = this.controllers[key];
|
const controller = this.controllers[key];
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
},
|
},
|
||||||
@@ -26,12 +26,12 @@ export const ChatControllerPool = {
|
|||||||
return Object.values(this.controllers).length > 0;
|
return Object.values(this.controllers).length > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
remove(sessionIndex: number, messageId: number) {
|
remove(sessionId: string, messageId: string) {
|
||||||
const key = this.key(sessionIndex, messageId);
|
const key = this.key(sessionId, messageId);
|
||||||
delete this.controllers[key];
|
delete this.controllers[key];
|
||||||
},
|
},
|
||||||
|
|
||||||
key(sessionIndex: number, messageIndex: number) {
|
key(sessionId: string, messageIndex: string) {
|
||||||
return `${sessionIndex},${messageIndex}`;
|
return `${sessionId},${messageIndex}`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
275
app/client/platforms/alibaba.ts
Normal file
275
app/client/platforms/alibaba.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
ApiPath,
|
||||||
|
Alibaba,
|
||||||
|
ALIBABA_BASE_URL,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
SpeechOptions,
|
||||||
|
MultimodalContent,
|
||||||
|
} from "../api";
|
||||||
|
import Locale from "../../locales";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
export interface OpenAIListModelResponse {
|
||||||
|
object: string;
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
root: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestInput {
|
||||||
|
messages: {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string | MultimodalContent[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
interface RequestParam {
|
||||||
|
result_format: string;
|
||||||
|
incremental_output?: boolean;
|
||||||
|
temperature: number;
|
||||||
|
repetition_penalty?: number;
|
||||||
|
top_p: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
interface RequestPayload {
|
||||||
|
model: string;
|
||||||
|
input: RequestInput;
|
||||||
|
parameters: RequestParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QwenApi implements LLMApi {
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.alibabaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
baseUrl = isApp ? ALIBABA_BASE_URL : ApiPath.Alibaba;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Alibaba)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
return [baseUrl, path].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
extractMessage(res: any) {
|
||||||
|
return res?.output?.choices?.at(0)?.message?.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions) {
|
||||||
|
const messages = options.messages.map((v) => ({
|
||||||
|
role: v.role,
|
||||||
|
content: getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
model: modelConfig.model,
|
||||||
|
input: {
|
||||||
|
messages,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
result_format: "message",
|
||||||
|
incremental_output: shouldStream,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
// max_tokens: modelConfig.max_tokens,
|
||||||
|
top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatPath = this.path(Alibaba.ChatPath);
|
||||||
|
const chatPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
...getHeaders(),
|
||||||
|
"X-DashScope-SSE": shouldStream ? "enable" : "disable",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldStream) {
|
||||||
|
let responseText = "";
|
||||||
|
let remainText = "";
|
||||||
|
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 = () => {
|
||||||
|
if (!finished) {
|
||||||
|
finished = true;
|
||||||
|
options.onFinish(responseText + remainText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
|
...chatPayload,
|
||||||
|
async onopen(res) {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log(
|
||||||
|
"[Alibaba] request response content type: ",
|
||||||
|
contentType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
responseText = await res.clone().text();
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [responseText];
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]" || finished) {
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.output.choices as Array<{
|
||||||
|
message: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.message?.content;
|
||||||
|
if (delta) {
|
||||||
|
remainText += delta;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await fetch(chatPath, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = this.extractMessage(resJson);
|
||||||
|
options.onFinish(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async usage() {
|
||||||
|
return {
|
||||||
|
used: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { Alibaba };
|
||||||
413
app/client/platforms/anthropic.ts
Normal file
413
app/client/platforms/anthropic.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { Anthropic, ApiPath } from "@/app/constant";
|
||||||
|
import { ChatOptions, getHeaders, LLMApi, SpeechOptions } from "../api";
|
||||||
|
import {
|
||||||
|
useAccessStore,
|
||||||
|
useAppConfig,
|
||||||
|
useChatStore,
|
||||||
|
usePluginStore,
|
||||||
|
ChatMessageTool,
|
||||||
|
} from "@/app/store";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { ANTHROPIC_BASE_URL } from "@/app/constant";
|
||||||
|
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||||
|
import { preProcessImageContent, stream } from "@/app/utils/chat";
|
||||||
|
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||||
|
import { RequestPayload } from "./openai";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// try get base64image from local cache image_url
|
||||||
|
const messages: ChatOptions["messages"] = [];
|
||||||
|
for (const v of options.messages) {
|
||||||
|
const content = await preProcessImageContent(v.content);
|
||||||
|
messages.push({ role: v.role, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (shouldStream) {
|
||||||
|
let index = -1;
|
||||||
|
const [tools, funcs] = usePluginStore
|
||||||
|
.getState()
|
||||||
|
.getAsTools(
|
||||||
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
|
);
|
||||||
|
return stream(
|
||||||
|
path,
|
||||||
|
requestBody,
|
||||||
|
{
|
||||||
|
...getHeaders(),
|
||||||
|
"anthropic-version": accessStore.anthropicApiVersion,
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
tools.map((tool) => ({
|
||||||
|
name: tool?.function?.name,
|
||||||
|
description: tool?.function?.description,
|
||||||
|
input_schema: tool?.function?.parameters,
|
||||||
|
})),
|
||||||
|
funcs,
|
||||||
|
controller,
|
||||||
|
// parseSSE
|
||||||
|
(text: string, runTools: ChatMessageTool[]) => {
|
||||||
|
// console.log("parseSSE", text, runTools);
|
||||||
|
let chunkJson:
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
type: "content_block_delta" | "content_block_stop";
|
||||||
|
content_block?: {
|
||||||
|
type: "tool_use";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
delta?: {
|
||||||
|
type: "text_delta" | "input_json_delta";
|
||||||
|
text?: string;
|
||||||
|
partial_json?: string;
|
||||||
|
};
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
chunkJson = JSON.parse(text);
|
||||||
|
|
||||||
|
if (chunkJson?.content_block?.type == "tool_use") {
|
||||||
|
index += 1;
|
||||||
|
const id = chunkJson?.content_block.id;
|
||||||
|
const name = chunkJson?.content_block.name;
|
||||||
|
runTools.push({
|
||||||
|
id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
arguments: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
chunkJson?.delta?.type == "input_json_delta" &&
|
||||||
|
chunkJson?.delta?.partial_json
|
||||||
|
) {
|
||||||
|
// @ts-ignore
|
||||||
|
runTools[index]["function"]["arguments"] +=
|
||||||
|
chunkJson?.delta?.partial_json;
|
||||||
|
}
|
||||||
|
return chunkJson?.delta?.text;
|
||||||
|
},
|
||||||
|
// processToolMessage, include tool_calls message and tool call results
|
||||||
|
(
|
||||||
|
requestPayload: RequestPayload,
|
||||||
|
toolCallMessage: any,
|
||||||
|
toolCallResult: any[],
|
||||||
|
) => {
|
||||||
|
// reset index value
|
||||||
|
index = -1;
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.messages?.splice(
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.messages?.length,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: toolCallMessage.tool_calls.map(
|
||||||
|
(tool: ChatMessageTool) => ({
|
||||||
|
type: "tool_use",
|
||||||
|
id: tool.id,
|
||||||
|
name: tool?.function?.name,
|
||||||
|
input: tool?.function?.arguments
|
||||||
|
? JSON.parse(tool?.function?.arguments)
|
||||||
|
: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
...toolCallResult.map((result) => ({
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: result.tool_call_id,
|
||||||
|
content: result.content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const payload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
...getHeaders(), // get common headers
|
||||||
|
"anthropic-version": accessStore.anthropicApiVersion,
|
||||||
|
// do not send `anthropicApiKey` in browser!!!
|
||||||
|
// Authorization: getAuthKey(accessStore.anthropicApiKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ? ANTHROPIC_BASE_URL : ApiPath.Anthropic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl = trimEnd(baseUrl, "/");
|
||||||
|
|
||||||
|
// try rebuild url, when using cloudflare ai gateway in client
|
||||||
|
return cloudflareAIGatewayUrl(`${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;
|
||||||
|
}
|
||||||
288
app/client/platforms/baidu.ts
Normal file
288
app/client/platforms/baidu.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
ApiPath,
|
||||||
|
Baidu,
|
||||||
|
BAIDU_BASE_URL,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
import { getAccessToken } from "@/app/utils/baidu";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
MultimodalContent,
|
||||||
|
SpeechOptions,
|
||||||
|
} from "../api";
|
||||||
|
import Locale from "../../locales";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
export interface OpenAIListModelResponse {
|
||||||
|
object: string;
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
root: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ErnieApi implements LLMApi {
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.baiduUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
// do not use proxy for baidubce api
|
||||||
|
baseUrl = isApp ? BAIDU_BASE_URL : ApiPath.Baidu;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Baidu)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
return [baseUrl, path].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions) {
|
||||||
|
const messages = options.messages.map((v) => ({
|
||||||
|
// "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function",
|
||||||
|
role: v.role === "system" ? "user" : v.role,
|
||||||
|
content: getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// "error_code": 336006, "error_msg": "the length of messages must be an odd number",
|
||||||
|
if (messages.length % 2 === 0) {
|
||||||
|
if (messages.at(0)?.role === "user") {
|
||||||
|
messages.splice(1, 0, {
|
||||||
|
role: "assistant",
|
||||||
|
content: " ",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.unshift({
|
||||||
|
role: "user",
|
||||||
|
content: " ",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
messages,
|
||||||
|
stream: shouldStream,
|
||||||
|
model: modelConfig.model,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
presence_penalty: modelConfig.presence_penalty,
|
||||||
|
frequency_penalty: modelConfig.frequency_penalty,
|
||||||
|
top_p: modelConfig.top_p,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Request] Baidu payload: ", requestPayload);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let chatPath = this.path(Baidu.ChatPath(modelConfig.model));
|
||||||
|
|
||||||
|
// getAccessToken can not run in browser, because cors error
|
||||||
|
if (!!getClientConfig()?.isApp) {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
if (accessStore.isValidBaidu()) {
|
||||||
|
const { access_token } = await getAccessToken(
|
||||||
|
accessStore.baiduApiKey,
|
||||||
|
accessStore.baiduSecretKey,
|
||||||
|
);
|
||||||
|
chatPath = `${chatPath}${
|
||||||
|
chatPath.includes("?") ? "&" : "?"
|
||||||
|
}access_token=${access_token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 = () => {
|
||||||
|
if (!finished) {
|
||||||
|
finished = true;
|
||||||
|
options.onFinish(responseText + remainText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
|
...chatPayload,
|
||||||
|
async onopen(res) {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("[Baidu] request response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
responseText = await res.clone().text();
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [responseText];
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]" || finished) {
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const delta = json?.result;
|
||||||
|
if (delta) {
|
||||||
|
remainText += delta;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await fetch(chatPath, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = resJson?.result;
|
||||||
|
options.onFinish(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async usage() {
|
||||||
|
return {
|
||||||
|
used: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { Baidu };
|
||||||
262
app/client/platforms/bytedance.ts
Normal file
262
app/client/platforms/bytedance.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
ApiPath,
|
||||||
|
ByteDance,
|
||||||
|
BYTEDANCE_BASE_URL,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
MultimodalContent,
|
||||||
|
SpeechOptions,
|
||||||
|
} from "../api";
|
||||||
|
import Locale from "../../locales";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
export interface OpenAIListModelResponse {
|
||||||
|
object: string;
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
root: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 DoubaoApi implements LLMApi {
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.bytedanceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
baseUrl = isApp ? BYTEDANCE_BASE_URL : ApiPath.ByteDance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ByteDance)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
return [baseUrl, path].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
extractMessage(res: any) {
|
||||||
|
return res.choices?.at(0)?.message?.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions) {
|
||||||
|
const messages = options.messages.map((v) => ({
|
||||||
|
role: v.role,
|
||||||
|
content: getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
messages,
|
||||||
|
stream: shouldStream,
|
||||||
|
model: modelConfig.model,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
presence_penalty: modelConfig.presence_penalty,
|
||||||
|
frequency_penalty: modelConfig.frequency_penalty,
|
||||||
|
top_p: modelConfig.top_p,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatPath = this.path(ByteDance.ChatPath);
|
||||||
|
const chatPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: getHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldStream) {
|
||||||
|
let responseText = "";
|
||||||
|
let remainText = "";
|
||||||
|
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 = () => {
|
||||||
|
if (!finished) {
|
||||||
|
finished = true;
|
||||||
|
options.onFinish(responseText + remainText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
|
...chatPayload,
|
||||||
|
async onopen(res) {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log(
|
||||||
|
"[ByteDance] request response content type: ",
|
||||||
|
contentType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
responseText = await res.clone().text();
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [responseText];
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]" || finished) {
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.choices as Array<{
|
||||||
|
delta: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
remainText += delta;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await fetch(chatPath, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = this.extractMessage(resJson);
|
||||||
|
options.onFinish(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async usage() {
|
||||||
|
return {
|
||||||
|
used: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { ByteDance };
|
||||||
287
app/client/platforms/google.ts
Normal file
287
app/client/platforms/google.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
LLMUsage,
|
||||||
|
SpeechOptions,
|
||||||
|
} from "../api";
|
||||||
|
import {
|
||||||
|
useAccessStore,
|
||||||
|
useAppConfig,
|
||||||
|
useChatStore,
|
||||||
|
usePluginStore,
|
||||||
|
ChatMessageTool,
|
||||||
|
} from "@/app/store";
|
||||||
|
import { stream } from "@/app/utils/chat";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { GEMINI_BASE_URL } from "@/app/constant";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMessageTextContent,
|
||||||
|
getMessageImages,
|
||||||
|
isVisionModel,
|
||||||
|
} from "@/app/utils";
|
||||||
|
import { preProcessImageContent } from "@/app/utils/chat";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { RequestPayload } from "./openai";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
export class GeminiProApi implements LLMApi {
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.googleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
baseUrl = isApp ? GEMINI_BASE_URL : ApiPath.Google;
|
||||||
|
}
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Google)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
let chatPath = [baseUrl, path].join("/");
|
||||||
|
|
||||||
|
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
||||||
|
return chatPath;
|
||||||
|
}
|
||||||
|
extractMessage(res: any) {
|
||||||
|
console.log("[Response] gemini-pro response: ", res);
|
||||||
|
|
||||||
|
return (
|
||||||
|
res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
|
||||||
|
res?.error?.message ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions): Promise<void> {
|
||||||
|
const apiClient = this;
|
||||||
|
let multimodal = false;
|
||||||
|
|
||||||
|
// try get base64image from local cache image_url
|
||||||
|
const _messages: ChatOptions["messages"] = [];
|
||||||
|
for (const v of options.messages) {
|
||||||
|
const content = await preProcessImageContent(v.content);
|
||||||
|
_messages.push({ role: v.role, content });
|
||||||
|
}
|
||||||
|
const messages = _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 accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
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: accessStore.googleSafetySettings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||||
|
threshold: accessStore.googleSafetySettings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||||
|
threshold: accessStore.googleSafetySettings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||||
|
threshold: accessStore.googleSafetySettings,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let shouldStream = !!options.config.stream;
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
try {
|
||||||
|
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
|
||||||
|
const chatPath = this.path(Google.ChatPath(modelConfig.model));
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const [tools, funcs] = usePluginStore
|
||||||
|
.getState()
|
||||||
|
.getAsTools(
|
||||||
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
|
);
|
||||||
|
return stream(
|
||||||
|
chatPath,
|
||||||
|
requestPayload,
|
||||||
|
getHeaders(),
|
||||||
|
// @ts-ignore
|
||||||
|
[{ functionDeclarations: tools.map((tool) => tool.function) }],
|
||||||
|
funcs,
|
||||||
|
controller,
|
||||||
|
// parseSSE
|
||||||
|
(text: string, runTools: ChatMessageTool[]) => {
|
||||||
|
// console.log("parseSSE", text, runTools);
|
||||||
|
const chunkJson = JSON.parse(text);
|
||||||
|
|
||||||
|
const functionCall = chunkJson?.candidates
|
||||||
|
?.at(0)
|
||||||
|
?.content.parts.at(0)?.functionCall;
|
||||||
|
if (functionCall) {
|
||||||
|
const { name, args } = functionCall;
|
||||||
|
runTools.push({
|
||||||
|
id: nanoid(),
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
arguments: JSON.stringify(args), // utils.chat call function, using JSON.parse
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return chunkJson?.candidates?.at(0)?.content.parts.at(0)?.text;
|
||||||
|
},
|
||||||
|
// processToolMessage, include tool_calls message and tool call results
|
||||||
|
(
|
||||||
|
requestPayload: RequestPayload,
|
||||||
|
toolCallMessage: any,
|
||||||
|
toolCallResult: any[],
|
||||||
|
) => {
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.contents?.splice(
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.contents?.length,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: toolCallMessage.tool_calls.map(
|
||||||
|
(tool: ChatMessageTool) => ({
|
||||||
|
functionCall: {
|
||||||
|
name: tool?.function?.name,
|
||||||
|
args: JSON.parse(tool?.function?.arguments as string),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
...toolCallResult.map((result) => ({
|
||||||
|
role: "function",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: result.name,
|
||||||
|
response: {
|
||||||
|
name: result.name,
|
||||||
|
content: result.content, // TODO just text content...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const res = await fetch(chatPath, 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 = apiClient.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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
252
app/client/platforms/iflytek.ts
Normal file
252
app/client/platforms/iflytek.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
ApiPath,
|
||||||
|
IFLYTEK_BASE_URL,
|
||||||
|
Iflytek,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
SpeechOptions,
|
||||||
|
} from "../api";
|
||||||
|
import Locale from "../../locales";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
import { RequestPayload } from "./openai";
|
||||||
|
|
||||||
|
export class SparkApi implements LLMApi {
|
||||||
|
private disableListModels = true;
|
||||||
|
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.iflytekUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
const apiPath = ApiPath.Iflytek;
|
||||||
|
baseUrl = isApp ? IFLYTEK_BASE_URL : apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Iflytek)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
return [baseUrl, path].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
extractMessage(res: any) {
|
||||||
|
return res.choices?.at(0)?.message?.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions) {
|
||||||
|
const messages: ChatOptions["messages"] = [];
|
||||||
|
for (const v of options.messages) {
|
||||||
|
const content = getMessageTextContent(v);
|
||||||
|
messages.push({ role: v.role, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
providerName: options.config.providerName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
messages,
|
||||||
|
stream: options.config.stream,
|
||||||
|
model: modelConfig.model,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
presence_penalty: modelConfig.presence_penalty,
|
||||||
|
frequency_penalty: modelConfig.frequency_penalty,
|
||||||
|
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.
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Request] Spark payload: ", requestPayload);
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatPath = this.path(Iflytek.ChatPath);
|
||||||
|
const chatPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: getHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldStream) {
|
||||||
|
let responseText = "";
|
||||||
|
let remainText = "";
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
// Animate response text to make it look smooth
|
||||||
|
function animateResponseText() {
|
||||||
|
if (finished || controller.signal.aborted) {
|
||||||
|
responseText += remainText;
|
||||||
|
console.log("[Response Animation] finished");
|
||||||
|
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 animation
|
||||||
|
animateResponseText();
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (!finished) {
|
||||||
|
finished = true;
|
||||||
|
options.onFinish(responseText + remainText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
|
...chatPayload,
|
||||||
|
async onopen(res) {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("[Spark] request response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
responseText = await res.clone().text();
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different error scenarios
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
extraInfo = Locale.Error.Unauthorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onError?.(
|
||||||
|
new Error(
|
||||||
|
`Request failed with status ${res.status}: ${extraInfo}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]" || finished) {
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
remainText += delta;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text);
|
||||||
|
options.onError?.(new Error(`Failed to parse response: ${text}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await fetch(chatPath, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
options.onError?.(
|
||||||
|
new Error(`Request failed with status ${res.status}: ${errorText}`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async usage() {
|
||||||
|
return {
|
||||||
|
used: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
199
app/client/platforms/moonshot.ts
Normal file
199
app/client/platforms/moonshot.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"use client";
|
||||||
|
// azure and openai, using same models. so using same LLMApi.
|
||||||
|
import {
|
||||||
|
ApiPath,
|
||||||
|
MOONSHOT_BASE_URL,
|
||||||
|
Moonshot,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import {
|
||||||
|
useAccessStore,
|
||||||
|
useAppConfig,
|
||||||
|
useChatStore,
|
||||||
|
ChatMessageTool,
|
||||||
|
usePluginStore,
|
||||||
|
} from "@/app/store";
|
||||||
|
import { stream } from "@/app/utils/chat";
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
SpeechOptions,
|
||||||
|
} from "../api";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { RequestPayload } from "./openai";
|
||||||
|
|
||||||
|
export class MoonshotApi implements LLMApi {
|
||||||
|
private disableListModels = true;
|
||||||
|
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.moonshotUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
const apiPath = ApiPath.Moonshot;
|
||||||
|
baseUrl = isApp ? MOONSHOT_BASE_URL : apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
return [baseUrl, path].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
extractMessage(res: any) {
|
||||||
|
return res.choices?.at(0)?.message?.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions) {
|
||||||
|
const messages: ChatOptions["messages"] = [];
|
||||||
|
for (const v of options.messages) {
|
||||||
|
const content = getMessageTextContent(v);
|
||||||
|
messages.push({ role: v.role, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
providerName: options.config.providerName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPayload: RequestPayload = {
|
||||||
|
messages,
|
||||||
|
stream: options.config.stream,
|
||||||
|
model: modelConfig.model,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
presence_penalty: modelConfig.presence_penalty,
|
||||||
|
frequency_penalty: modelConfig.frequency_penalty,
|
||||||
|
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.
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatPath = this.path(Moonshot.ChatPath);
|
||||||
|
const chatPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: getHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldStream) {
|
||||||
|
const [tools, funcs] = usePluginStore
|
||||||
|
.getState()
|
||||||
|
.getAsTools(
|
||||||
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
|
);
|
||||||
|
return stream(
|
||||||
|
chatPath,
|
||||||
|
requestPayload,
|
||||||
|
getHeaders(),
|
||||||
|
tools as any,
|
||||||
|
funcs,
|
||||||
|
controller,
|
||||||
|
// parseSSE
|
||||||
|
(text: string, runTools: ChatMessageTool[]) => {
|
||||||
|
// console.log("parseSSE", text, runTools);
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.choices as Array<{
|
||||||
|
delta: {
|
||||||
|
content: string;
|
||||||
|
tool_calls: ChatMessageTool[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||||
|
if (tool_calls?.length > 0) {
|
||||||
|
const index = tool_calls[0]?.index;
|
||||||
|
const id = tool_calls[0]?.id;
|
||||||
|
const args = tool_calls[0]?.function?.arguments;
|
||||||
|
if (id) {
|
||||||
|
runTools.push({
|
||||||
|
id,
|
||||||
|
type: tool_calls[0]?.type,
|
||||||
|
function: {
|
||||||
|
name: tool_calls[0]?.function?.name as string,
|
||||||
|
arguments: args,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
runTools[index]["function"]["arguments"] += args;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choices[0]?.delta?.content;
|
||||||
|
},
|
||||||
|
// processToolMessage, include tool_calls message and tool call results
|
||||||
|
(
|
||||||
|
requestPayload: RequestPayload,
|
||||||
|
toolCallMessage: any,
|
||||||
|
toolCallResult: any[],
|
||||||
|
) => {
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.messages?.splice(
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.messages?.length,
|
||||||
|
0,
|
||||||
|
toolCallMessage,
|
||||||
|
...toolCallResult,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const res = await fetch(chatPath, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = this.extractMessage(resJson);
|
||||||
|
options.onFinish(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async usage() {
|
||||||
|
return {
|
||||||
|
used: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,59 +1,164 @@
|
|||||||
import { OpenaiPath, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
"use client";
|
||||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
// azure and openai, using same models. so using same LLMApi.
|
||||||
|
|
||||||
import { ChatOptions, getHeaders, LLMApi, LLMUsage } from "../api";
|
|
||||||
import Locale from "../../locales";
|
|
||||||
import {
|
import {
|
||||||
EventStreamContentType,
|
ApiPath,
|
||||||
fetchEventSource,
|
OPENAI_BASE_URL,
|
||||||
} from "@fortaine/fetch-event-source";
|
DEFAULT_MODELS,
|
||||||
import { prettyObject } from "@/app/utils/format";
|
OpenaiPath,
|
||||||
|
Azure,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
ServiceProvider,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import {
|
||||||
|
ChatMessageTool,
|
||||||
|
useAccessStore,
|
||||||
|
useAppConfig,
|
||||||
|
useChatStore,
|
||||||
|
usePluginStore,
|
||||||
|
} from "@/app/store";
|
||||||
|
import { collectModelsWithDefaultModel } from "@/app/utils/model";
|
||||||
|
import {
|
||||||
|
preProcessImageContent,
|
||||||
|
uploadImage,
|
||||||
|
base64Image2Blob,
|
||||||
|
stream,
|
||||||
|
} from "@/app/utils/chat";
|
||||||
|
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||||
|
import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
LLMUsage,
|
||||||
|
MultimodalContent,
|
||||||
|
SpeechOptions,
|
||||||
|
} from "../api";
|
||||||
|
import Locale from "../../locales";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import {
|
||||||
|
getMessageTextContent,
|
||||||
|
isVisionModel,
|
||||||
|
isDalle3 as _isDalle3,
|
||||||
|
} from "@/app/utils";
|
||||||
|
|
||||||
|
export interface OpenAIListModelResponse {
|
||||||
|
object: string;
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
root: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 interface DalleRequestPayload {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
response_format: "url" | "b64_json";
|
||||||
|
n: number;
|
||||||
|
size: DalleSize;
|
||||||
|
quality: DalleQuality;
|
||||||
|
style: DalleStyle;
|
||||||
|
}
|
||||||
|
|
||||||
export class ChatGPTApi implements LLMApi {
|
export class ChatGPTApi implements LLMApi {
|
||||||
|
private disableListModels = true;
|
||||||
|
|
||||||
path(path: string): string {
|
path(path: string): string {
|
||||||
let openaiUrl = useAccessStore.getState().openaiUrl;
|
const accessStore = useAccessStore.getState();
|
||||||
if (openaiUrl.endsWith("/")) {
|
|
||||||
openaiUrl = openaiUrl.slice(0, openaiUrl.length - 1);
|
let baseUrl = "";
|
||||||
}
|
|
||||||
return [openaiUrl, path].join("/");
|
const isAzure = path.includes("deployments");
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
if (isAzure && !accessStore.isValidAzure()) {
|
||||||
|
throw Error(
|
||||||
|
"incomplete azure config, please check it in your settings page",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
extractMessage(res: any) {
|
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||||
return res.choices?.at(0)?.message?.content ?? "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions) {
|
if (baseUrl.length === 0) {
|
||||||
const messages = options.messages.map((v) => ({
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
role: v.role,
|
const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
|
||||||
content: v.content,
|
baseUrl = isApp ? OPENAI_BASE_URL : apiPath;
|
||||||
}));
|
}
|
||||||
|
|
||||||
const modelConfig = {
|
if (baseUrl.endsWith("/")) {
|
||||||
...useAppConfig.getState().modelConfig,
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
}
|
||||||
...{
|
if (
|
||||||
model: options.config.model,
|
!baseUrl.startsWith("http") &&
|
||||||
|
!isAzure &&
|
||||||
|
!baseUrl.startsWith(ApiPath.OpenAI)
|
||||||
|
) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
|
// try rebuild url, when using cloudflare ai gateway in client
|
||||||
|
return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractMessage(res: any) {
|
||||||
|
if (res.error) {
|
||||||
|
return "```\n" + JSON.stringify(res, null, 4) + "\n```";
|
||||||
|
}
|
||||||
|
// dalle3 model return url, using url create image message
|
||||||
|
if (res.data) {
|
||||||
|
let url = res.data?.at(0)?.url ?? "";
|
||||||
|
const b64_json = res.data?.at(0)?.b64_json ?? "";
|
||||||
|
if (!url && b64_json) {
|
||||||
|
// uploadImage
|
||||||
|
url = await uploadImage(base64Image2Blob(b64_json, "image/png"));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url,
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return res.choices?.at(0)?.message?.content ?? res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
messages,
|
model: options.model,
|
||||||
stream: options.config.stream,
|
input: options.input,
|
||||||
model: modelConfig.model,
|
voice: options.voice,
|
||||||
temperature: modelConfig.temperature,
|
response_format: options.response_format,
|
||||||
presence_penalty: modelConfig.presence_penalty,
|
speed: options.speed,
|
||||||
frequency_penalty: modelConfig.frequency_penalty,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[Request] openai payload: ", requestPayload);
|
console.log("[Request] openai speech payload: ", requestPayload);
|
||||||
|
|
||||||
const shouldStream = !!options.config.stream;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
options.onController?.(controller);
|
options.onController?.(controller);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chatPath = this.path(OpenaiPath.ChatPath);
|
const speechPath = this.path(OpenaiPath.SpeechPath);
|
||||||
const chatPayload = {
|
const speechPayload = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(requestPayload),
|
body: JSON.stringify(requestPayload),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -66,96 +171,199 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldStream) {
|
const res = await fetch(speechPath, speechPayload);
|
||||||
let responseText = "";
|
clearTimeout(requestTimeoutId);
|
||||||
let finished = false;
|
return await res.arrayBuffer();
|
||||||
|
} catch (e) {
|
||||||
const finish = () => {
|
console.log("[Request] failed to make a speech request", e);
|
||||||
if (!finished) {
|
throw e;
|
||||||
options.onFinish(responseText);
|
|
||||||
finished = true;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions) {
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
providerName: options.config.providerName,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.signal.onabort = finish;
|
let requestPayload: RequestPayload | DalleRequestPayload;
|
||||||
|
|
||||||
fetchEventSource(chatPath, {
|
const isDalle3 = _isDalle3(options.config.model);
|
||||||
...chatPayload,
|
const isO1 = options.config.model.startsWith("o1");
|
||||||
async onopen(res) {
|
if (isDalle3) {
|
||||||
clearTimeout(requestTimeoutId);
|
const prompt = getMessageTextContent(
|
||||||
const contentType = res.headers.get("content-type");
|
options.messages.slice(-1)?.pop() as any,
|
||||||
console.log(
|
|
||||||
"[OpenAI] request response content type: ",
|
|
||||||
contentType,
|
|
||||||
);
|
);
|
||||||
|
requestPayload = {
|
||||||
if (contentType?.startsWith("text/plain")) {
|
model: options.config.model,
|
||||||
responseText = await res.clone().text();
|
prompt,
|
||||||
return finish();
|
// URLs are only valid for 60 minutes after the image has been generated.
|
||||||
|
response_format: "b64_json", // using b64_json, and save image in CacheStorage
|
||||||
|
n: 1,
|
||||||
|
size: options.config?.size ?? "1024x1024",
|
||||||
|
quality: options.config?.quality ?? "standard",
|
||||||
|
style: options.config?.style ?? "vivid",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const visionModel = isVisionModel(options.config.model);
|
||||||
|
const messages: ChatOptions["messages"] = [];
|
||||||
|
for (const v of options.messages) {
|
||||||
|
const content = visionModel
|
||||||
|
? await preProcessImageContent(v.content)
|
||||||
|
: getMessageTextContent(v);
|
||||||
|
if (!(isO1 && v.role === "system"))
|
||||||
|
messages.push({ role: v.role, content });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
|
||||||
!res.ok ||
|
requestPayload = {
|
||||||
!res.headers
|
messages,
|
||||||
.get("content-type")
|
stream: !isO1 ? options.config.stream : false,
|
||||||
?.startsWith(EventStreamContentType) ||
|
model: modelConfig.model,
|
||||||
res.status !== 200
|
temperature: !isO1 ? modelConfig.temperature : 1,
|
||||||
) {
|
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
|
||||||
const responseTexts = [responseText];
|
frequency_penalty: !isO1 ? modelConfig.frequency_penalty : 0,
|
||||||
let extraInfo = await res.clone().text();
|
top_p: !isO1 ? modelConfig.top_p : 1,
|
||||||
|
// 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) {
|
||||||
|
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
|
const shouldStream = !isDalle3 && !!options.config.stream && !isO1;
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resJson = await res.clone().json();
|
let chatPath = "";
|
||||||
extraInfo = prettyObject(resJson);
|
if (modelConfig.providerName === ServiceProvider.Azure) {
|
||||||
} catch {}
|
// find model, and get displayName as deployName
|
||||||
|
const { models: configModels, customModels: configCustomModels } =
|
||||||
if (res.status === 401) {
|
useAppConfig.getState();
|
||||||
responseTexts.push(Locale.Error.Unauthorized);
|
const {
|
||||||
|
defaultModel,
|
||||||
|
customModels: accessCustomModels,
|
||||||
|
useCustomConfig,
|
||||||
|
} = useAccessStore.getState();
|
||||||
|
const models = collectModelsWithDefaultModel(
|
||||||
|
configModels,
|
||||||
|
[configCustomModels, accessCustomModels].join(","),
|
||||||
|
defaultModel,
|
||||||
|
);
|
||||||
|
const model = models.find(
|
||||||
|
(model) =>
|
||||||
|
model.name === modelConfig.model &&
|
||||||
|
model?.provider?.providerName === ServiceProvider.Azure,
|
||||||
|
);
|
||||||
|
chatPath = this.path(
|
||||||
|
(isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
|
||||||
|
(model?.displayName ?? model?.name) as string,
|
||||||
|
useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
chatPath = this.path(
|
||||||
|
isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
if (shouldStream) {
|
||||||
if (extraInfo) {
|
let index = -1;
|
||||||
responseTexts.push(extraInfo);
|
const [tools, funcs] = usePluginStore
|
||||||
}
|
.getState()
|
||||||
|
.getAsTools(
|
||||||
responseText = responseTexts.join("\n\n");
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
|
);
|
||||||
return finish();
|
// console.log("getAsTools", tools, funcs);
|
||||||
}
|
stream(
|
||||||
},
|
chatPath,
|
||||||
onmessage(msg) {
|
requestPayload,
|
||||||
if (msg.data === "[DONE]" || finished) {
|
getHeaders(),
|
||||||
return finish();
|
tools as any,
|
||||||
}
|
funcs,
|
||||||
const text = msg.data;
|
controller,
|
||||||
try {
|
// parseSSE
|
||||||
|
(text: string, runTools: ChatMessageTool[]) => {
|
||||||
|
// console.log("parseSSE", text, runTools);
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
const delta = json.choices[0].delta.content;
|
const choices = json.choices as Array<{
|
||||||
if (delta) {
|
delta: {
|
||||||
responseText += delta;
|
content: string;
|
||||||
options.onUpdate?.(responseText, delta);
|
tool_calls: ChatMessageTool[];
|
||||||
}
|
};
|
||||||
} catch (e) {
|
}>;
|
||||||
console.error("[Request] parse error", text, msg);
|
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||||
}
|
if (tool_calls?.length > 0) {
|
||||||
|
const id = tool_calls[0]?.id;
|
||||||
|
const args = tool_calls[0]?.function?.arguments;
|
||||||
|
if (id) {
|
||||||
|
index += 1;
|
||||||
|
runTools.push({
|
||||||
|
id,
|
||||||
|
type: tool_calls[0]?.type,
|
||||||
|
function: {
|
||||||
|
name: tool_calls[0]?.function?.name as string,
|
||||||
|
arguments: args,
|
||||||
},
|
},
|
||||||
onclose() {
|
|
||||||
finish();
|
|
||||||
},
|
|
||||||
onerror(e) {
|
|
||||||
options.onError?.(e);
|
|
||||||
throw e;
|
|
||||||
},
|
|
||||||
openWhenHidden: true,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
runTools[index]["function"]["arguments"] += args;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return choices[0]?.delta?.content;
|
||||||
|
},
|
||||||
|
// processToolMessage, include tool_calls message and tool call results
|
||||||
|
(
|
||||||
|
requestPayload: RequestPayload,
|
||||||
|
toolCallMessage: any,
|
||||||
|
toolCallResult: any[],
|
||||||
|
) => {
|
||||||
|
// reset index value
|
||||||
|
index = -1;
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.messages?.splice(
|
||||||
|
// @ts-ignore
|
||||||
|
requestPayload?.messages?.length,
|
||||||
|
0,
|
||||||
|
toolCallMessage,
|
||||||
|
...toolCallResult,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const chatPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: getHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 4 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
|
||||||
|
);
|
||||||
|
|
||||||
const res = await fetch(chatPath, chatPayload);
|
const res = await fetch(chatPath, chatPayload);
|
||||||
clearTimeout(requestTimeoutId);
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
const resJson = await res.json();
|
const resJson = await res.json();
|
||||||
const message = this.extractMessage(resJson);
|
const message = await this.extractMessage(resJson);
|
||||||
options.onFinish(message);
|
options.onFinish(message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Request] failed to make a chat reqeust", e);
|
console.log("[Request] failed to make a chat request", e);
|
||||||
options.onError?.(e as Error);
|
options.onError?.(e as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,5 +432,42 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
total: total.hard_limit_usd,
|
total: total.hard_limit_usd,
|
||||||
} as LLMUsage;
|
} as LLMUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
if (this.disableListModels) {
|
||||||
|
return DEFAULT_MODELS.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(this.path(OpenaiPath.ListModelPath), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
...getHeaders(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resJson = (await res.json()) as OpenAIListModelResponse;
|
||||||
|
const chatModels = resJson.data?.filter(
|
||||||
|
(m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"),
|
||||||
|
);
|
||||||
|
console.log("[Models]", chatModels);
|
||||||
|
|
||||||
|
if (!chatModels) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
//由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场
|
||||||
|
let seq = 1000; //同 Constant.ts 中的排序保持一致
|
||||||
|
return chatModels.map((m) => ({
|
||||||
|
name: m.id,
|
||||||
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
|
provider: {
|
||||||
|
id: "openai",
|
||||||
|
providerName: "OpenAI",
|
||||||
|
providerType: "openai",
|
||||||
|
sorted: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export { OpenaiPath };
|
export { OpenaiPath };
|
||||||
|
|||||||
273
app/client/platforms/tencent.ts
Normal file
273
app/client/platforms/tencent.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
import { ApiPath, TENCENT_BASE_URL, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||||
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatOptions,
|
||||||
|
getHeaders,
|
||||||
|
LLMApi,
|
||||||
|
LLMModel,
|
||||||
|
MultimodalContent,
|
||||||
|
SpeechOptions,
|
||||||
|
} from "../api";
|
||||||
|
import Locale from "../../locales";
|
||||||
|
import {
|
||||||
|
EventStreamContentType,
|
||||||
|
fetchEventSource,
|
||||||
|
} from "@fortaine/fetch-event-source";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||||
|
import mapKeys from "lodash-es/mapKeys";
|
||||||
|
import mapValues from "lodash-es/mapValues";
|
||||||
|
import isArray from "lodash-es/isArray";
|
||||||
|
import isObject from "lodash-es/isObject";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
export interface OpenAIListModelResponse {
|
||||||
|
object: string;
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
root: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestPayload {
|
||||||
|
Messages: {
|
||||||
|
Role: "system" | "user" | "assistant";
|
||||||
|
Content: string | MultimodalContent[];
|
||||||
|
}[];
|
||||||
|
Stream?: boolean;
|
||||||
|
Model: string;
|
||||||
|
Temperature: number;
|
||||||
|
TopP: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalizeKeys(obj: any): any {
|
||||||
|
if (isArray(obj)) {
|
||||||
|
return obj.map(capitalizeKeys);
|
||||||
|
} else if (isObject(obj)) {
|
||||||
|
return mapValues(
|
||||||
|
mapKeys(obj, (value: any, key: string) =>
|
||||||
|
key.replace(/(^|_)(\w)/g, (m, $1, $2) => $2.toUpperCase()),
|
||||||
|
),
|
||||||
|
capitalizeKeys,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HunyuanApi implements LLMApi {
|
||||||
|
path(): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.tencentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
baseUrl = isApp ? TENCENT_BASE_URL : ApiPath.Tencent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Tencent)) {
|
||||||
|
baseUrl = "https://" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Proxy Endpoint] ", baseUrl);
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractMessage(res: any) {
|
||||||
|
return res.Choices?.at(0)?.Message?.Content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(options: ChatOptions) {
|
||||||
|
const visionModel = isVisionModel(options.config.model);
|
||||||
|
const messages = options.messages.map((v, index) => ({
|
||||||
|
// "Messages 中 system 角色必须位于列表的最开始"
|
||||||
|
role: index !== 0 && v.role === "system" ? "user" : v.role,
|
||||||
|
content: visionModel ? v.content : getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modelConfig = {
|
||||||
|
...useAppConfig.getState().modelConfig,
|
||||||
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
|
...{
|
||||||
|
model: options.config.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPayload: RequestPayload = capitalizeKeys({
|
||||||
|
model: modelConfig.model,
|
||||||
|
messages,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
top_p: modelConfig.top_p,
|
||||||
|
stream: options.config.stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Request] Tencent payload: ", requestPayload);
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatPath = this.path();
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 = () => {
|
||||||
|
if (!finished) {
|
||||||
|
finished = true;
|
||||||
|
options.onFinish(responseText + remainText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
|
...chatPayload,
|
||||||
|
async onopen(res) {
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log(
|
||||||
|
"[Tencent] request response content type: ",
|
||||||
|
contentType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
responseText = await res.clone().text();
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [responseText];
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
responseText = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
if (msg.data === "[DONE]" || finished) {
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
const text = msg.data;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const choices = json.Choices as Array<{
|
||||||
|
Delta: { Content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.Delta?.Content;
|
||||||
|
if (delta) {
|
||||||
|
remainText += delta;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await fetch(chatPath, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = this.extractMessage(resJson);
|
||||||
|
options.onFinish(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async usage() {
|
||||||
|
return {
|
||||||
|
used: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(): Promise<LLMModel[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import Locale from "./locales";
|
||||||
|
|
||||||
type Command = (param: string) => void;
|
type Command = (param: string) => void;
|
||||||
interface Commands {
|
interface Commands {
|
||||||
fill?: Command;
|
fill?: Command;
|
||||||
submit?: Command;
|
submit?: Command;
|
||||||
mask?: Command;
|
mask?: Command;
|
||||||
|
code?: Command;
|
||||||
|
settings?: Command;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommand(commands: Commands = {}) {
|
export function useCommand(commands: Commands = {}) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
if (commands === undefined) return;
|
useEffect(() => {
|
||||||
|
|
||||||
let shouldUpdate = false;
|
let shouldUpdate = false;
|
||||||
searchParams.forEach((param, name) => {
|
searchParams.forEach((param, name) => {
|
||||||
const commandName = name as keyof Commands;
|
const commandName = name as keyof Commands;
|
||||||
@@ -25,4 +28,52 @@ export function useCommand(commands: Commands = {}) {
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams, commands]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatCommands {
|
||||||
|
new?: Command;
|
||||||
|
newm?: Command;
|
||||||
|
next?: Command;
|
||||||
|
prev?: Command;
|
||||||
|
clear?: Command;
|
||||||
|
fork?: Command;
|
||||||
|
del?: Command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatible with Chinese colon character ":"
|
||||||
|
export const ChatCommandPrefix = /^[::]/;
|
||||||
|
|
||||||
|
export function useChatCommand(commands: ChatCommands = {}) {
|
||||||
|
function extract(userInput: string) {
|
||||||
|
const match = userInput.match(ChatCommandPrefix);
|
||||||
|
if (match) {
|
||||||
|
return userInput.slice(1) as keyof ChatCommands;
|
||||||
|
}
|
||||||
|
return userInput as keyof ChatCommands;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(userInput: string) {
|
||||||
|
const input = extract(userInput);
|
||||||
|
const desc = Locale.Chat.Commands;
|
||||||
|
return Object.keys(commands)
|
||||||
|
.filter((c) => c.startsWith(input))
|
||||||
|
.map((c) => ({
|
||||||
|
title: desc[c as keyof ChatCommands],
|
||||||
|
content: ":" + c,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function match(userInput: string) {
|
||||||
|
const command = extract(userInput);
|
||||||
|
const matched = typeof commands[command] === "function";
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
invoke: () => matched && commands[command]!(userInput),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match, search };
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/components/artifacts.module.scss
Normal file
31
app/components/artifacts.module.scss
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.artifacts {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
&-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--second);
|
||||||
|
}
|
||||||
|
&-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
&-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 0 20px 20px 20px;
|
||||||
|
background-color: var(--second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifacts-iframe {
|
||||||
|
width: 100%;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--gray);
|
||||||
|
}
|
||||||
266
app/components/artifacts.tsx
Normal file
266
app/components/artifacts.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { IconButton } from "./button";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import ExportIcon from "../icons/share.svg";
|
||||||
|
import CopyIcon from "../icons/copy.svg";
|
||||||
|
import DownloadIcon from "../icons/download.svg";
|
||||||
|
import GithubIcon from "../icons/github.svg";
|
||||||
|
import LoadingButtonIcon from "../icons/loading.svg";
|
||||||
|
import ReloadButtonIcon from "../icons/reload.svg";
|
||||||
|
import Locale from "../locales";
|
||||||
|
import { Modal, showToast } from "./ui-lib";
|
||||||
|
import { copyToClipboard, downloadAs } from "../utils";
|
||||||
|
import { Path, ApiPath, REPO_URL } from "@/app/constant";
|
||||||
|
import { Loading } from "./home";
|
||||||
|
import styles from "./artifacts.module.scss";
|
||||||
|
|
||||||
|
type HTMLPreviewProps = {
|
||||||
|
code: string;
|
||||||
|
autoHeight?: boolean;
|
||||||
|
height?: number | string;
|
||||||
|
onLoad?: (title?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HTMLPreviewHander = {
|
||||||
|
reload: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HTMLPreview = forwardRef<HTMLPreviewHander, HTMLPreviewProps>(
|
||||||
|
function HTMLPreview(props, ref) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const [frameId, setFrameId] = useState<string>(nanoid());
|
||||||
|
const [iframeHeight, setIframeHeight] = useState(600);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
/*
|
||||||
|
* https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
|
||||||
|
* 1. using srcdoc
|
||||||
|
* 2. using src with dataurl:
|
||||||
|
* easy to share
|
||||||
|
* length limit (Data URIs cannot be larger than 32,768 characters.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (e: any) => {
|
||||||
|
const { id, height, title } = e.data;
|
||||||
|
setTitle(title);
|
||||||
|
if (id == frameId) {
|
||||||
|
setIframeHeight(height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, [frameId]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
reload: () => {
|
||||||
|
setFrameId(nanoid());
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const height = useMemo(() => {
|
||||||
|
if (!props.autoHeight) return props.height || 600;
|
||||||
|
if (typeof props.height === "string") {
|
||||||
|
return props.height;
|
||||||
|
}
|
||||||
|
const parentHeight = props.height || 600;
|
||||||
|
return iframeHeight + 40 > parentHeight
|
||||||
|
? parentHeight
|
||||||
|
: iframeHeight + 40;
|
||||||
|
}, [props.autoHeight, props.height, iframeHeight]);
|
||||||
|
|
||||||
|
const srcDoc = useMemo(() => {
|
||||||
|
const script = `<script>window.addEventListener("DOMContentLoaded", () => new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body))</script>`;
|
||||||
|
if (props.code.includes("<!DOCTYPE html>")) {
|
||||||
|
props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + script);
|
||||||
|
}
|
||||||
|
return script + props.code;
|
||||||
|
}, [props.code, frameId]);
|
||||||
|
|
||||||
|
const handleOnLoad = () => {
|
||||||
|
if (props?.onLoad) {
|
||||||
|
props.onLoad(title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
className={styles["artifacts-iframe"]}
|
||||||
|
key={frameId}
|
||||||
|
ref={iframeRef}
|
||||||
|
sandbox="allow-forms allow-modals allow-scripts"
|
||||||
|
style={{ height }}
|
||||||
|
srcDoc={srcDoc}
|
||||||
|
onLoad={handleOnLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ArtifactsShareButton({
|
||||||
|
getCode,
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
fileName,
|
||||||
|
}: {
|
||||||
|
getCode: () => string;
|
||||||
|
id?: string;
|
||||||
|
style?: any;
|
||||||
|
fileName?: string;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [name, setName] = useState(id);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const shareUrl = useMemo(
|
||||||
|
() => [location.origin, "#", Path.Artifacts, "/", name].join(""),
|
||||||
|
[name],
|
||||||
|
);
|
||||||
|
const upload = (code: string) =>
|
||||||
|
id
|
||||||
|
? Promise.resolve({ id })
|
||||||
|
: fetch(ApiPath.Artifacts, {
|
||||||
|
method: "POST",
|
||||||
|
body: code,
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(({ id }) => {
|
||||||
|
if (id) {
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
throw Error();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
showToast(Locale.Export.Artifacts.Error);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="window-action-button" style={style}>
|
||||||
|
<IconButton
|
||||||
|
icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
|
||||||
|
bordered
|
||||||
|
title={Locale.Export.Artifacts.Title}
|
||||||
|
onClick={() => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
upload(getCode())
|
||||||
|
.then((res) => {
|
||||||
|
if (res?.id) {
|
||||||
|
setShow(true);
|
||||||
|
setName(res?.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{show && (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Export.Artifacts.Title}
|
||||||
|
onClose={() => setShow(false)}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="download"
|
||||||
|
icon={<DownloadIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.Export.Download}
|
||||||
|
onClick={() => {
|
||||||
|
downloadAs(getCode(), `${fileName || name}.html`).then(() =>
|
||||||
|
setShow(false),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<IconButton
|
||||||
|
key="copy"
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.Chat.Actions.Copy}
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(shareUrl).then(() => setShow(false));
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a target="_blank" href={shareUrl}>
|
||||||
|
{shareUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Artifacts() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fileName, setFileName] = useState("");
|
||||||
|
const previewRef = useRef<HTMLPreviewHander>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetch(`${ApiPath.Artifacts}?id=${id}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status > 300) {
|
||||||
|
throw Error("can not get content");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then(setCode)
|
||||||
|
.catch((e) => {
|
||||||
|
showToast(Locale.Export.Artifacts.Error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["artifacts"]}>
|
||||||
|
<div className={styles["artifacts-header"]}>
|
||||||
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
|
<IconButton bordered icon={<GithubIcon />} shadow />
|
||||||
|
</a>
|
||||||
|
<IconButton
|
||||||
|
bordered
|
||||||
|
style={{ marginLeft: 20 }}
|
||||||
|
icon={<ReloadButtonIcon />}
|
||||||
|
shadow
|
||||||
|
onClick={() => previewRef.current?.reload()}
|
||||||
|
/>
|
||||||
|
<div className={styles["artifacts-title"]}>NextChat Artifacts</div>
|
||||||
|
<ArtifactsShareButton
|
||||||
|
id={id}
|
||||||
|
getCode={() => code}
|
||||||
|
fileName={fileName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles["artifacts-content"]}>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{code && (
|
||||||
|
<HTMLPreview
|
||||||
|
code={code}
|
||||||
|
ref={previewRef}
|
||||||
|
autoHeight={false}
|
||||||
|
height={"100%"}
|
||||||
|
onLoad={(title) => {
|
||||||
|
setFileName(title as string);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,70 @@
|
|||||||
.auth-page {
|
.auth-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
.top-banner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 64px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--second);
|
||||||
|
.top-banner-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 150%;
|
||||||
|
span {
|
||||||
|
gap: 8px;
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.top-banner-close {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 48px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.top-banner {
|
||||||
|
padding: 12px 24px 12px 12px;
|
||||||
|
.top-banner-close {
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
.top-banner-inner {
|
||||||
|
.top-banner-logo {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: slide-in-from-top ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
|
margin-top: 10vh;
|
||||||
transform: scale(1.4);
|
transform: scale(1.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +72,7 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
|
margin-bottom: 1vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-tips {
|
.auth-tips {
|
||||||
@@ -24,6 +83,10 @@
|
|||||||
margin: 3vh 0;
|
margin: 3vh 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-input-second {
|
||||||
|
margin: 0 0 3vh 0;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-actions {
|
.auth-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,21 +1,59 @@
|
|||||||
import styles from "./auth.module.scss";
|
import styles from "./auth.module.scss";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Path } from "../constant";
|
import { Path, SAAS_CHAT_URL } from "../constant";
|
||||||
import { useAccessStore } from "../store";
|
import { useAccessStore } from "../store";
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
import Delete from "../icons/close.svg";
|
||||||
|
import Arrow from "../icons/arrow.svg";
|
||||||
|
import Logo from "../icons/logo.svg";
|
||||||
|
import { useMobileScreen } from "@/app/utils";
|
||||||
import BotIcon from "../icons/bot.svg";
|
import BotIcon from "../icons/bot.svg";
|
||||||
|
import { getClientConfig } from "../config/client";
|
||||||
|
import { PasswordInput } from "./ui-lib";
|
||||||
|
import LeftIcon from "@/app/icons/left.svg";
|
||||||
|
import { safeLocalStorage } from "@/app/utils";
|
||||||
|
import {
|
||||||
|
trackSettingsPageGuideToCPaymentClick,
|
||||||
|
trackAuthorizationPageButtonToCPaymentClick,
|
||||||
|
} from "../utils/auth-settings-events";
|
||||||
|
const storage = safeLocalStorage();
|
||||||
|
|
||||||
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 goSaas = () => {
|
||||||
|
trackAuthorizationPageButtonToCPaymentClick();
|
||||||
|
window.location.href = SAAS_CHAT_URL;
|
||||||
|
};
|
||||||
|
|
||||||
|
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"]}>
|
||||||
|
<TopBanner></TopBanner>
|
||||||
|
<div className={styles["auth-header"]}>
|
||||||
|
<IconButton
|
||||||
|
icon={<LeftIcon />}
|
||||||
|
text={Locale.Auth.Return}
|
||||||
|
onClick={() => navigate(Path.Home)}
|
||||||
|
></IconButton>
|
||||||
|
</div>
|
||||||
<div className={`no-dark ${styles["auth-logo"]}`}>
|
<div className={`no-dark ${styles["auth-logo"]}`}>
|
||||||
<BotIcon />
|
<BotIcon />
|
||||||
</div>
|
</div>
|
||||||
@@ -23,24 +61,127 @@ export function AuthPage() {
|
|||||||
<div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
|
<div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
|
||||||
<div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
|
<div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
|
||||||
|
|
||||||
<input
|
<PasswordInput
|
||||||
className={styles["auth-input"]}
|
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||||
type="password"
|
aria={Locale.Settings.ShowPassword}
|
||||||
|
aria-label={Locale.Auth.Input}
|
||||||
|
value={accessStore.accessCode}
|
||||||
|
type="text"
|
||||||
placeholder={Locale.Auth.Input}
|
placeholder={Locale.Auth.Input}
|
||||||
value={access.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>
|
||||||
|
<PasswordInput
|
||||||
|
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||||
|
aria={Locale.Settings.ShowPassword}
|
||||||
|
aria-label={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||||
|
value={accessStore.openaiApiKey}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.openaiApiKey = e.currentTarget.value),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||||
|
aria={Locale.Settings.ShowPassword}
|
||||||
|
aria-label={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||||
|
value={accessStore.googleApiKey}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||||
|
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.SaasTips}
|
||||||
|
onClick={() => {
|
||||||
|
goSaas();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton text={Locale.Auth.Later} onClick={goHome} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TopBanner() {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const isMobile = useMobileScreen();
|
||||||
|
useEffect(() => {
|
||||||
|
// 检查 localStorage 中是否有标记
|
||||||
|
const bannerDismissed = storage.getItem("bannerDismissed");
|
||||||
|
// 如果标记不存在,存储默认值并显示横幅
|
||||||
|
if (!bannerDismissed) {
|
||||||
|
storage.setItem("bannerDismissed", "false");
|
||||||
|
setIsVisible(true); // 显示横幅
|
||||||
|
} else if (bannerDismissed === "true") {
|
||||||
|
// 如果标记为 "true",则隐藏横幅
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsHovered(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsHovered(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
storage.setItem("bannerDismissed", "true");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles["top-banner"]}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<div className={`${styles["top-banner-inner"]} no-dark`}>
|
||||||
|
<Logo className={styles["top-banner-logo"]}></Logo>
|
||||||
|
<span>
|
||||||
|
{Locale.Auth.TopTips}
|
||||||
|
<a
|
||||||
|
href={SAAS_CHAT_URL}
|
||||||
|
rel="stylesheet"
|
||||||
|
onClick={() => {
|
||||||
|
trackSettingsPageGuideToCPaymentClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Locale.Settings.Access.SaasStart.ChatNow}
|
||||||
|
<Arrow style={{ marginLeft: "4px" }} />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(isHovered || isMobile) && (
|
||||||
|
<Delete className={styles["top-banner-close"]} onClick={handleClose} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -27,6 +26,26 @@
|
|||||||
fill: white !important;
|
fill: white !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: rgba($color: red, $alpha: 0.8);
|
||||||
|
border-color: rgba($color: red, $alpha: 0.5);
|
||||||
|
background-color: rgba($color: red, $alpha: 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: red;
|
||||||
|
background-color: rgba($color: red, $alpha: 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: red !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow {
|
.shadow {
|
||||||
@@ -37,10 +56,6 @@
|
|||||||
border: var(--border-in-light);
|
border: var(--border-in-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button-icon {
|
.icon-button-icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -56,9 +71,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon-button-text {
|
.icon-button-text {
|
||||||
margin-left: 5px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import styles from "./button.module.scss";
|
import styles from "./button.module.scss";
|
||||||
|
import { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export type ButtonType = "primary" | "danger" | null;
|
||||||
|
|
||||||
export function IconButton(props: {
|
export function IconButton(props: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
type?: "primary" | "danger";
|
type?: ButtonType;
|
||||||
text?: string;
|
text?: string;
|
||||||
bordered?: boolean;
|
bordered?: boolean;
|
||||||
shadow?: boolean;
|
shadow?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
|
aria?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -25,9 +32,14 @@ export function IconButton(props: {
|
|||||||
title={props.title}
|
title={props.title}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
role="button"
|
role="button"
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
|
autoFocus={props.autoFocus}
|
||||||
|
style={props.style}
|
||||||
|
aria-label={props.aria}
|
||||||
>
|
>
|
||||||
{props.icon && (
|
{props.icon && (
|
||||||
<div
|
<div
|
||||||
|
aria-label={props.text || props.title}
|
||||||
className={
|
className={
|
||||||
styles["icon-button-icon"] +
|
styles["icon-button-icon"] +
|
||||||
` ${props.type === "primary" && "no-dark"}`
|
` ${props.type === "primary" && "no-dark"}`
|
||||||
@@ -38,7 +50,12 @@ export function IconButton(props: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{props.text && (
|
{props.text && (
|
||||||
<div className={styles["icon-button-text"]}>{props.text}</div>
|
<div
|
||||||
|
aria-label={props.text || props.title}
|
||||||
|
className={styles["icon-button-text"]}
|
||||||
|
>
|
||||||
|
{props.text}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import DeleteIcon from "../icons/delete.svg";
|
import DeleteIcon from "../icons/delete.svg";
|
||||||
import BotIcon from "../icons/bot.svg";
|
|
||||||
|
|
||||||
import styles from "./home.module.scss";
|
import styles from "./home.module.scss";
|
||||||
import {
|
import {
|
||||||
@@ -12,11 +11,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 { 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 { useMobileScreen } from "../utils";
|
||||||
|
|
||||||
export function ChatItem(props: {
|
export function ChatItem(props: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -25,7 +26,7 @@ export function ChatItem(props: {
|
|||||||
count: number;
|
count: number;
|
||||||
time: string;
|
time: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
id: number;
|
id: string;
|
||||||
index: number;
|
index: number;
|
||||||
narrow?: boolean;
|
narrow?: boolean;
|
||||||
mask: Mask;
|
mask: Mask;
|
||||||
@@ -38,12 +39,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) => {
|
||||||
@@ -59,7 +64,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}
|
||||||
@@ -79,7 +87,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>
|
||||||
@@ -100,6 +112,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;
|
||||||
@@ -139,8 +152,11 @@ export function ChatList(props: { narrow?: boolean }) {
|
|||||||
navigate(Path.Chat);
|
navigate(Path.Chat);
|
||||||
selectSession(i);
|
selectSession(i);
|
||||||
}}
|
}}
|
||||||
onDelete={() => {
|
onDelete={async () => {
|
||||||
if (!props.narrow || confirm(Locale.Home.DeleteChat)) {
|
if (
|
||||||
|
(!props.narrow && !isMobileScreen) ||
|
||||||
|
(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;
|
||||||
@@ -14,11 +56,11 @@
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
animation: slide-in ease 0.3s;
|
animation: slide-in ease 0.3s;
|
||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
transition: all ease 0.3s;
|
transition: width ease 0.3s;
|
||||||
margin-bottom: 10px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
width: var(--icon-width);
|
width: var(--icon-width);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
@@ -30,14 +72,16 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-5px);
|
transform: translateX(-5px);
|
||||||
transition: all ease 0.3s;
|
transition: all ease 0.3s;
|
||||||
transition-delay: 0.1s;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
--delay: 0.5s;
|
||||||
width: var(--full-width);
|
width: var(--full-width);
|
||||||
|
transition-delay: var(--delay);
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
transition-delay: var(--delay);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(0);
|
transform: translate(0);
|
||||||
}
|
}
|
||||||
@@ -96,11 +140,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.context-prompt {
|
.context-prompt {
|
||||||
|
.context-prompt-insert {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
opacity: 0.2;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.context-prompt-row {
|
.context-prompt-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 10px;
|
|
||||||
|
&:hover {
|
||||||
|
.context-drag {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-drag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
.context-role {
|
.context-role {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
@@ -157,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 {
|
||||||
@@ -202,3 +274,423 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
position: relative;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-body-main-title {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.chat-body-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
.chat-message-header {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-header {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.chat-message-actions {
|
||||||
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
transform: scale(0.9) translateY(5px);
|
||||||
|
margin: 0 10px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.chat-input-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--black);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-container {
|
||||||
|
max-width: var(--message-max-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.chat-message-edit {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user>.chat-message-container {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-avatar {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.chat-message-edit {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
|
||||||
|
button {
|
||||||
|
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 {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-tools {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 5px;
|
||||||
|
.chat-message-tool {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
svg {
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: text;
|
||||||
|
word-break: break-word;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
position: relative;
|
||||||
|
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 {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all ease 0.6s;
|
||||||
|
color: var(--black);
|
||||||
|
text-align: right;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-right: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||||
|
background-color: var(--second);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-panel {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
padding-top: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: var(--border-in-light);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
|
||||||
|
.chat-input-actions {
|
||||||
|
.chat-input-action {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin single-line {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-hints {
|
||||||
|
min-height: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
|
||||||
|
background-color: var(--white);
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
|
||||||
|
.prompt-hint {
|
||||||
|
color: var(--black);
|
||||||
|
padding: 6px 10px;
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
border: transparent 1px solid;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bolder;
|
||||||
|
|
||||||
|
@include single-line();
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-content {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
@include single-line();
|
||||||
|
}
|
||||||
|
|
||||||
|
&-selected,
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-panel-inner {
|
||||||
|
cursor: text;
|
||||||
|
display: flex;
|
||||||
|
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 {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 10px 90px 10px 14px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:focus {}
|
||||||
|
|
||||||
|
.chat-input-send {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: 30px;
|
||||||
|
bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.chat-input {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-send {
|
||||||
|
bottom: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-container {
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key-keys {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
background-color: var(--gray);
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--black);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
@@ -32,7 +36,8 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
|||||||
if (props.model) {
|
if (props.model) {
|
||||||
return (
|
return (
|
||||||
<div className="no-dark">
|
<div className="no-dark">
|
||||||
{props.model?.startsWith("gpt-4") ? (
|
{props.model?.startsWith("gpt-4") ||
|
||||||
|
props.model?.startsWith("chatgpt-4o") ? (
|
||||||
<BlackBotIcon className="user-avatar" />
|
<BlackBotIcon className="user-avatar" />
|
||||||
) : (
|
) : (
|
||||||
<BotIcon className="user-avatar" />
|
<BotIcon className="user-avatar" />
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
import GithubIcon from "../icons/github.svg";
|
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 { useSyncStore } from "../store/sync";
|
||||||
|
import { useChatStore } from "../store/chat";
|
||||||
|
|
||||||
interface IErrorBoundaryState {
|
interface IErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -25,13 +29,9 @@ 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();
|
useChatStore.getState().clearAllData();
|
||||||
location.reload();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +57,11 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon={<ResetIcon />}
|
icon={<ResetIcon />}
|
||||||
text="Clear All Data"
|
text="Clear All Data"
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
confirm(Locale.Settings.Actions.ConfirmClearAll) &&
|
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
|
||||||
this.clearAndSaveData()
|
this.clearAndSaveData();
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
bordered
|
bordered
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
@@ -186,9 +187,63 @@
|
|||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
border: var(--border-in-light);
|
border: var(--border-in-light);
|
||||||
|
|
||||||
* {
|
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 +267,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-theme {
|
.default-theme {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
import { ChatMessage, 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 { List, ListItem, Modal, Select, showToast } from "./ui-lib";
|
import {
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
showImageModal,
|
||||||
|
showModal,
|
||||||
|
showToast,
|
||||||
|
} 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";
|
||||||
@@ -18,11 +32,14 @@ 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 } from "../constant";
|
||||||
|
import { getClientConfig } from "../config/client";
|
||||||
|
import { type ClientApi, getClientApi } from "../client/api";
|
||||||
|
import { getMessageTextContent } from "../utils";
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
@@ -31,7 +48,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>
|
||||||
@@ -117,7 +149,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({
|
||||||
@@ -139,7 +171,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 ?? i)));
|
ret.push(...session.messages.filter((m) => selection.has(m.id)));
|
||||||
return ret;
|
return ret;
|
||||||
}, [
|
}, [
|
||||||
exportConfig.includeContext,
|
exportConfig.includeContext,
|
||||||
@@ -147,7 +179,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
|
||||||
@@ -202,16 +248,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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -234,17 +271,19 @@ export function RenderExport(props: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderMsgs = messages.map((v) => {
|
const renderMsgs = messages.map((v, i) => {
|
||||||
const [_, role] = v.id.split(":");
|
const [role, _] = v.id.split(":");
|
||||||
return {
|
return {
|
||||||
|
id: i.toString(),
|
||||||
role: role as any,
|
role: role as any,
|
||||||
content: v.innerHTML,
|
content: role === "user" ? v.textContent ?? "" : v.innerHTML,
|
||||||
date: "",
|
date: "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
props.onRender(renderMsgs);
|
props.onRender(renderMsgs);
|
||||||
});
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={domRef}>
|
<div ref={domRef}>
|
||||||
@@ -254,7 +293,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>
|
||||||
@@ -269,15 +308,40 @@ 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);
|
||||||
|
|
||||||
|
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
||||||
|
|
||||||
api
|
api
|
||||||
.share(msgs)
|
.share(msgs)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
copyToClipboard(res);
|
showModal({
|
||||||
|
title: Locale.Export.Share,
|
||||||
|
children: [
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={res}
|
||||||
|
key="input"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "unset",
|
||||||
|
}}
|
||||||
|
readOnly
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
></input>,
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
<IconButton
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
text={Locale.Chat.Actions.Copy}
|
||||||
|
key="copy"
|
||||||
|
onClick={() => copyToClipboard(res)}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.open(res, "_blank");
|
window.open(res, "_blank");
|
||||||
}, 800);
|
}, 800);
|
||||||
@@ -344,7 +408,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}
|
||||||
@@ -354,7 +418,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: {
|
||||||
@@ -369,6 +433,7 @@ export function ImagePreviewer(props: {
|
|||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
|
showToast(Locale.Export.Image.Toast);
|
||||||
const dom = previewRef.current;
|
const dom = previewRef.current;
|
||||||
if (!dom) return;
|
if (!dom) return;
|
||||||
toBlob(dom).then((blob) => {
|
toBlob(dom).then((blob) => {
|
||||||
@@ -382,6 +447,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);
|
||||||
@@ -392,26 +458,62 @@ export function ImagePreviewer(props: {
|
|||||||
|
|
||||||
const isMobile = useMobileScreen();
|
const isMobile = useMobileScreen();
|
||||||
|
|
||||||
const download = () => {
|
const download = async () => {
|
||||||
|
showToast(Locale.Export.Image.Toast);
|
||||||
const dom = previewRef.current;
|
const dom = previewRef.current;
|
||||||
if (!dom) return;
|
if (!dom) return;
|
||||||
toPng(dom)
|
|
||||||
.then((blob) => {
|
const isApp = getClientConfig()?.isApp;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await toPng(dom);
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile || (isApp && window.__TAURI__)) {
|
||||||
const image = new Image();
|
if (isApp && window.__TAURI__) {
|
||||||
image.src = blob;
|
const result = await window.__TAURI__.dialog.save({
|
||||||
const win = window.open("");
|
defaultPath: `${props.topic}.png`,
|
||||||
win?.document.write(image.outerHTML);
|
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 {
|
||||||
|
showImageModal(blob);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.download = `${props.topic}.png`;
|
link.download = `${props.topic}.png`;
|
||||||
link.href = blob;
|
link.href = blob;
|
||||||
link.click();
|
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
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch((e) => console.log("[Export Image] ", e));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -437,9 +539,9 @@ 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/ChatGPTNextWeb/ChatGPT-Next-Web
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["icons"]}>
|
<div className={styles["icons"]}>
|
||||||
<ExportAvatar avatar={config.avatar} />
|
<ExportAvatar avatar={config.avatar} />
|
||||||
@@ -479,10 +581,38 @@ 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}
|
||||||
|
fontFamily={config.fontFamily}
|
||||||
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>
|
||||||
);
|
);
|
||||||
@@ -501,8 +631,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");
|
||||||
|
|
||||||
@@ -512,12 +644,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">
|
||||||
@@ -526,3 +658,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;
|
||||||
@@ -61,24 +61,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
.sidebar-drag {
|
||||||
|
background-color: rgba($color: #000000, $alpha: 0.01);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-drag {
|
.sidebar-drag {
|
||||||
$width: 10px;
|
$width: 14px;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: $width;
|
width: $width;
|
||||||
background-color: var(--black);
|
background-color: rgba($color: #000000, $alpha: 0);
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
opacity: 0;
|
|
||||||
transition: all ease 0.3s;
|
transition: all ease 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
&:hover,
|
svg {
|
||||||
&:active {
|
opacity: 0;
|
||||||
opacity: 0.2;
|
margin-left: -2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,12 +137,21 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
&-narrow {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
position: absolute;
|
display: inline-flex;
|
||||||
right: 0;
|
}
|
||||||
bottom: 18px;
|
|
||||||
|
.sidebar-title-container {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
@@ -162,6 +183,7 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
content-visibility: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-item:hover {
|
.chat-item:hover {
|
||||||
@@ -185,7 +207,7 @@
|
|||||||
|
|
||||||
.chat-item-delete {
|
.chat-item-delete {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
transition: all ease 0.3s;
|
transition: all ease 0.3s;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -194,7 +216,7 @@
|
|||||||
|
|
||||||
.chat-item:hover > .chat-item-delete {
|
.chat-item:hover > .chat-item-delete {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transform: translateX(-10px);
|
transform: translateX(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-item:hover > .chat-item-delete:hover {
|
.chat-item:hover > .chat-item-delete:hover {
|
||||||
@@ -283,15 +305,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-item-delete {
|
|
||||||
top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-item:hover > .chat-item-delete {
|
|
||||||
opacity: 0.5;
|
|
||||||
right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tail {
|
.sidebar-tail {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -322,243 +335,6 @@
|
|||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 20px;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
position: relative;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-body-title {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
animation: slide-in ease 0.3s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-user {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-container {
|
|
||||||
max-width: var(--message-max-width);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.chat-message-top-actions {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(10px);
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-user > .chat-message-container {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-avatar {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-status {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #aaa;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-item {
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-top: 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
user-select: text;
|
|
||||||
word-break: break-word;
|
|
||||||
border: var(--border-in-light);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-top-actions {
|
|
||||||
min-width: 120px;
|
|
||||||
font-size: 12px;
|
|
||||||
position: absolute;
|
|
||||||
right: 20px;
|
|
||||||
top: -26px;
|
|
||||||
left: 30px;
|
|
||||||
transition: all ease 0.3s;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
|
|
||||||
.chat-message-top-action {
|
|
||||||
opacity: 0.5;
|
|
||||||
color: var(--black);
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:first-child) {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-user > .chat-message-container > .chat-message-item {
|
|
||||||
background-color: var(--second);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
width: 100%;
|
|
||||||
padding-top: 5px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-action-date {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-panel {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
padding: 20px;
|
|
||||||
padding-top: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex-direction: column;
|
|
||||||
border-top-left-radius: 10px;
|
|
||||||
border-top-right-radius: 10px;
|
|
||||||
border-top: var(--border-in-light);
|
|
||||||
box-shadow: var(--card-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin single-line {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-hints {
|
|
||||||
min-height: 20px;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
|
|
||||||
background-color: var(--white);
|
|
||||||
border: var(--border-in-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
|
|
||||||
.prompt-hint {
|
|
||||||
color: var(--black);
|
|
||||||
padding: 6px 10px;
|
|
||||||
animation: slide-in ease 0.3s;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all ease 0.3s;
|
|
||||||
border: transparent 1px solid;
|
|
||||||
margin: 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bolder;
|
|
||||||
|
|
||||||
@include single-line();
|
|
||||||
}
|
|
||||||
.hint-content {
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
@include single-line();
|
|
||||||
}
|
|
||||||
|
|
||||||
&-selected,
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-panel-inner {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: var(--border-in-light);
|
|
||||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
|
||||||
background-color: var(--white);
|
|
||||||
color: var(--black);
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 10px 90px 10px 14px;
|
|
||||||
resize: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input:focus {
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-send {
|
|
||||||
background-color: var(--primary);
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
right: 30px;
|
|
||||||
bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.chat-input {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-send {
|
|
||||||
bottom: 30px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-content {
|
.loading-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -567,3 +343,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rtl-screen {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import dynamic from "next/dynamic";
|
|||||||
import { Path, SlotID } from "../constant";
|
import { Path, SlotID } from "../constant";
|
||||||
import { ErrorBoundary } from "./error";
|
import { ErrorBoundary } from "./error";
|
||||||
|
|
||||||
|
import { getISOLang, getLang } from "../locales";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HashRouter as Router,
|
HashRouter as Router,
|
||||||
Routes,
|
Routes,
|
||||||
@@ -25,6 +27,8 @@ 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 { type ClientApi, getClientApi } from "../client/api";
|
||||||
|
import { useAccessStore } from "../store";
|
||||||
|
|
||||||
export function Loading(props: { noLogo?: boolean }) {
|
export function Loading(props: { noLogo?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -35,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, {
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
});
|
||||||
|
|
||||||
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
||||||
loading: () => <Loading noLogo />,
|
loading: () => <Loading noLogo />,
|
||||||
});
|
});
|
||||||
@@ -51,6 +59,21 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
|
|||||||
loading: () => <Loading noLogo />,
|
loading: () => <Loading noLogo />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, {
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const SearchChat = dynamic(
|
||||||
|
async () => (await import("./search-chat")).SearchChatPage,
|
||||||
|
{
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Sd = dynamic(async () => (await import("./sd")).Sd, {
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
});
|
||||||
|
|
||||||
export function useSwitchTheme() {
|
export function useSwitchTheme() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
|
|
||||||
@@ -82,6 +105,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);
|
||||||
|
|
||||||
@@ -101,60 +135,99 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function WindowContent(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
||||||
|
{props?.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isArtifact = location.pathname.includes(Path.Artifacts);
|
||||||
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 isSd = location.pathname === Path.Sd;
|
||||||
|
const isSdNew = location.pathname === Path.SdNew;
|
||||||
|
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const shouldTightBorder =
|
||||||
|
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAsyncGoogleFont();
|
loadAsyncGoogleFont();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (isArtifact) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Routes>
|
||||||
className={
|
<Route path="/artifacts/:id" element={<Artifacts />} />
|
||||||
styles.container +
|
</Routes>
|
||||||
` ${
|
);
|
||||||
config.tightBorder && !isMobileScreen
|
|
||||||
? styles["tight-container"]
|
|
||||||
: styles.container
|
|
||||||
}`
|
|
||||||
}
|
}
|
||||||
>
|
const renderContent = () => {
|
||||||
{isAuth ? (
|
if (isAuth) return <AuthPage />;
|
||||||
<>
|
if (isSd) return <Sd />;
|
||||||
<AuthPage />
|
if (isSdNew) return <Sd />;
|
||||||
</>
|
return (
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
||||||
|
<WindowContent>
|
||||||
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={Path.Home} element={<Chat />} />
|
<Route path={Path.Home} element={<Chat />} />
|
||||||
<Route path={Path.NewChat} element={<NewChat />} />
|
<Route path={Path.NewChat} element={<NewChat />} />
|
||||||
<Route path={Path.Masks} element={<MaskPage />} />
|
<Route path={Path.Masks} element={<MaskPage />} />
|
||||||
|
<Route path={Path.Plugins} element={<PluginPage />} />
|
||||||
|
<Route path={Path.SearchChat} element={<SearchChat />} />
|
||||||
<Route path={Path.Chat} element={<Chat />} />
|
<Route path={Path.Chat} element={<Chat />} />
|
||||||
<Route path={Path.Settings} element={<Settings />} />
|
<Route path={Path.Settings} element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</WindowContent>
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.container} ${
|
||||||
|
shouldTightBorder ? styles["tight-container"] : styles.container
|
||||||
|
} ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLoadData() {
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const models = await api.llm.models();
|
||||||
|
config.mergeModels(models);
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
useSwitchTheme();
|
useSwitchTheme();
|
||||||
|
useLoadData();
|
||||||
|
useHtmlLang();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[Config] got config from build time", getClientConfig());
|
console.log("[Config] got config from build time", getClientConfig());
|
||||||
|
useAccessStore.getState().fetch();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!useHasHydrated()) {
|
if (!useHasHydrated()) {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
.input-range {
|
.input-range {
|
||||||
border: var(--border-in-light);
|
border: var(--border-in-light);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 5px 15px 5px 10px;
|
padding: 5px 10px 5px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
max-width: 40%;
|
max-width: 40%;
|
||||||
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
max-width: calc(100% - 50px);
|
max-width: calc(100% - 34px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface InputRangeProps {
|
|||||||
min: string;
|
min: string;
|
||||||
max: string;
|
max: string;
|
||||||
step: string;
|
step: string;
|
||||||
|
aria: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputRange({
|
export function InputRange({
|
||||||
@@ -19,11 +20,13 @@ export function InputRange({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
|
aria,
|
||||||
}: InputRangeProps) {
|
}: InputRangeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
||||||
{title || value}
|
{title || value}
|
||||||
<input
|
<input
|
||||||
|
aria-label={aria}
|
||||||
type="range"
|
type="range"
|
||||||
title={title}
|
title={title}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -5,13 +5,24 @@ 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, useWindowSize } from "../utils";
|
||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
|
import Locale from "../locales";
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
|
import ReloadButtonIcon from "../icons/reload.svg";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDebouncedCallback, useThrottledCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { showImageModal, FullScreen } from "./ui-lib";
|
||||||
|
import {
|
||||||
|
ArtifactsShareButton,
|
||||||
|
HTMLPreview,
|
||||||
|
HTMLPreviewHander,
|
||||||
|
} from "./artifacts";
|
||||||
|
import { useChatStore } from "../store";
|
||||||
|
import { IconButton } from "./button";
|
||||||
|
|
||||||
|
import { useAppConfig } from "../store/config";
|
||||||
|
|
||||||
export function Mermaid(props: { code: string }) {
|
export function Mermaid(props: { code: string }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -37,11 +48,7 @@ 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" });
|
||||||
const url = URL.createObjectURL(blob);
|
showImageModal(URL.createObjectURL(blob));
|
||||||
const win = window.open(url);
|
|
||||||
if (win) {
|
|
||||||
win.onload = () => URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
@@ -65,44 +72,192 @@ export function Mermaid(props: { code: string }) {
|
|||||||
|
|
||||||
export function PreCode(props: { children: any }) {
|
export function PreCode(props: { children: any }) {
|
||||||
const ref = useRef<HTMLPreElement>(null);
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
const refText = ref.current?.innerText;
|
const previewRef = useRef<HTMLPreviewHander>(null);
|
||||||
const [mermaidCode, setMermaidCode] = useState("");
|
const [mermaidCode, setMermaidCode] = useState("");
|
||||||
|
const [htmlCode, setHtmlCode] = useState("");
|
||||||
|
const { height } = useWindowSize();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
|
||||||
const renderMermaid = useDebouncedCallback(() => {
|
const renderArtifacts = useDebouncedCallback(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
||||||
if (mermaidDom) {
|
if (mermaidDom) {
|
||||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||||
}
|
}
|
||||||
|
const htmlDom = ref.current.querySelector("code.language-html");
|
||||||
|
const refText = ref.current.querySelector("code")?.innerText;
|
||||||
|
if (htmlDom) {
|
||||||
|
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||||
|
} else if (refText?.startsWith("<!DOCTYPE")) {
|
||||||
|
setHtmlCode(refText);
|
||||||
|
}
|
||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
|
const config = useAppConfig();
|
||||||
|
const enableArtifacts =
|
||||||
|
session.mask?.enableArtifacts !== false && config.enableArtifacts;
|
||||||
|
|
||||||
|
//Wrap the paragraph for plain-text
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(renderMermaid, 1);
|
if (ref.current) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const codeElements = ref.current.querySelectorAll(
|
||||||
}, [refText]);
|
"code",
|
||||||
|
) as NodeListOf<HTMLElement>;
|
||||||
|
const wrapLanguages = [
|
||||||
|
"",
|
||||||
|
"md",
|
||||||
|
"markdown",
|
||||||
|
"text",
|
||||||
|
"txt",
|
||||||
|
"plaintext",
|
||||||
|
"tex",
|
||||||
|
"latex",
|
||||||
|
];
|
||||||
|
codeElements.forEach((codeElement) => {
|
||||||
|
let languageClass = codeElement.className.match(/language-(\w+)/);
|
||||||
|
let name = languageClass ? languageClass[1] : "";
|
||||||
|
if (wrapLanguages.includes(name)) {
|
||||||
|
codeElement.style.whiteSpace = "pre-wrap";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(renderArtifacts, 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mermaidCode.length > 0 && (
|
|
||||||
<Mermaid code={mermaidCode} key={mermaidCode} />
|
|
||||||
)}
|
|
||||||
<pre ref={ref}>
|
<pre ref={ref}>
|
||||||
<span
|
<span
|
||||||
className="copy-code-button"
|
className="copy-code-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
const code = ref.current.innerText;
|
copyToClipboard(
|
||||||
copyToClipboard(code);
|
ref.current.querySelector("code")?.innerText ?? "",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></span>
|
></span>
|
||||||
{props.children}
|
{props.children}
|
||||||
</pre>
|
</pre>
|
||||||
|
{mermaidCode.length > 0 && (
|
||||||
|
<Mermaid code={mermaidCode} key={mermaidCode} />
|
||||||
|
)}
|
||||||
|
{htmlCode.length > 0 && enableArtifacts && (
|
||||||
|
<FullScreen className="no-dark html" right={70}>
|
||||||
|
<ArtifactsShareButton
|
||||||
|
style={{ position: "absolute", right: 20, top: 10 }}
|
||||||
|
getCode={() => htmlCode}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
style={{ position: "absolute", right: 120, top: 10 }}
|
||||||
|
bordered
|
||||||
|
icon={<ReloadButtonIcon />}
|
||||||
|
shadow
|
||||||
|
onClick={() => previewRef.current?.reload()}
|
||||||
|
/>
|
||||||
|
<HTMLPreview
|
||||||
|
ref={previewRef}
|
||||||
|
code={htmlCode}
|
||||||
|
autoHeight={!document.fullscreenElement}
|
||||||
|
height={!document.fullscreenElement ? 600 : height}
|
||||||
|
/>
|
||||||
|
</FullScreen>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CustomCode(props: { children: any; className?: string }) {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const enableCodeFold =
|
||||||
|
session.mask?.enableCodeFold !== false && config.enableCodeFold;
|
||||||
|
|
||||||
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [showToggle, setShowToggle] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const codeHeight = ref.current.scrollHeight;
|
||||||
|
setShowToggle(codeHeight > 400);
|
||||||
|
ref.current.scrollTop = ref.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [props.children]);
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
setCollapsed((collapsed) => !collapsed);
|
||||||
|
};
|
||||||
|
const renderShowMoreButton = () => {
|
||||||
|
if (showToggle && enableCodeFold && collapsed) {
|
||||||
|
return (
|
||||||
|
<div className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}>
|
||||||
|
<button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<code
|
||||||
|
className={props?.className}
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
maxHeight: enableCodeFold && collapsed ? "400px" : "none",
|
||||||
|
overflowY: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
{renderShowMoreButton()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tryWrapHtmlCode(text: string) {
|
||||||
|
// try add wrap html code (fixed: html codeblock include 2 newline)
|
||||||
|
return text
|
||||||
|
.replace(
|
||||||
|
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
|
||||||
|
(match, quoteStart, lang, newLine, doctype) => {
|
||||||
|
return !quoteStart ? "\n```html\n" + doctype : match;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
|
||||||
|
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
|
||||||
|
return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function _MarkDownContent(props: { content: string }) {
|
function _MarkDownContent(props: { content: string }) {
|
||||||
|
const escapedContent = useMemo(() => {
|
||||||
|
return tryWrapHtmlCode(escapeBrackets(props.content));
|
||||||
|
}, [props.content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||||
@@ -118,15 +273,31 @@ function _MarkDownContent(props: { content: string }) {
|
|||||||
]}
|
]}
|
||||||
components={{
|
components={{
|
||||||
pre: PreCode,
|
pre: PreCode,
|
||||||
|
code: CustomCode,
|
||||||
|
p: (pProps) => <p {...pProps} dir="auto" />,
|
||||||
a: (aProps) => {
|
a: (aProps) => {
|
||||||
const href = aProps.href || "";
|
const href = aProps.href || "";
|
||||||
|
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
|
||||||
|
return (
|
||||||
|
<figure>
|
||||||
|
<audio controls src={href}></audio>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
|
||||||
|
return (
|
||||||
|
<video controls width="99.9%">
|
||||||
|
<source src={href} />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
const isInternal = /^\/#/i.test(href);
|
const isInternal = /^\/#/i.test(href);
|
||||||
const target = isInternal ? "_self" : aProps.target ?? "_blank";
|
const target = isInternal ? "_self" : aProps.target ?? "_blank";
|
||||||
return <a {...aProps} target={target} />;
|
return <a {...aProps} target={target} />;
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.content}
|
{escapedContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,74 +309,30 @@ export function Markdown(
|
|||||||
content: string;
|
content: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
fontFamily?: string;
|
||||||
parentRef?: RefObject<HTMLDivElement>;
|
parentRef?: RefObject<HTMLDivElement>;
|
||||||
defaultShow?: boolean;
|
defaultShow?: boolean;
|
||||||
} & 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"
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${props.fontSize ?? 14}px`,
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
height: getSize(renderedHeight.current),
|
fontFamily: props.fontFamily || "inherit",
|
||||||
width: getSize(renderedWidth.current),
|
|
||||||
}}
|
}}
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,28 +11,64 @@ import CloseIcon from "../icons/close.svg";
|
|||||||
import DeleteIcon from "../icons/delete.svg";
|
import DeleteIcon from "../icons/delete.svg";
|
||||||
import EyeIcon from "../icons/eye.svg";
|
import EyeIcon from "../icons/eye.svg";
|
||||||
import CopyIcon from "../icons/copy.svg";
|
import CopyIcon from "../icons/copy.svg";
|
||||||
|
import DragIcon from "../icons/drag.svg";
|
||||||
|
|
||||||
import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
|
import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
|
||||||
import { ChatMessage, ModelConfig, useAppConfig, useChatStore } from "../store";
|
import {
|
||||||
import { ROLES } from "../client/api";
|
ChatMessage,
|
||||||
import { Input, List, ListItem, Modal, Popover, Select } from "./ui-lib";
|
createMessage,
|
||||||
|
ModelConfig,
|
||||||
|
ModelType,
|
||||||
|
useAppConfig,
|
||||||
|
useChatStore,
|
||||||
|
} from "../store";
|
||||||
|
import { MultimodalContent, ROLES } from "../client/api";
|
||||||
|
import {
|
||||||
|
Input,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Modal,
|
||||||
|
Popover,
|
||||||
|
Select,
|
||||||
|
showConfirm,
|
||||||
|
} from "./ui-lib";
|
||||||
import { Avatar, AvatarPicker } from "./emoji";
|
import { Avatar, AvatarPicker } from "./emoji";
|
||||||
import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
|
import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import chatStyle from "./chat.module.scss";
|
import chatStyle from "./chat.module.scss";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { 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";
|
||||||
import { BUILTIN_MASK_STORE } from "../masks";
|
import { BUILTIN_MASK_STORE } from "../masks";
|
||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Droppable,
|
||||||
|
Draggable,
|
||||||
|
OnDragEndResponder,
|
||||||
|
} from "@hello-pangea/dnd";
|
||||||
|
import { getMessageTextContent } from "../utils";
|
||||||
|
|
||||||
export function MaskAvatar(props: { mask: Mask }) {
|
// drag and drop helper function
|
||||||
return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
|
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||||
<Avatar avatar={props.mask.avatar} />
|
const result = [...list];
|
||||||
|
const [removed] = result.splice(startIndex, 1);
|
||||||
|
result.splice(endIndex, 0, removed);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaskAvatar(props: { avatar: string; model?: ModelType }) {
|
||||||
|
return props.avatar !== DEFAULT_MASK_AVATAR ? (
|
||||||
|
<Avatar avatar={props.avatar} />
|
||||||
) : (
|
) : (
|
||||||
<Avatar model={props.mask.modelConfig.model} />
|
<Avatar model={props.model} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +93,11 @@ export function MaskConfig(props: {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyMaskLink = () => {
|
||||||
|
const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`;
|
||||||
|
copyToClipboard(maskLink);
|
||||||
|
};
|
||||||
|
|
||||||
const globalConfig = useAppConfig();
|
const globalConfig = useAppConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -85,15 +126,21 @@ export function MaskConfig(props: {
|
|||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={Locale.Mask.Config.Avatar}
|
||||||
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>
|
||||||
<ListItem title={Locale.Mask.Config.Name}>
|
<ListItem title={Locale.Mask.Config.Name}>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Mask.Config.Name}
|
||||||
type="text"
|
type="text"
|
||||||
value={props.mask.name}
|
value={props.mask.name}
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
@@ -108,6 +155,7 @@ export function MaskConfig(props: {
|
|||||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Mask.Config.HideContext.Title}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.mask.hideContext}
|
checked={props.mask.hideContext}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -117,23 +165,79 @@ export function MaskConfig(props: {
|
|||||||
}}
|
}}
|
||||||
></input>
|
></input>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
{globalConfig.enableArtifacts && (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Mask.Config.Artifacts.Title}
|
||||||
|
subTitle={Locale.Mask.Config.Artifacts.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={Locale.Mask.Config.Artifacts.Title}
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.mask.enableArtifacts !== false}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.enableArtifacts = e.currentTarget.checked;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
{globalConfig.enableCodeFold && (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Mask.Config.CodeFold.Title}
|
||||||
|
subTitle={Locale.Mask.Config.CodeFold.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={Locale.Mask.Config.CodeFold.Title}
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.mask.enableCodeFold !== false}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.enableCodeFold = e.currentTarget.checked;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!props.shouldSyncFromGlobal ? (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Mask.Config.Share.Title}
|
||||||
|
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria={Locale.Mask.Config.Share.Title}
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
text={Locale.Mask.Config.Share.Action}
|
||||||
|
onClick={copyMaskLink}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{props.shouldSyncFromGlobal ? (
|
{props.shouldSyncFromGlobal ? (
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Mask.Config.Sync.Title}
|
title={Locale.Mask.Config.Sync.Title}
|
||||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Mask.Config.Sync.Title}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.mask.syncGlobalConfig}
|
checked={props.mask.syncGlobalConfig}
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
|
const checked = e.currentTarget.checked;
|
||||||
if (
|
if (
|
||||||
e.currentTarget.checked &&
|
checked &&
|
||||||
confirm(Locale.Mask.Config.Sync.Confirm)
|
(await showConfirm(Locale.Mask.Config.Sync.Confirm))
|
||||||
) {
|
) {
|
||||||
props.updateMask((mask) => {
|
props.updateMask((mask) => {
|
||||||
mask.syncGlobalConfig = e.currentTarget.checked;
|
mask.syncGlobalConfig = checked;
|
||||||
mask.modelConfig = { ...globalConfig.modelConfig };
|
mask.modelConfig = { ...globalConfig.modelConfig };
|
||||||
});
|
});
|
||||||
|
} else if (!checked) {
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.syncGlobalConfig = checked;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></input>
|
></input>
|
||||||
@@ -153,6 +257,7 @@ export function MaskConfig(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ContextPromptItem(props: {
|
function ContextPromptItem(props: {
|
||||||
|
index: number;
|
||||||
prompt: ChatMessage;
|
prompt: ChatMessage;
|
||||||
update: (prompt: ChatMessage) => void;
|
update: (prompt: ChatMessage) => void;
|
||||||
remove: () => void;
|
remove: () => void;
|
||||||
@@ -162,6 +267,10 @@ function ContextPromptItem(props: {
|
|||||||
return (
|
return (
|
||||||
<div className={chatStyle["context-prompt-row"]}>
|
<div className={chatStyle["context-prompt-row"]}>
|
||||||
{!focusingInput && (
|
{!focusingInput && (
|
||||||
|
<>
|
||||||
|
<div className={chatStyle["context-drag"]}>
|
||||||
|
<DragIcon />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={props.prompt.role}
|
value={props.prompt.role}
|
||||||
className={chatStyle["context-role"]}
|
className={chatStyle["context-role"]}
|
||||||
@@ -178,9 +287,10 @@ function ContextPromptItem(props: {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<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}
|
||||||
@@ -216,8 +326,8 @@ export function ContextPrompts(props: {
|
|||||||
}) {
|
}) {
|
||||||
const context = props.context;
|
const context = props.context;
|
||||||
|
|
||||||
const addContextPrompt = (prompt: ChatMessage) => {
|
const addContextPrompt = (prompt: ChatMessage, i: number) => {
|
||||||
props.updateContext((context) => context.push(prompt));
|
props.updateContext((context) => context.splice(i, 0, prompt));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeContextPrompt = (i: number) => {
|
const removeContextPrompt = (i: number) => {
|
||||||
@@ -225,21 +335,85 @@ 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) => {
|
||||||
|
if (!result.destination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newContext = reorder(
|
||||||
|
context,
|
||||||
|
result.source.index,
|
||||||
|
result.destination.index,
|
||||||
|
);
|
||||||
|
props.updateContext((context) => {
|
||||||
|
context.splice(0, context.length, ...newContext);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
|
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<Droppable droppableId="context-prompt-list">
|
||||||
|
{(provided) => (
|
||||||
|
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
{context.map((c, i) => (
|
{context.map((c, i) => (
|
||||||
|
<Draggable
|
||||||
|
draggableId={c.id || i.toString()}
|
||||||
|
index={i}
|
||||||
|
key={c.id}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
>
|
||||||
<ContextPromptItem
|
<ContextPromptItem
|
||||||
key={i}
|
index={i}
|
||||||
prompt={c}
|
prompt={c}
|
||||||
update={(prompt) => updateContextPrompt(i, prompt)}
|
update={(prompt) => updateContextPrompt(i, prompt)}
|
||||||
remove={() => removeContextPrompt(i)}
|
remove={() => removeContextPrompt(i)}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className={chatStyle["context-prompt-insert"]}
|
||||||
|
onClick={() => {
|
||||||
|
addContextPrompt(
|
||||||
|
createMessage({
|
||||||
|
role: "user",
|
||||||
|
content: "",
|
||||||
|
date: new Date().toLocaleString(),
|
||||||
|
}),
|
||||||
|
i + 1,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
))}
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{props.context.length === 0 && (
|
||||||
<div className={chatStyle["context-prompt-row"]}>
|
<div className={chatStyle["context-prompt-row"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<AddIcon />}
|
icon={<AddIcon />}
|
||||||
@@ -247,14 +421,18 @@ export function ContextPrompts(props: {
|
|||||||
bordered
|
bordered
|
||||||
className={chatStyle["context-prompt-button"]}
|
className={chatStyle["context-prompt-button"]}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
addContextPrompt({
|
addContextPrompt(
|
||||||
|
createMessage({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "",
|
content: "",
|
||||||
date: "",
|
date: "",
|
||||||
})
|
}),
|
||||||
|
props.context.length,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -266,7 +444,7 @@ export function MaskPage() {
|
|||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
const [filterLang, setFilterLang] = useState<Lang>();
|
const filterLang = maskStore.language;
|
||||||
|
|
||||||
const allMasks = maskStore
|
const allMasks = maskStore
|
||||||
.getAll()
|
.getAll()
|
||||||
@@ -276,24 +454,26 @@ 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [editingMaskId, setEditingMaskId] = useState<number | undefined>();
|
const [editingMaskId, setEditingMaskId] = useState<string | undefined>();
|
||||||
const editingMask =
|
const editingMask =
|
||||||
maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
|
maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
|
||||||
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 = () => {
|
||||||
@@ -335,11 +515,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()}
|
||||||
/>
|
/>
|
||||||
@@ -369,9 +551,9 @@ export function MaskPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.currentTarget.value;
|
const value = e.currentTarget.value;
|
||||||
if (value === Locale.Settings.Lang.All) {
|
if (value === Locale.Settings.Lang.All) {
|
||||||
setFilterLang(undefined);
|
maskStore.setLanguage(undefined);
|
||||||
} else {
|
} else {
|
||||||
setFilterLang(value as Lang);
|
maskStore.setLanguage(value as Lang);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -402,7 +584,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>
|
||||||
@@ -439,8 +621,8 @@ export function MaskPage() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon={<DeleteIcon />}
|
icon={<DeleteIcon />}
|
||||||
text={Locale.Mask.Item.Delete}
|
text={Locale.Mask.Item.Delete}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (confirm(Locale.Mask.Item.DeleteConfirm)) {
|
if (await showConfirm(Locale.Mask.Item.DeleteConfirm)) {
|
||||||
maskStore.delete(m.id);
|
maskStore.delete(m.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -487,7 +669,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>();
|
||||||
@@ -51,9 +52,9 @@ function useShiftRange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useMessageSelector() {
|
export function useMessageSelector() {
|
||||||
const [selection, setSelection] = useState(new Set<number>());
|
const [selection, setSelection] = useState(new Set<string>());
|
||||||
const updateSelection: Updater<Set<number>> = (updater) => {
|
const updateSelection: Updater<Set<string>> = (updater) => {
|
||||||
const newSelection = new Set<number>(selection);
|
const newSelection = new Set<string>(selection);
|
||||||
updater(newSelection);
|
updater(newSelection);
|
||||||
setSelection(newSelection);
|
setSelection(newSelection);
|
||||||
};
|
};
|
||||||
@@ -65,33 +66,47 @@ export function useMessageSelector() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MessageSelector(props: {
|
export function MessageSelector(props: {
|
||||||
selection: Set<number>;
|
selection: Set<string>;
|
||||||
updateSelection: Updater<Set<number>>;
|
updateSelection: Updater<Set<string>>;
|
||||||
defaultSelectAll?: boolean;
|
defaultSelectAll?: boolean;
|
||||||
onSelected?: (messages: ChatMessage[]) => void;
|
onSelected?: (messages: ChatMessage[]) => void;
|
||||||
}) {
|
}) {
|
||||||
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(() => {
|
||||||
|
let startIndex = Math.max(0, session.clearContextIndex ?? 0);
|
||||||
|
if (startIndex === session.messages.length - 1) {
|
||||||
|
startIndex = 0;
|
||||||
|
}
|
||||||
|
return session.messages.slice(startIndex);
|
||||||
|
}, [session.messages, session.clearContextIndex]);
|
||||||
|
|
||||||
|
const messages = useMemo(
|
||||||
|
() =>
|
||||||
|
allMessages.filter(
|
||||||
(m, i) =>
|
(m, i) =>
|
||||||
m.id && // message must have id
|
m.id && // message must have id
|
||||||
isValid(m) &&
|
isValid(m) &&
|
||||||
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
|
(i >= allMessages.length - 1 || isValid(allMessages[i + 1])),
|
||||||
|
),
|
||||||
|
[allMessages],
|
||||||
);
|
);
|
||||||
const messageCount = messages.length;
|
const messageCount = messages.length;
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [searchIds, setSearchIds] = useState(new Set<number>());
|
const [searchIds, setSearchIds] = useState(new Set<string>());
|
||||||
const isInSearchResult = (id: number) => {
|
const isInSearchResult = (id: string) => {
|
||||||
return searchInput.length === 0 || searchIds.has(id);
|
return searchInput.length === 0 || searchIds.has(id);
|
||||||
};
|
};
|
||||||
const doSearch = (text: string) => {
|
const doSearch = (text: string) => {
|
||||||
const searchResults = new Set<number>();
|
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
7
app/components/model-config.module.scss
Normal file
7
app/components/model-config.module.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.select-compress-model {
|
||||||
|
width: 60%;
|
||||||
|
select {
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,49 @@
|
|||||||
import { ALL_MODELS, ModalConfigValidator, ModelConfig } from "../store";
|
import { ServiceProvider } from "@/app/constant";
|
||||||
|
import { ModalConfigValidator, ModelConfig } from "../store";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { InputRange } from "./input-range";
|
import { InputRange } from "./input-range";
|
||||||
import { List, ListItem, Select } from "./ui-lib";
|
import { ListItem, Select } from "./ui-lib";
|
||||||
|
import { useAllModels } from "../utils/hooks";
|
||||||
|
import { groupBy } from "lodash-es";
|
||||||
|
import styles from "./model-config.module.scss";
|
||||||
|
|
||||||
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 allModels = useAllModels();
|
||||||
|
const groupModels = groupBy(
|
||||||
|
allModels.filter((v) => v.available),
|
||||||
|
"provider.providerName",
|
||||||
|
);
|
||||||
|
const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`;
|
||||||
|
const compressModelValue = `${props.modelConfig.compressModel}@${props.modelConfig?.compressProviderName}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListItem title={Locale.Settings.Model}>
|
<ListItem title={Locale.Settings.Model}>
|
||||||
<Select
|
<Select
|
||||||
value={props.modelConfig.model}
|
aria-label={Locale.Settings.Model}
|
||||||
|
value={value}
|
||||||
|
align="left"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
props.updateConfig(
|
const [model, providerName] = e.currentTarget.value.split("@");
|
||||||
(config) =>
|
props.updateConfig((config) => {
|
||||||
(config.model = ModalConfigValidator.model(
|
config.model = ModalConfigValidator.model(model);
|
||||||
e.currentTarget.value,
|
config.providerName = providerName as ServiceProvider;
|
||||||
)),
|
});
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ALL_MODELS.map((v) => (
|
{Object.keys(groupModels).map((providerName, index) => (
|
||||||
<option value={v.name} key={v.name} disabled={!v.available}>
|
<optgroup label={providerName} key={index}>
|
||||||
{v.name}
|
{groupModels[providerName].map((v, i) => (
|
||||||
|
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
||||||
|
{v.displayName}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -34,6 +51,7 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
|
aria={Locale.Settings.Temperature.Title}
|
||||||
value={props.modelConfig.temperature?.toFixed(1)}
|
value={props.modelConfig.temperature?.toFixed(1)}
|
||||||
min="0"
|
min="0"
|
||||||
max="1" // lets limit it to 0-1
|
max="1" // lets limit it to 0-1
|
||||||
@@ -48,14 +66,35 @@ export function ModelConfigList(props: {
|
|||||||
}}
|
}}
|
||||||
></InputRange>
|
></InputRange>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.TopP.Title}
|
||||||
|
subTitle={Locale.Settings.TopP.SubTitle}
|
||||||
|
>
|
||||||
|
<InputRange
|
||||||
|
aria={Locale.Settings.TopP.Title}
|
||||||
|
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.top_p = ModalConfigValidator.top_p(
|
||||||
|
e.currentTarget.valueAsNumber,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Settings.MaxTokens.Title}
|
title={Locale.Settings.MaxTokens.Title}
|
||||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Settings.MaxTokens.Title}
|
||||||
type="number"
|
type="number"
|
||||||
min={100}
|
min={1024}
|
||||||
max={32000}
|
max={512000}
|
||||||
value={props.modelConfig.max_tokens}
|
value={props.modelConfig.max_tokens}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
props.updateConfig(
|
props.updateConfig(
|
||||||
@@ -67,11 +106,15 @@ export function ModelConfigList(props: {
|
|||||||
}
|
}
|
||||||
></input>
|
></input>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
{props.modelConfig?.providerName == ServiceProvider.Google ? null : (
|
||||||
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Settings.PresencePenalty.Title}
|
title={Locale.Settings.PresencePenalty.Title}
|
||||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
|
aria={Locale.Settings.PresencePenalty.Title}
|
||||||
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||||
min="-2"
|
min="-2"
|
||||||
max="2"
|
max="2"
|
||||||
@@ -93,6 +136,7 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
|
aria={Locale.Settings.FrequencyPenalty.Title}
|
||||||
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
||||||
min="-2"
|
min="-2"
|
||||||
max="2"
|
max="2"
|
||||||
@@ -109,15 +153,51 @@ export function ModelConfigList(props: {
|
|||||||
></InputRange>
|
></InputRange>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.InjectSystemPrompts.Title}
|
||||||
|
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={Locale.Settings.InjectSystemPrompts.Title}
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.modelConfig.enableInjectSystemPrompts}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.enableInjectSystemPrompts =
|
||||||
|
e.currentTarget.checked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.InputTemplate.Title}
|
||||||
|
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={Locale.Settings.InputTemplate.Title}
|
||||||
|
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}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
|
aria={Locale.Settings.HistoryCount.Title}
|
||||||
title={props.modelConfig.historyMessageCount.toString()}
|
title={props.modelConfig.historyMessageCount.toString()}
|
||||||
value={props.modelConfig.historyMessageCount}
|
value={props.modelConfig.historyMessageCount}
|
||||||
min="0"
|
min="0"
|
||||||
max="32"
|
max="64"
|
||||||
step="1"
|
step="1"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
props.updateConfig(
|
props.updateConfig(
|
||||||
@@ -132,6 +212,7 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Settings.CompressThreshold.Title}
|
||||||
type="number"
|
type="number"
|
||||||
min={500}
|
min={500}
|
||||||
max={4000}
|
max={4000}
|
||||||
@@ -147,6 +228,7 @@ export function ModelConfigList(props: {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Memory.Title}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.modelConfig.sendMemory}
|
checked={props.modelConfig.sendMemory}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -156,6 +238,31 @@ export function ModelConfigList(props: {
|
|||||||
}
|
}
|
||||||
></input>
|
></input>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.CompressModel.Title}
|
||||||
|
subTitle={Locale.Settings.CompressModel.SubTitle}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
className={styles["select-compress-model"]}
|
||||||
|
aria-label={Locale.Settings.CompressModel.Title}
|
||||||
|
value={compressModelValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [model, providerName] = e.currentTarget.value.split("@");
|
||||||
|
props.updateConfig((config) => {
|
||||||
|
config.compressModel = ModalConfigValidator.model(model);
|
||||||
|
config.compressProviderName = providerName as ServiceProvider;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allModels
|
||||||
|
.filter((v) => v.available)
|
||||||
|
.map((v, i) => (
|
||||||
|
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
||||||
|
{v.displayName}({v.provider?.providerName})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,16 @@ import Locale from "../locales";
|
|||||||
import { useAppConfig, useChatStore } from "../store";
|
import { useAppConfig, useChatStore } from "../store";
|
||||||
import { MaskAvatar } from "./mask";
|
import { MaskAvatar } from "./mask";
|
||||||
import { useCommand } from "../command";
|
import { useCommand } from "../command";
|
||||||
|
import { showConfirm } from "./ui-lib";
|
||||||
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
|
import { BUILTIN_MASK_STORE } from "../masks";
|
||||||
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>
|
||||||
);
|
);
|
||||||
@@ -92,14 +86,16 @@ export function NewChat() {
|
|||||||
const { state } = useLocation();
|
const { state } = useLocation();
|
||||||
|
|
||||||
const startChat = (mask?: Mask) => {
|
const startChat = (mask?: Mask) => {
|
||||||
|
setTimeout(() => {
|
||||||
chatStore.newSession(mask);
|
chatStore.newSession(mask);
|
||||||
setTimeout(() => navigate(Path.Chat), 1);
|
navigate(Path.Chat);
|
||||||
|
}, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
useCommand({
|
useCommand({
|
||||||
mask: (id) => {
|
mask: (id) => {
|
||||||
try {
|
try {
|
||||||
const mask = maskStore.get(parseInt(id));
|
const mask = maskStore.get(id) ?? BUILTIN_MASK_STORE.get(id);
|
||||||
startChat(mask ?? undefined);
|
startChat(mask ?? undefined);
|
||||||
} catch {
|
} catch {
|
||||||
console.error("[New Chat] failed to create chat from mask id=", id);
|
console.error("[New Chat] failed to create chat from mask id=", id);
|
||||||
@@ -125,8 +121,8 @@ export function NewChat() {
|
|||||||
{!state?.fromHome && (
|
{!state?.fromHome && (
|
||||||
<IconButton
|
<IconButton
|
||||||
text={Locale.NewChat.NotShow}
|
text={Locale.NewChat.NotShow}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (confirm(Locale.NewChat.ConfirmNoShow)) {
|
if (await showConfirm(Locale.NewChat.ConfirmNoShow)) {
|
||||||
startChat();
|
startChat();
|
||||||
config.update(
|
config.update(
|
||||||
(config) => (config.dontShowMaskSplashScreen = true),
|
(config) => (config.dontShowMaskSplashScreen = true),
|
||||||
|
|||||||
38
app/components/plugin.module.scss
Normal file
38
app/components/plugin.module.scss
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.plugin-title {
|
||||||
|
font-weight: bolder;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.plugin-content {
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
pre code {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-schema {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-right: 20px;
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
margin-right: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
366
app/components/plugin.tsx
Normal file
366
app/components/plugin.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import OpenAPIClientAxios from "openapi-client-axios";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import { PLUGINS_REPO_URL } from "../constant";
|
||||||
|
import { IconButton } from "./button";
|
||||||
|
import { ErrorBoundary } from "./error";
|
||||||
|
|
||||||
|
import styles from "./mask.module.scss";
|
||||||
|
import pluginStyles from "./plugin.module.scss";
|
||||||
|
|
||||||
|
import EditIcon from "../icons/edit.svg";
|
||||||
|
import AddIcon from "../icons/add.svg";
|
||||||
|
import CloseIcon from "../icons/close.svg";
|
||||||
|
import DeleteIcon from "../icons/delete.svg";
|
||||||
|
import ConfirmIcon from "../icons/confirm.svg";
|
||||||
|
import ReloadIcon from "../icons/reload.svg";
|
||||||
|
import GithubIcon from "../icons/github.svg";
|
||||||
|
|
||||||
|
import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin";
|
||||||
|
import {
|
||||||
|
PasswordInput,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Modal,
|
||||||
|
showConfirm,
|
||||||
|
showToast,
|
||||||
|
} from "./ui-lib";
|
||||||
|
import Locale from "../locales";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function PluginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const pluginStore = usePluginStore();
|
||||||
|
|
||||||
|
const allPlugins = pluginStore.getAll();
|
||||||
|
const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
|
||||||
|
|
||||||
|
// refactored already, now it accurate
|
||||||
|
const onSearch = (text: string) => {
|
||||||
|
setSearchText(text);
|
||||||
|
if (text.length > 0) {
|
||||||
|
const result = allPlugins.filter(
|
||||||
|
(m) => m?.title.toLowerCase().includes(text.toLowerCase()),
|
||||||
|
);
|
||||||
|
setSearchPlugins(result);
|
||||||
|
} else {
|
||||||
|
setSearchPlugins(allPlugins);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
|
||||||
|
const editingPlugin = pluginStore.get(editingPluginId);
|
||||||
|
const editingPluginTool = FunctionToolService.get(editingPlugin?.id);
|
||||||
|
const closePluginModal = () => setEditingPluginId(undefined);
|
||||||
|
|
||||||
|
const onChangePlugin = useDebouncedCallback((editingPlugin, e) => {
|
||||||
|
const content = e.target.innerText;
|
||||||
|
try {
|
||||||
|
const api = new OpenAPIClientAxios({
|
||||||
|
definition: yaml.load(content) as any,
|
||||||
|
});
|
||||||
|
api
|
||||||
|
.init()
|
||||||
|
.then(() => {
|
||||||
|
if (content != editingPlugin.content) {
|
||||||
|
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||||
|
plugin.content = content;
|
||||||
|
const tool = FunctionToolService.add(plugin, true);
|
||||||
|
plugin.title = tool.api.definition.info.title;
|
||||||
|
plugin.version = tool.api.definition.info.version;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
showToast(Locale.Plugin.EditModal.Error);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast(Locale.Plugin.EditModal.Error);
|
||||||
|
}
|
||||||
|
}, 100).bind(null, editingPlugin);
|
||||||
|
|
||||||
|
const [loadUrl, setLoadUrl] = useState<string>("");
|
||||||
|
const loadFromUrl = (loadUrl: string) =>
|
||||||
|
fetch(loadUrl)
|
||||||
|
.catch((e) => {
|
||||||
|
const p = new URL(loadUrl);
|
||||||
|
return fetch(`/api/proxy/${p.pathname}?${p.search}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Base-URL": p.origin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then((content) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(content), null, " ");
|
||||||
|
} catch (e) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((content) => {
|
||||||
|
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||||
|
plugin.content = content;
|
||||||
|
const tool = FunctionToolService.add(plugin, true);
|
||||||
|
plugin.title = tool.api.definition.info.title;
|
||||||
|
plugin.version = tool.api.definition.info.version;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
showToast(Locale.Plugin.EditModal.Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className={styles["mask-page"]}>
|
||||||
|
<div className="window-header">
|
||||||
|
<div className="window-header-title">
|
||||||
|
<div className="window-header-main-title">
|
||||||
|
{Locale.Plugin.Page.Title}
|
||||||
|
</div>
|
||||||
|
<div className="window-header-submai-title">
|
||||||
|
{Locale.Plugin.Page.SubTitle(plugins.length)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="window-actions">
|
||||||
|
<div className="window-action-button">
|
||||||
|
<a
|
||||||
|
href={PLUGINS_REPO_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IconButton icon={<GithubIcon />} bordered />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="window-action-button">
|
||||||
|
<IconButton
|
||||||
|
icon={<CloseIcon />}
|
||||||
|
bordered
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["mask-page-body"]}>
|
||||||
|
<div className={styles["mask-filter"]}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles["search-bar"]}
|
||||||
|
placeholder={Locale.Plugin.Page.Search}
|
||||||
|
autoFocus
|
||||||
|
onInput={(e) => onSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles["mask-create"]}
|
||||||
|
icon={<AddIcon />}
|
||||||
|
text={Locale.Plugin.Page.Create}
|
||||||
|
bordered
|
||||||
|
onClick={() => {
|
||||||
|
const createdPlugin = pluginStore.create();
|
||||||
|
setEditingPluginId(createdPlugin.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{plugins.length == 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
margin: "60px auto",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Locale.Plugin.Page.Find}
|
||||||
|
<a
|
||||||
|
href={PLUGINS_REPO_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ marginLeft: 16 }}
|
||||||
|
>
|
||||||
|
<IconButton icon={<GithubIcon />} bordered />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plugins.map((m) => (
|
||||||
|
<div className={styles["mask-item"]} key={m.id}>
|
||||||
|
<div className={styles["mask-header"]}>
|
||||||
|
<div className={styles["mask-icon"]}></div>
|
||||||
|
<div className={styles["mask-title"]}>
|
||||||
|
<div className={styles["mask-name"]}>
|
||||||
|
{m.title}@<small>{m.version}</small>
|
||||||
|
</div>
|
||||||
|
<div className={styles["mask-info"] + " one-line"}>
|
||||||
|
{Locale.Plugin.Item.Info(
|
||||||
|
FunctionToolService.add(m).length,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles["mask-actions"]}>
|
||||||
|
<IconButton
|
||||||
|
icon={<EditIcon />}
|
||||||
|
text={Locale.Plugin.Item.Edit}
|
||||||
|
onClick={() => setEditingPluginId(m.id)}
|
||||||
|
/>
|
||||||
|
{!m.builtin && (
|
||||||
|
<IconButton
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
text={Locale.Plugin.Item.Delete}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
await showConfirm(Locale.Plugin.Item.DeleteConfirm)
|
||||||
|
) {
|
||||||
|
pluginStore.delete(m.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingPlugin && (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
|
||||||
|
onClose={closePluginModal}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
icon={<ConfirmIcon />}
|
||||||
|
text={Locale.UI.Confirm}
|
||||||
|
key="export"
|
||||||
|
bordered
|
||||||
|
onClick={() => setEditingPluginId("")}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List>
|
||||||
|
<ListItem title={Locale.Plugin.EditModal.Auth}>
|
||||||
|
<select
|
||||||
|
value={editingPlugin?.authType}
|
||||||
|
onChange={(e) => {
|
||||||
|
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||||
|
plugin.authType = e.target.value;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{Locale.Plugin.Auth.None}</option>
|
||||||
|
<option value="bearer">{Locale.Plugin.Auth.Bearer}</option>
|
||||||
|
<option value="basic">{Locale.Plugin.Auth.Basic}</option>
|
||||||
|
<option value="custom">{Locale.Plugin.Auth.Custom}</option>
|
||||||
|
</select>
|
||||||
|
</ListItem>
|
||||||
|
{["bearer", "basic", "custom"].includes(
|
||||||
|
editingPlugin.authType as string,
|
||||||
|
) && (
|
||||||
|
<ListItem title={Locale.Plugin.Auth.Location}>
|
||||||
|
<select
|
||||||
|
value={editingPlugin?.authLocation}
|
||||||
|
onChange={(e) => {
|
||||||
|
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||||
|
plugin.authLocation = e.target.value;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="header">
|
||||||
|
{Locale.Plugin.Auth.LocationHeader}
|
||||||
|
</option>
|
||||||
|
<option value="query">
|
||||||
|
{Locale.Plugin.Auth.LocationQuery}
|
||||||
|
</option>
|
||||||
|
<option value="body">
|
||||||
|
{Locale.Plugin.Auth.LocationBody}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
{editingPlugin.authType == "custom" && (
|
||||||
|
<ListItem title={Locale.Plugin.Auth.CustomHeader}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingPlugin?.authHeader}
|
||||||
|
onChange={(e) => {
|
||||||
|
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||||
|
plugin.authHeader = e.target.value;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
{["bearer", "basic", "custom"].includes(
|
||||||
|
editingPlugin.authType as string,
|
||||||
|
) && (
|
||||||
|
<ListItem title={Locale.Plugin.Auth.Token}>
|
||||||
|
<PasswordInput
|
||||||
|
type="text"
|
||||||
|
value={editingPlugin?.authToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||||
|
plugin.authToken = e.currentTarget.value;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></PasswordInput>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
<List>
|
||||||
|
<ListItem title={Locale.Plugin.EditModal.Content}>
|
||||||
|
<div className={pluginStyles["plugin-schema"]}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
onInput={(e) => setLoadUrl(e.currentTarget.value)}
|
||||||
|
></input>
|
||||||
|
<IconButton
|
||||||
|
icon={<ReloadIcon />}
|
||||||
|
text={Locale.Plugin.EditModal.Load}
|
||||||
|
bordered
|
||||||
|
onClick={() => loadFromUrl(loadUrl)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
subTitle={
|
||||||
|
<div
|
||||||
|
className={`markdown-body ${pluginStyles["plugin-content"]}`}
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
<pre>
|
||||||
|
<code
|
||||||
|
contentEditable={true}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: editingPlugin.content,
|
||||||
|
}}
|
||||||
|
onBlur={onChangePlugin}
|
||||||
|
></code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
></ListItem>
|
||||||
|
{editingPluginTool?.tools.map((tool, index) => (
|
||||||
|
<ListItem
|
||||||
|
key={index}
|
||||||
|
title={tool?.function?.name}
|
||||||
|
subTitle={tool?.function?.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
app/components/sd/index.tsx
Normal file
2
app/components/sd/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./sd";
|
||||||
|
export * from "./sd-panel";
|
||||||
45
app/components/sd/sd-panel.module.scss
Normal file
45
app/components/sd/sd-panel.module.scss
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
.ctrl-param-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 10px 0;
|
||||||
|
animation: slide-in ease 0.6s;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ctrl-param-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.ctrl-param-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bolder;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-param-item-sub-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-models {
|
||||||
|
button {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
320
app/components/sd/sd-panel.tsx
Normal file
320
app/components/sd/sd-panel.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import styles from "./sd-panel.module.scss";
|
||||||
|
import React from "react";
|
||||||
|
import { Select } from "@/app/components/ui-lib";
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { useSdStore } from "@/app/store/sd";
|
||||||
|
|
||||||
|
export const params = [
|
||||||
|
{
|
||||||
|
name: Locale.SdPanel.Prompt,
|
||||||
|
value: "prompt",
|
||||||
|
type: "textarea",
|
||||||
|
placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.Prompt),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Locale.SdPanel.ModelVersion,
|
||||||
|
value: "model",
|
||||||
|
type: "select",
|
||||||
|
default: "sd3-medium",
|
||||||
|
support: ["sd3"],
|
||||||
|
options: [
|
||||||
|
{ name: "SD3 Medium", value: "sd3-medium" },
|
||||||
|
{ name: "SD3 Large", value: "sd3-large" },
|
||||||
|
{ name: "SD3 Large Turbo", value: "sd3-large-turbo" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Locale.SdPanel.NegativePrompt,
|
||||||
|
value: "negative_prompt",
|
||||||
|
type: "textarea",
|
||||||
|
placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.NegativePrompt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Locale.SdPanel.AspectRatio,
|
||||||
|
value: "aspect_ratio",
|
||||||
|
type: "select",
|
||||||
|
default: "1:1",
|
||||||
|
options: [
|
||||||
|
{ name: "1:1", value: "1:1" },
|
||||||
|
{ name: "16:9", value: "16:9" },
|
||||||
|
{ name: "21:9", value: "21:9" },
|
||||||
|
{ name: "2:3", value: "2:3" },
|
||||||
|
{ name: "3:2", value: "3:2" },
|
||||||
|
{ name: "4:5", value: "4:5" },
|
||||||
|
{ name: "5:4", value: "5:4" },
|
||||||
|
{ name: "9:16", value: "9:16" },
|
||||||
|
{ name: "9:21", value: "9:21" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Locale.SdPanel.ImageStyle,
|
||||||
|
value: "style",
|
||||||
|
type: "select",
|
||||||
|
default: "3d-model",
|
||||||
|
support: ["core"],
|
||||||
|
options: [
|
||||||
|
{ name: Locale.SdPanel.Styles.D3Model, value: "3d-model" },
|
||||||
|
{ name: Locale.SdPanel.Styles.AnalogFilm, value: "analog-film" },
|
||||||
|
{ name: Locale.SdPanel.Styles.Anime, value: "anime" },
|
||||||
|
{ name: Locale.SdPanel.Styles.Cinematic, value: "cinematic" },
|
||||||
|
{ name: Locale.SdPanel.Styles.ComicBook, value: "comic-book" },
|
||||||
|
{ name: Locale.SdPanel.Styles.DigitalArt, value: "digital-art" },
|
||||||
|
{ name: Locale.SdPanel.Styles.Enhance, value: "enhance" },
|
||||||
|
{ name: Locale.SdPanel.Styles.FantasyArt, value: "fantasy-art" },
|
||||||
|
{ name: Locale.SdPanel.Styles.Isometric, value: "isometric" },
|
||||||
|
{ name: Locale.SdPanel.Styles.LineArt, value: "line-art" },
|
||||||
|
{ name: Locale.SdPanel.Styles.LowPoly, value: "low-poly" },
|
||||||
|
{
|
||||||
|
name: Locale.SdPanel.Styles.ModelingCompound,
|
||||||
|
value: "modeling-compound",
|
||||||
|
},
|
||||||
|
{ name: Locale.SdPanel.Styles.NeonPunk, value: "neon-punk" },
|
||||||
|
{ name: Locale.SdPanel.Styles.Origami, value: "origami" },
|
||||||
|
{ name: Locale.SdPanel.Styles.Photographic, value: "photographic" },
|
||||||
|
{ name: Locale.SdPanel.Styles.PixelArt, value: "pixel-art" },
|
||||||
|
{ name: Locale.SdPanel.Styles.TileTexture, value: "tile-texture" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Seed",
|
||||||
|
value: "seed",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 4294967294,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: Locale.SdPanel.OutFormat,
|
||||||
|
value: "output_format",
|
||||||
|
type: "select",
|
||||||
|
default: "png",
|
||||||
|
options: [
|
||||||
|
{ name: "PNG", value: "png" },
|
||||||
|
{ name: "JPEG", value: "jpeg" },
|
||||||
|
{ name: "WebP", value: "webp" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sdCommonParams = (model: string, data: any) => {
|
||||||
|
return params.filter((item) => {
|
||||||
|
return !(item.support && !item.support.includes(model));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const models = [
|
||||||
|
{
|
||||||
|
name: "Stable Image Ultra",
|
||||||
|
value: "ultra",
|
||||||
|
params: (data: any) => sdCommonParams("ultra", data),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Stable Image Core",
|
||||||
|
value: "core",
|
||||||
|
params: (data: any) => sdCommonParams("core", data),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Stable Diffusion 3",
|
||||||
|
value: "sd3",
|
||||||
|
params: (data: any) => {
|
||||||
|
return sdCommonParams("sd3", data).filter((item) => {
|
||||||
|
return !(
|
||||||
|
data.model === "sd3-large-turbo" && item.value == "negative_prompt"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ControlParamItem(props: {
|
||||||
|
title: string;
|
||||||
|
subTitle?: string;
|
||||||
|
required?: boolean;
|
||||||
|
children?: JSX.Element | JSX.Element[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}>
|
||||||
|
<div className={styles["ctrl-param-item-header"]}>
|
||||||
|
<div className={styles["ctrl-param-item-title"]}>
|
||||||
|
<div>
|
||||||
|
{props.title}
|
||||||
|
{props.required && <span style={{ color: "red" }}>*</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.children}
|
||||||
|
{props.subTitle && (
|
||||||
|
<div className={styles["ctrl-param-item-sub-title"]}>
|
||||||
|
{props.subTitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlParam(props: {
|
||||||
|
columns: any[];
|
||||||
|
data: any;
|
||||||
|
onChange: (field: string, val: any) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.columns?.map((item) => {
|
||||||
|
let element: null | JSX.Element;
|
||||||
|
switch (item.type) {
|
||||||
|
case "textarea":
|
||||||
|
element = (
|
||||||
|
<ControlParamItem
|
||||||
|
title={item.name}
|
||||||
|
subTitle={item.sub}
|
||||||
|
required={item.required}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
rows={item.rows || 3}
|
||||||
|
style={{ maxWidth: "100%", width: "100%", padding: "10px" }}
|
||||||
|
placeholder={item.placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.onChange(item.value, e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
value={props.data[item.value]}
|
||||||
|
></textarea>
|
||||||
|
</ControlParamItem>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "select":
|
||||||
|
element = (
|
||||||
|
<ControlParamItem
|
||||||
|
title={item.name}
|
||||||
|
subTitle={item.sub}
|
||||||
|
required={item.required}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
aria-label={item.name}
|
||||||
|
value={props.data[item.value]}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.onChange(item.value, e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.options.map((opt: any) => {
|
||||||
|
return (
|
||||||
|
<option value={opt.value} key={opt.value}>
|
||||||
|
{opt.name}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</ControlParamItem>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
element = (
|
||||||
|
<ControlParamItem
|
||||||
|
title={item.name}
|
||||||
|
subTitle={item.sub}
|
||||||
|
required={item.required}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={item.name}
|
||||||
|
type="number"
|
||||||
|
min={item.min}
|
||||||
|
max={item.max}
|
||||||
|
value={props.data[item.value] || 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.onChange(item.value, parseInt(e.currentTarget.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ControlParamItem>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
element = (
|
||||||
|
<ControlParamItem
|
||||||
|
title={item.name}
|
||||||
|
subTitle={item.sub}
|
||||||
|
required={item.required}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={item.name}
|
||||||
|
type="text"
|
||||||
|
value={props.data[item.value]}
|
||||||
|
style={{ maxWidth: "100%", width: "100%" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.onChange(item.value, e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ControlParamItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div key={item.value}>{element}</div>;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getModelParamBasicData = (
|
||||||
|
columns: any[],
|
||||||
|
data: any,
|
||||||
|
clearText?: boolean,
|
||||||
|
) => {
|
||||||
|
const newParams: any = {};
|
||||||
|
columns.forEach((item: any) => {
|
||||||
|
if (clearText && ["text", "textarea", "number"].includes(item.type)) {
|
||||||
|
newParams[item.value] = item.default || "";
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
newParams[item.value] = data[item.value] || item.default || "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getParams = (model: any, params: any) => {
|
||||||
|
return models.find((m) => m.value === model.value)?.params(params) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SdPanel() {
|
||||||
|
const sdStore = useSdStore();
|
||||||
|
const currentModel = sdStore.currentModel;
|
||||||
|
const setCurrentModel = sdStore.setCurrentModel;
|
||||||
|
const params = sdStore.currentParams;
|
||||||
|
const setParams = sdStore.setCurrentParams;
|
||||||
|
|
||||||
|
const handleValueChange = (field: string, val: any) => {
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[field]: val,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleModelChange = (model: any) => {
|
||||||
|
setCurrentModel(model);
|
||||||
|
setParams(getModelParamBasicData(model.params({}), params));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlParamItem title={Locale.SdPanel.AIModel}>
|
||||||
|
<div className={styles["ai-models"]}>
|
||||||
|
{models.map((item) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
text={item.name}
|
||||||
|
key={item.value}
|
||||||
|
type={currentModel.value == item.value ? "primary" : null}
|
||||||
|
shadow
|
||||||
|
onClick={() => handleModelChange(item)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ControlParamItem>
|
||||||
|
<ControlParam
|
||||||
|
columns={getParams?.(currentModel, params) as any[]}
|
||||||
|
data={params}
|
||||||
|
onChange={handleValueChange}
|
||||||
|
></ControlParam>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
app/components/sd/sd-sidebar.tsx
Normal file
140
app/components/sd/sd-sidebar.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import GithubIcon from "@/app/icons/github.svg";
|
||||||
|
import SDIcon from "@/app/icons/sd.svg";
|
||||||
|
import ReturnIcon from "@/app/icons/return.svg";
|
||||||
|
import HistoryIcon from "@/app/icons/history.svg";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
|
||||||
|
import { Path, REPO_URL } from "@/app/constant";
|
||||||
|
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import {
|
||||||
|
SideBarContainer,
|
||||||
|
SideBarBody,
|
||||||
|
SideBarHeader,
|
||||||
|
SideBarTail,
|
||||||
|
useDragSideBar,
|
||||||
|
useHotKey,
|
||||||
|
} from "@/app/components/sidebar";
|
||||||
|
|
||||||
|
import { getParams, getModelParamBasicData } from "./sd-panel";
|
||||||
|
import { useSdStore } from "@/app/store/sd";
|
||||||
|
import { showToast } from "@/app/components/ui-lib";
|
||||||
|
import { useMobileScreen } from "@/app/utils";
|
||||||
|
|
||||||
|
const SdPanel = dynamic(
|
||||||
|
async () => (await import("@/app/components/sd")).SdPanel,
|
||||||
|
{
|
||||||
|
loading: () => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function SideBar(props: { className?: string }) {
|
||||||
|
useHotKey();
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const sdStore = useSdStore();
|
||||||
|
const currentModel = sdStore.currentModel;
|
||||||
|
const params = sdStore.currentParams;
|
||||||
|
const setParams = sdStore.setCurrentParams;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const columns = getParams?.(currentModel, params);
|
||||||
|
const reqParams: any = {};
|
||||||
|
for (let i = 0; i < columns.length; i++) {
|
||||||
|
const item = columns[i];
|
||||||
|
reqParams[item.value] = params[item.value] ?? null;
|
||||||
|
if (item.required) {
|
||||||
|
if (!reqParams[item.value]) {
|
||||||
|
showToast(Locale.SdPanel.ParamIsRequired(item.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let data: any = {
|
||||||
|
model: currentModel.value,
|
||||||
|
model_name: currentModel.name,
|
||||||
|
status: "wait",
|
||||||
|
params: reqParams,
|
||||||
|
created_at: new Date().toLocaleString(),
|
||||||
|
img_data: "",
|
||||||
|
};
|
||||||
|
sdStore.sendTask(data, () => {
|
||||||
|
setParams(getModelParamBasicData(columns, params, true));
|
||||||
|
navigate(Path.SdNew);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideBarContainer
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
shouldNarrow={shouldNarrow}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isMobileScreen ? (
|
||||||
|
<div
|
||||||
|
className="window-header"
|
||||||
|
data-tauri-drag-region
|
||||||
|
style={{
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="window-actions">
|
||||||
|
<div className="window-action-button">
|
||||||
|
<IconButton
|
||||||
|
icon={<ReturnIcon />}
|
||||||
|
bordered
|
||||||
|
title={Locale.Sd.Actions.ReturnHome}
|
||||||
|
onClick={() => navigate(Path.Home)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SDIcon width={50} height={50} />
|
||||||
|
<div className="window-actions">
|
||||||
|
<div className="window-action-button">
|
||||||
|
<IconButton
|
||||||
|
icon={<HistoryIcon />}
|
||||||
|
bordered
|
||||||
|
title={Locale.Sd.Actions.History}
|
||||||
|
onClick={() => navigate(Path.SdNew)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SideBarHeader
|
||||||
|
title={
|
||||||
|
<IconButton
|
||||||
|
icon={<ReturnIcon />}
|
||||||
|
bordered
|
||||||
|
title={Locale.Sd.Actions.ReturnHome}
|
||||||
|
onClick={() => navigate(Path.Home)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
logo={<SDIcon width={38} height={"100%"} />}
|
||||||
|
></SideBarHeader>
|
||||||
|
)}
|
||||||
|
<SideBarBody>
|
||||||
|
<SdPanel />
|
||||||
|
</SideBarBody>
|
||||||
|
<SideBarTail
|
||||||
|
primaryAction={
|
||||||
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
|
<IconButton icon={<GithubIcon />} shadow />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
secondaryAction={
|
||||||
|
<IconButton
|
||||||
|
text={Locale.SdPanel.Submit}
|
||||||
|
type="primary"
|
||||||
|
shadow
|
||||||
|
onClick={handleSubmit}
|
||||||
|
></IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SideBarContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
app/components/sd/sd.module.scss
Normal file
53
app/components/sd/sd.module.scss
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
.sd-img-list{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
.sd-img-item{
|
||||||
|
width: 48%;
|
||||||
|
.sd-img-item-info{
|
||||||
|
flex:1;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: text;
|
||||||
|
p{
|
||||||
|
margin: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.line-1{
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pre-img{
|
||||||
|
display: flex;
|
||||||
|
width: 130px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--second);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.img{
|
||||||
|
width: 130px;
|
||||||
|
height: 130px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .3s;
|
||||||
|
&:hover{
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:not(:last-child){
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.sd-img-list{
|
||||||
|
.sd-img-item{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
336
app/components/sd/sd.tsx
Normal file
336
app/components/sd/sd.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import chatStyles from "@/app/components/chat.module.scss";
|
||||||
|
import styles from "@/app/components/sd/sd.module.scss";
|
||||||
|
import homeStyles from "@/app/components/home.module.scss";
|
||||||
|
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import ReturnIcon from "@/app/icons/return.svg";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { Path } from "@/app/constant";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
getMessageTextContent,
|
||||||
|
useMobileScreen,
|
||||||
|
} from "@/app/utils";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAppConfig } from "@/app/store";
|
||||||
|
import MinIcon from "@/app/icons/min.svg";
|
||||||
|
import MaxIcon from "@/app/icons/max.svg";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { ChatAction } from "@/app/components/chat";
|
||||||
|
import DeleteIcon from "@/app/icons/clear.svg";
|
||||||
|
import CopyIcon from "@/app/icons/copy.svg";
|
||||||
|
import PromptIcon from "@/app/icons/prompt.svg";
|
||||||
|
import ResetIcon from "@/app/icons/reload.svg";
|
||||||
|
import { useSdStore } from "@/app/store/sd";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
import ErrorIcon from "@/app/icons/delete.svg";
|
||||||
|
import SDIcon from "@/app/icons/sd.svg";
|
||||||
|
import { Property } from "csstype";
|
||||||
|
import {
|
||||||
|
showConfirm,
|
||||||
|
showImageModal,
|
||||||
|
showModal,
|
||||||
|
} from "@/app/components/ui-lib";
|
||||||
|
import { removeImage } from "@/app/utils/chat";
|
||||||
|
import { SideBar } from "./sd-sidebar";
|
||||||
|
import { WindowContent } from "@/app/components/home";
|
||||||
|
import { params } from "./sd-panel";
|
||||||
|
|
||||||
|
function getSdTaskStatus(item: any) {
|
||||||
|
let s: string;
|
||||||
|
let color: Property.Color | undefined = undefined;
|
||||||
|
switch (item.status) {
|
||||||
|
case "success":
|
||||||
|
s = Locale.Sd.Status.Success;
|
||||||
|
color = "green";
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
s = Locale.Sd.Status.Error;
|
||||||
|
color = "red";
|
||||||
|
break;
|
||||||
|
case "wait":
|
||||||
|
s = Locale.Sd.Status.Wait;
|
||||||
|
color = "yellow";
|
||||||
|
break;
|
||||||
|
case "running":
|
||||||
|
s = Locale.Sd.Status.Running;
|
||||||
|
color = "blue";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
s = item.status.toUpperCase();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className={styles["line-1"]} title={item.error} style={{ color: color }}>
|
||||||
|
<span>
|
||||||
|
{Locale.Sd.Status.Name}: {s}
|
||||||
|
</span>
|
||||||
|
{item.status === "error" && (
|
||||||
|
<span
|
||||||
|
className="clickable"
|
||||||
|
onClick={() => {
|
||||||
|
showModal({
|
||||||
|
title: Locale.Sd.Detail,
|
||||||
|
children: (
|
||||||
|
<div style={{ color: color, userSelect: "text" }}>
|
||||||
|
{item.error}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
- {item.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sd() {
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||||
|
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
|
||||||
|
const config = useAppConfig();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sdStore = useSdStore();
|
||||||
|
const [sdImages, setSdImages] = useState(sdStore.draw);
|
||||||
|
const isSd = location.pathname === Path.Sd;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSdImages(sdStore.draw);
|
||||||
|
}, [sdStore.currentId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SideBar className={isSd ? homeStyles["sidebar-show"] : ""} />
|
||||||
|
<WindowContent>
|
||||||
|
<div className={chatStyles.chat} key={"1"}>
|
||||||
|
<div className="window-header" data-tauri-drag-region>
|
||||||
|
{isMobileScreen && (
|
||||||
|
<div className="window-actions">
|
||||||
|
<div className={"window-action-button"}>
|
||||||
|
<IconButton
|
||||||
|
icon={<ReturnIcon />}
|
||||||
|
bordered
|
||||||
|
title={Locale.Chat.Actions.ChatList}
|
||||||
|
onClick={() => navigate(Path.Sd)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`window-header-title ${chatStyles["chat-body-title"]}`}
|
||||||
|
>
|
||||||
|
<div className={`window-header-main-title`}>Stability AI</div>
|
||||||
|
<div className="window-header-sub-title">
|
||||||
|
{Locale.Sd.SubTitle(sdImages.length || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="window-actions">
|
||||||
|
{showMaxIcon && (
|
||||||
|
<div className="window-action-button">
|
||||||
|
<IconButton
|
||||||
|
aria={Locale.Chat.Actions.FullScreen}
|
||||||
|
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
||||||
|
bordered
|
||||||
|
onClick={() => {
|
||||||
|
config.update(
|
||||||
|
(config) => (config.tightBorder = !config.tightBorder),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isMobileScreen && <SDIcon width={50} height={50} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={chatStyles["chat-body"]} ref={scrollRef}>
|
||||||
|
<div className={styles["sd-img-list"]}>
|
||||||
|
{sdImages.length > 0 ? (
|
||||||
|
sdImages.map((item: any) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{ display: "flex" }}
|
||||||
|
className={styles["sd-img-item"]}
|
||||||
|
>
|
||||||
|
{item.status === "success" ? (
|
||||||
|
<img
|
||||||
|
className={styles["img"]}
|
||||||
|
src={item.img_data}
|
||||||
|
alt={item.id}
|
||||||
|
onClick={(e) =>
|
||||||
|
showImageModal(
|
||||||
|
item.img_data,
|
||||||
|
true,
|
||||||
|
isMobileScreen
|
||||||
|
? { width: "100%", height: "fit-content" }
|
||||||
|
: { maxWidth: "100%", maxHeight: "100%" },
|
||||||
|
isMobileScreen
|
||||||
|
? { width: "100%", height: "fit-content" }
|
||||||
|
: { width: "100%", height: "100%" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : item.status === "error" ? (
|
||||||
|
<div className={styles["pre-img"]}>
|
||||||
|
<ErrorIcon />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles["pre-img"]}>
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{ marginLeft: "10px" }}
|
||||||
|
className={styles["sd-img-item-info"]}
|
||||||
|
>
|
||||||
|
<p className={styles["line-1"]}>
|
||||||
|
{Locale.SdPanel.Prompt}:{" "}
|
||||||
|
<span
|
||||||
|
className="clickable"
|
||||||
|
title={item.params.prompt}
|
||||||
|
onClick={() => {
|
||||||
|
showModal({
|
||||||
|
title: Locale.Sd.Detail,
|
||||||
|
children: (
|
||||||
|
<div style={{ userSelect: "text" }}>
|
||||||
|
{item.params.prompt}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.params.prompt}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{Locale.SdPanel.AIModel}: {item.model_name}
|
||||||
|
</p>
|
||||||
|
{getSdTaskStatus(item)}
|
||||||
|
<p>{item.created_at}</p>
|
||||||
|
<div className={chatStyles["chat-message-actions"]}>
|
||||||
|
<div className={chatStyles["chat-input-actions"]}>
|
||||||
|
<ChatAction
|
||||||
|
text={Locale.Sd.Actions.Params}
|
||||||
|
icon={<PromptIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
showModal({
|
||||||
|
title: Locale.Sd.GenerateParams,
|
||||||
|
children: (
|
||||||
|
<div style={{ userSelect: "text" }}>
|
||||||
|
{Object.keys(item.params).map((key) => {
|
||||||
|
let label = key;
|
||||||
|
let value = item.params[key];
|
||||||
|
switch (label) {
|
||||||
|
case "prompt":
|
||||||
|
label = Locale.SdPanel.Prompt;
|
||||||
|
break;
|
||||||
|
case "negative_prompt":
|
||||||
|
label =
|
||||||
|
Locale.SdPanel.NegativePrompt;
|
||||||
|
break;
|
||||||
|
case "aspect_ratio":
|
||||||
|
label = Locale.SdPanel.AspectRatio;
|
||||||
|
break;
|
||||||
|
case "seed":
|
||||||
|
label = "Seed";
|
||||||
|
value = value || 0;
|
||||||
|
break;
|
||||||
|
case "output_format":
|
||||||
|
label = Locale.SdPanel.OutFormat;
|
||||||
|
value = value?.toUpperCase();
|
||||||
|
break;
|
||||||
|
case "style":
|
||||||
|
label = Locale.SdPanel.ImageStyle;
|
||||||
|
value = params
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
item.value === "style",
|
||||||
|
)
|
||||||
|
?.options?.find(
|
||||||
|
(item) => item.value === value,
|
||||||
|
)?.name;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{ margin: "10px" }}
|
||||||
|
>
|
||||||
|
<strong>{label}: </strong>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChatAction
|
||||||
|
text={Locale.Sd.Actions.Copy}
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(
|
||||||
|
getMessageTextContent({
|
||||||
|
role: "user",
|
||||||
|
content: item.params.prompt,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChatAction
|
||||||
|
text={Locale.Sd.Actions.Retry}
|
||||||
|
icon={<ResetIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
const reqData = {
|
||||||
|
model: item.model,
|
||||||
|
model_name: item.model_name,
|
||||||
|
status: "wait",
|
||||||
|
params: { ...item.params },
|
||||||
|
created_at: new Date().toLocaleString(),
|
||||||
|
img_data: "",
|
||||||
|
};
|
||||||
|
sdStore.sendTask(reqData);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChatAction
|
||||||
|
text={Locale.Sd.Actions.Delete}
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
await showConfirm(Locale.Sd.Danger.Delete)
|
||||||
|
) {
|
||||||
|
// remove img_data + remove item in list
|
||||||
|
removeImage(item.img_data).finally(() => {
|
||||||
|
sdStore.draw = sdImages.filter(
|
||||||
|
(i: any) => i.id !== item.id,
|
||||||
|
);
|
||||||
|
sdStore.getNextId();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div>{Locale.Sd.EmptyRecord}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WindowContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
app/components/search-chat.tsx
Normal file
167
app/components/search-chat.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { ErrorBoundary } from "./error";
|
||||||
|
import styles from "./mask.module.scss";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { IconButton } from "./button";
|
||||||
|
import CloseIcon from "../icons/close.svg";
|
||||||
|
import EyeIcon from "../icons/eye.svg";
|
||||||
|
import Locale from "../locales";
|
||||||
|
import { Path } from "../constant";
|
||||||
|
|
||||||
|
import { useChatStore } from "../store";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
export function SearchChatPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const sessions = chatStore.sessions;
|
||||||
|
const selectSession = chatStore.selectSession;
|
||||||
|
|
||||||
|
const [searchResults, setSearchResults] = useState<Item[]>([]);
|
||||||
|
|
||||||
|
const previousValueRef = useRef<string>("");
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const doSearch = useCallback((text: string) => {
|
||||||
|
const lowerCaseText = text.toLowerCase();
|
||||||
|
const results: Item[] = [];
|
||||||
|
|
||||||
|
sessions.forEach((session, index) => {
|
||||||
|
const fullTextContents: string[] = [];
|
||||||
|
|
||||||
|
session.messages.forEach((message) => {
|
||||||
|
const content = message.content as string;
|
||||||
|
if (!content.toLowerCase || content === "") return;
|
||||||
|
const lowerCaseContent = content.toLowerCase();
|
||||||
|
|
||||||
|
// full text search
|
||||||
|
let pos = lowerCaseContent.indexOf(lowerCaseText);
|
||||||
|
while (pos !== -1) {
|
||||||
|
const start = Math.max(0, pos - 35);
|
||||||
|
const end = Math.min(content.length, pos + lowerCaseText.length + 35);
|
||||||
|
fullTextContents.push(content.substring(start, end));
|
||||||
|
pos = lowerCaseContent.indexOf(
|
||||||
|
lowerCaseText,
|
||||||
|
pos + lowerCaseText.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fullTextContents.length > 0) {
|
||||||
|
results.push({
|
||||||
|
id: index,
|
||||||
|
name: session.topic,
|
||||||
|
content: fullTextContents.join("... "), // concat content with...
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// sort by length of matching content
|
||||||
|
results.sort((a, b) => b.content.length - a.content.length);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
if (searchInputRef.current) {
|
||||||
|
const currentValue = searchInputRef.current.value;
|
||||||
|
if (currentValue !== previousValueRef.current) {
|
||||||
|
if (currentValue.length > 0) {
|
||||||
|
const result = doSearch(currentValue);
|
||||||
|
setSearchResults(result);
|
||||||
|
}
|
||||||
|
previousValueRef.current = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Cleanup the interval on component unmount
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [doSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className={styles["mask-page"]}>
|
||||||
|
{/* header */}
|
||||||
|
<div className="window-header">
|
||||||
|
<div className="window-header-title">
|
||||||
|
<div className="window-header-main-title">
|
||||||
|
{Locale.SearchChat.Page.Title}
|
||||||
|
</div>
|
||||||
|
<div className="window-header-submai-title">
|
||||||
|
{Locale.SearchChat.Page.SubTitle(searchResults.length)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="window-actions">
|
||||||
|
<div className="window-action-button">
|
||||||
|
<IconButton
|
||||||
|
icon={<CloseIcon />}
|
||||||
|
bordered
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["mask-page-body"]}>
|
||||||
|
<div className={styles["mask-filter"]}>
|
||||||
|
{/**搜索输入框 */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles["search-bar"]}
|
||||||
|
placeholder={Locale.SearchChat.Page.Search}
|
||||||
|
autoFocus
|
||||||
|
ref={searchInputRef}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchText = e.currentTarget.value;
|
||||||
|
if (searchText.length > 0) {
|
||||||
|
const result = doSearch(searchText);
|
||||||
|
setSearchResults(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{searchResults.map((item) => (
|
||||||
|
<div
|
||||||
|
className={styles["mask-item"]}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(Path.Chat);
|
||||||
|
selectSession(item.id);
|
||||||
|
}}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{/** 搜索匹配的文本 */}
|
||||||
|
<div className={styles["mask-header"]}>
|
||||||
|
<div className={styles["mask-title"]}>
|
||||||
|
<div className={styles["mask-name"]}>{item.name}</div>
|
||||||
|
{item.content.slice(0, 70)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/** 操作按钮 */}
|
||||||
|
<div className={styles["mask-actions"]}>
|
||||||
|
<IconButton
|
||||||
|
icon={<EyeIcon />}
|
||||||
|
text={Locale.SearchChat.Item.View}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-prompt-modal {
|
.edit-prompt-modal {
|
||||||
@@ -70,3 +72,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtitle-button {
|
||||||
|
button {
|
||||||
|
overflow:visible ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
|
||||||
|
|
||||||
import styles from "./home.module.scss";
|
import styles from "./home.module.scss";
|
||||||
|
|
||||||
@@ -7,44 +7,44 @@ import SettingsIcon from "../icons/settings.svg";
|
|||||||
import GithubIcon from "../icons/github.svg";
|
import GithubIcon from "../icons/github.svg";
|
||||||
import ChatGptIcon from "../icons/chatgpt.svg";
|
import ChatGptIcon from "../icons/chatgpt.svg";
|
||||||
import AddIcon from "../icons/add.svg";
|
import AddIcon from "../icons/add.svg";
|
||||||
import CloseIcon from "../icons/close.svg";
|
import DeleteIcon from "../icons/delete.svg";
|
||||||
import MaskIcon from "../icons/mask.svg";
|
import MaskIcon from "../icons/mask.svg";
|
||||||
import PluginIcon from "../icons/plugin.svg";
|
import DragIcon from "../icons/drag.svg";
|
||||||
|
import DiscoveryIcon from "../icons/discovery.svg";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
|
||||||
import { useAppConfig, useChatStore } from "../store";
|
import { useAppConfig, useChatStore } from "../store";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_SIDEBAR_WIDTH,
|
||||||
MAX_SIDEBAR_WIDTH,
|
MAX_SIDEBAR_WIDTH,
|
||||||
MIN_SIDEBAR_WIDTH,
|
MIN_SIDEBAR_WIDTH,
|
||||||
NARROW_SIDEBAR_WIDTH,
|
NARROW_SIDEBAR_WIDTH,
|
||||||
Path,
|
Path,
|
||||||
|
PLUGINS,
|
||||||
REPO_URL,
|
REPO_URL,
|
||||||
} from "../constant";
|
} from "../constant";
|
||||||
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useMobileScreen } from "../utils";
|
import { isIOS, useMobileScreen } from "../utils";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { showToast } from "./ui-lib";
|
import { showConfirm, Selector } from "./ui-lib";
|
||||||
|
|
||||||
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||||
loading: () => null,
|
loading: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
function useHotKey() {
|
export function useHotKey() {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.metaKey || e.altKey || e.ctrlKey) {
|
if (e.altKey || e.ctrlKey) {
|
||||||
const n = chatStore.sessions.length;
|
|
||||||
const limit = (x: number) => (x + n) % n;
|
|
||||||
const i = chatStore.currentSessionIndex;
|
|
||||||
if (e.key === "ArrowUp") {
|
if (e.key === "ArrowUp") {
|
||||||
chatStore.selectSession(limit(i - 1));
|
chatStore.nextSession(-1);
|
||||||
} else if (e.key === "ArrowDown") {
|
} else if (e.key === "ArrowDown") {
|
||||||
chatStore.selectSession(limit(i + 1));
|
chatStore.nextSession(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -54,36 +54,62 @@ function useHotKey() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDragSideBar() {
|
export function useDragSideBar() {
|
||||||
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
||||||
|
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
const startDragWidth = useRef(config.sidebarWidth ?? 300);
|
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||||
const lastUpdateTime = useRef(Date.now());
|
const lastUpdateTime = useRef(Date.now());
|
||||||
|
|
||||||
const handleMouseMove = useRef((e: MouseEvent) => {
|
const toggleSideBar = () => {
|
||||||
if (Date.now() < lastUpdateTime.current + 50) {
|
config.update((config) => {
|
||||||
|
if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
|
||||||
|
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
} else {
|
||||||
|
config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e: MouseEvent) => {
|
||||||
|
// Remembers the initial width each time the mouse is pressed
|
||||||
|
startX.current = e.clientX;
|
||||||
|
startDragWidth.current = config.sidebarWidth;
|
||||||
|
const dragStartTime = Date.now();
|
||||||
|
|
||||||
|
const handleDragMove = (e: MouseEvent) => {
|
||||||
|
if (Date.now() < lastUpdateTime.current + 20) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastUpdateTime.current = Date.now();
|
lastUpdateTime.current = Date.now();
|
||||||
const d = e.clientX - startX.current;
|
const d = e.clientX - startX.current;
|
||||||
const nextWidth = limit(startDragWidth.current + d);
|
const nextWidth = limit(startDragWidth.current + d);
|
||||||
config.update((config) => (config.sidebarWidth = nextWidth));
|
config.update((config) => {
|
||||||
|
if (nextWidth < MIN_SIDEBAR_WIDTH) {
|
||||||
|
config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
|
||||||
|
} else {
|
||||||
|
config.sidebarWidth = nextWidth;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMouseUp = useRef(() => {
|
|
||||||
startDragWidth.current = config.sidebarWidth ?? 300;
|
|
||||||
window.removeEventListener("mousemove", handleMouseMove.current);
|
|
||||||
window.removeEventListener("mouseup", handleMouseUp.current);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDragMouseDown = (e: MouseEvent) => {
|
|
||||||
startX.current = e.clientX;
|
|
||||||
|
|
||||||
window.addEventListener("mousemove", handleMouseMove.current);
|
|
||||||
window.addEventListener("mouseup", handleMouseUp.current);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
// In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
|
||||||
|
window.removeEventListener("pointermove", handleDragMove);
|
||||||
|
window.removeEventListener("pointerup", handleDragEnd);
|
||||||
|
|
||||||
|
// if user click the drag icon, should toggle the sidebar
|
||||||
|
const shouldFireClick = Date.now() - dragStartTime < 300;
|
||||||
|
if (shouldFireClick) {
|
||||||
|
toggleSideBar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handleDragMove);
|
||||||
|
window.addEventListener("pointerup", handleDragEnd);
|
||||||
|
};
|
||||||
|
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
const shouldNarrow =
|
const shouldNarrow =
|
||||||
!isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
|
!isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
|
||||||
@@ -91,64 +117,164 @@ function useDragSideBar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const barWidth = shouldNarrow
|
const barWidth = shouldNarrow
|
||||||
? NARROW_SIDEBAR_WIDTH
|
? NARROW_SIDEBAR_WIDTH
|
||||||
: limit(config.sidebarWidth ?? 300);
|
: limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||||
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
|
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
|
||||||
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
|
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
|
||||||
}, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
|
}, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onDragMouseDown,
|
onDragStart,
|
||||||
shouldNarrow,
|
shouldNarrow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export function SideBarContainer(props: {
|
||||||
export function SideBar(props: { className?: string }) {
|
children: React.ReactNode;
|
||||||
const chatStore = useChatStore();
|
onDragStart: (e: MouseEvent) => void;
|
||||||
|
shouldNarrow: boolean;
|
||||||
// drag side bar
|
className?: string;
|
||||||
const { onDragMouseDown, shouldNarrow } = useDragSideBar();
|
}) {
|
||||||
const navigate = useNavigate();
|
const isMobileScreen = useMobileScreen();
|
||||||
const config = useAppConfig();
|
const isIOSMobile = useMemo(
|
||||||
|
() => isIOS() && isMobileScreen,
|
||||||
useHotKey();
|
[isMobileScreen],
|
||||||
|
);
|
||||||
|
const { children, className, onDragStart, shouldNarrow } = props;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.sidebar} ${props.className} ${
|
className={`${styles.sidebar} ${className} ${
|
||||||
shouldNarrow && styles["narrow-sidebar"]
|
shouldNarrow && styles["narrow-sidebar"]
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
// #3016 disable transition on ios mobile screen
|
||||||
|
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles["sidebar-header"]} data-tauri-drag-region>
|
{children}
|
||||||
<div className={styles["sidebar-title"]} data-tauri-drag-region>
|
<div
|
||||||
ChatGPT Next
|
className={styles["sidebar-drag"]}
|
||||||
</div>
|
onPointerDown={(e) => onDragStart(e as any)}
|
||||||
<div className={styles["sidebar-sub-title"]}>
|
>
|
||||||
Build your own AI assistant.
|
<DragIcon />
|
||||||
</div>
|
|
||||||
<div className={styles["sidebar-logo"] + " no-dark"}>
|
|
||||||
<ChatGptIcon />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideBarHeader(props: {
|
||||||
|
title?: string | React.ReactNode;
|
||||||
|
subTitle?: string | React.ReactNode;
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
shouldNarrow?: boolean;
|
||||||
|
}) {
|
||||||
|
const { title, subTitle, logo, children, shouldNarrow } = props;
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
className={`${styles["sidebar-header"]} ${
|
||||||
|
shouldNarrow ? styles["sidebar-header-narrow"] : ""
|
||||||
|
}`}
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<div className={styles["sidebar-title-container"]}>
|
||||||
|
<div className={styles["sidebar-title"]} data-tauri-drag-region>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className={styles["sidebar-sub-title"]}>{subTitle}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideBarBody(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||||
|
}) {
|
||||||
|
const { onClick, children } = props;
|
||||||
|
return (
|
||||||
|
<div className={styles["sidebar-body"]} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideBarTail(props: {
|
||||||
|
primaryAction?: React.ReactNode;
|
||||||
|
secondaryAction?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { primaryAction, secondaryAction } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["sidebar-tail"]}>
|
||||||
|
<div className={styles["sidebar-actions"]}>{primaryAction}</div>
|
||||||
|
<div className={styles["sidebar-actions"]}>{secondaryAction}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideBar(props: { className?: string }) {
|
||||||
|
useHotKey();
|
||||||
|
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||||
|
const [showPluginSelector, setShowPluginSelector] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideBarContainer
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
shouldNarrow={shouldNarrow}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SideBarHeader
|
||||||
|
title="NextChat"
|
||||||
|
subTitle="Build your own AI assistant."
|
||||||
|
logo={<ChatGptIcon />}
|
||||||
|
shouldNarrow={shouldNarrow}
|
||||||
|
>
|
||||||
<div className={styles["sidebar-header-bar"]}>
|
<div className={styles["sidebar-header-bar"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<MaskIcon />}
|
icon={<MaskIcon />}
|
||||||
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
||||||
className={styles["sidebar-bar-button"]}
|
className={styles["sidebar-bar-button"]}
|
||||||
onClick={() => navigate(Path.NewChat, { state: { fromHome: true } })}
|
onClick={() => {
|
||||||
|
if (config.dontShowMaskSplashScreen !== true) {
|
||||||
|
navigate(Path.NewChat, { state: { fromHome: true } });
|
||||||
|
} else {
|
||||||
|
navigate(Path.Masks, { state: { fromHome: true } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
shadow
|
shadow
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<PluginIcon />}
|
icon={<DiscoveryIcon />}
|
||||||
text={shouldNarrow ? undefined : Locale.Plugin.Name}
|
text={shouldNarrow ? undefined : Locale.Discovery.Name}
|
||||||
className={styles["sidebar-bar-button"]}
|
className={styles["sidebar-bar-button"]}
|
||||||
onClick={() => showToast(Locale.WIP)}
|
onClick={() => setShowPluginSelector(true)}
|
||||||
shadow
|
shadow
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{showPluginSelector && (
|
||||||
<div
|
<Selector
|
||||||
className={styles["sidebar-body"]}
|
items={[
|
||||||
|
...PLUGINS.map((item) => {
|
||||||
|
return {
|
||||||
|
title: item.name,
|
||||||
|
value: item.path,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
onClose={() => setShowPluginSelector(false)}
|
||||||
|
onSelection={(s) => {
|
||||||
|
navigate(s[0], { state: { fromHome: true } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SideBarHeader>
|
||||||
|
<SideBarBody
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
navigate(Path.Home);
|
navigate(Path.Home);
|
||||||
@@ -156,15 +282,15 @@ export function SideBar(props: { className?: string }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChatList narrow={shouldNarrow} />
|
<ChatList narrow={shouldNarrow} />
|
||||||
</div>
|
</SideBarBody>
|
||||||
|
<SideBarTail
|
||||||
<div className={styles["sidebar-tail"]}>
|
primaryAction={
|
||||||
<div className={styles["sidebar-actions"]}>
|
<>
|
||||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<CloseIcon />}
|
icon={<DeleteIcon />}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (confirm(Locale.Home.DeleteChat)) {
|
if (await showConfirm(Locale.Home.DeleteChat)) {
|
||||||
chatStore.deleteSession(chatStore.currentSessionIndex);
|
chatStore.deleteSession(chatStore.currentSessionIndex);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -172,16 +298,25 @@ export function SideBar(props: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles["sidebar-action"]}>
|
<div className={styles["sidebar-action"]}>
|
||||||
<Link to={Path.Settings}>
|
<Link to={Path.Settings}>
|
||||||
<IconButton icon={<SettingsIcon />} shadow />
|
<IconButton
|
||||||
|
aria={Locale.Settings.Title}
|
||||||
|
icon={<SettingsIcon />}
|
||||||
|
shadow
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["sidebar-action"]}>
|
<div className={styles["sidebar-action"]}>
|
||||||
<a href={REPO_URL} target="_blank">
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
<IconButton icon={<GithubIcon />} shadow />
|
<IconButton
|
||||||
|
aria={Locale.Export.MessageFromChatGPT}
|
||||||
|
icon={<GithubIcon />}
|
||||||
|
shadow
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<div>
|
}
|
||||||
|
secondaryAction={
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<AddIcon />}
|
icon={<AddIcon />}
|
||||||
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
||||||
@@ -195,13 +330,8 @@ export function SideBar(props: { className?: string }) {
|
|||||||
}}
|
}}
|
||||||
shadow
|
shadow
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
</SideBarContainer>
|
||||||
<div
|
|
||||||
className={styles["sidebar-drag"]}
|
|
||||||
onMouseDown={(e) => onDragMouseDown(e as any)}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
133
app/components/tts-config.tsx
Normal file
133
app/components/tts-config.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { TTSConfig, TTSConfigValidator } from "../store";
|
||||||
|
|
||||||
|
import Locale from "../locales";
|
||||||
|
import { ListItem, Select } from "./ui-lib";
|
||||||
|
import {
|
||||||
|
DEFAULT_TTS_ENGINE,
|
||||||
|
DEFAULT_TTS_ENGINES,
|
||||||
|
DEFAULT_TTS_MODELS,
|
||||||
|
DEFAULT_TTS_VOICES,
|
||||||
|
} from "../constant";
|
||||||
|
import { InputRange } from "./input-range";
|
||||||
|
|
||||||
|
export function TTSConfigList(props: {
|
||||||
|
ttsConfig: TTSConfig;
|
||||||
|
updateConfig: (updater: (config: TTSConfig) => void) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.TTS.Enable.Title}
|
||||||
|
subTitle={Locale.Settings.TTS.Enable.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.ttsConfig.enable}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.enable = e.currentTarget.checked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
{/* <ListItem
|
||||||
|
title={Locale.Settings.TTS.Autoplay.Title}
|
||||||
|
subTitle={Locale.Settings.TTS.Autoplay.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.ttsConfig.autoplay}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.autoplay = e.currentTarget.checked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem> */}
|
||||||
|
<ListItem title={Locale.Settings.TTS.Engine}>
|
||||||
|
<Select
|
||||||
|
value={props.ttsConfig.engine}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.engine = TTSConfigValidator.engine(
|
||||||
|
e.currentTarget.value,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DEFAULT_TTS_ENGINES.map((v, i) => (
|
||||||
|
<option value={v} key={i}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
{props.ttsConfig.engine === DEFAULT_TTS_ENGINE && (
|
||||||
|
<>
|
||||||
|
<ListItem title={Locale.Settings.TTS.Model}>
|
||||||
|
<Select
|
||||||
|
value={props.ttsConfig.model}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.model = TTSConfigValidator.model(
|
||||||
|
e.currentTarget.value,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DEFAULT_TTS_MODELS.map((v, i) => (
|
||||||
|
<option value={v} key={i}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.TTS.Voice.Title}
|
||||||
|
subTitle={Locale.Settings.TTS.Voice.SubTitle}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={props.ttsConfig.voice}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.voice = TTSConfigValidator.voice(
|
||||||
|
e.currentTarget.value,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DEFAULT_TTS_VOICES.map((v, i) => (
|
||||||
|
<option value={v} key={i}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.TTS.Speed.Title}
|
||||||
|
subTitle={Locale.Settings.TTS.Speed.SubTitle}
|
||||||
|
>
|
||||||
|
<InputRange
|
||||||
|
aria={Locale.Settings.TTS.Speed.Title}
|
||||||
|
value={props.ttsConfig.speed?.toFixed(1)}
|
||||||
|
min="0.3"
|
||||||
|
max="4.0"
|
||||||
|
step="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.speed = TTSConfigValidator.speed(
|
||||||
|
e.currentTarget.valueAsNumber,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
app/components/tts.module.scss
Normal file
119
app/components/tts.module.scss
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
@import "../styles/animation.scss";
|
||||||
|
.plugin-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.plugin-page-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.plugin-filter {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar:focus {
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-filter-lang {
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-create {
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.plugin-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-title {
|
||||||
|
.plugin-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.plugin-info {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.plugin-runtime-warning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f86c6c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: var(--border-in-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,17 +14,24 @@
|
|||||||
|
|
||||||
.popover-content {
|
.popover-content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
width: 350px;
|
||||||
animation: slide-in 0.3s ease;
|
animation: slide-in 0.3s ease;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: calc(100% + 10px);
|
top: calc(100% + 10px);
|
||||||
}
|
}
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.popover-content {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
.popover-mask {
|
.popover-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item {
|
.list-item {
|
||||||
@@ -54,6 +61,19 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
.list-header {
|
||||||
|
.list-item-title {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.list-item-sub-title {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
@@ -62,6 +82,7 @@
|
|||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
animation: slide-in ease 0.3s;
|
animation: slide-in ease 0.3s;
|
||||||
|
background: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list .list-item:last-child {
|
.list .list-item:last-child {
|
||||||
@@ -72,11 +93,26 @@
|
|||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
background-color: var(--white);
|
background-color: var(--white);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
width: 60vw;
|
width: 80vw;
|
||||||
|
max-width: 900px;
|
||||||
|
min-width: 300px;
|
||||||
animation: slide-in ease 0.3s;
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
--modal-padding: 20px;
|
--modal-padding: 20px;
|
||||||
|
|
||||||
|
&-max {
|
||||||
|
width: 95vw;
|
||||||
|
max-width: unset;
|
||||||
|
height: 95vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-height: unset !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
padding: var(--modal-padding);
|
padding: var(--modal-padding);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -89,14 +125,22 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-btn {
|
.modal-header-actions {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.modal-header-action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
@@ -208,10 +252,16 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
max-width: fit-content;
|
max-width: fit-content;
|
||||||
|
|
||||||
|
&.left-align-option {
|
||||||
|
option {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.select-with-icon-select {
|
.select-with-icon-select {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: var(--border-in-light);
|
border: var(--border-in-light);
|
||||||
padding: 10px 25px 10px 10px;
|
padding: 10px 35px 10px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -228,3 +278,62 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-input {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 10px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
.selector-item-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
min-width: 300px;
|
||||||
|
.list {
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import styles from "./ui-lib.module.scss";
|
import styles from "./ui-lib.module.scss";
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
import CloseIcon from "../icons/close.svg";
|
import CloseIcon from "../icons/close.svg";
|
||||||
import EyeIcon from "../icons/eye.svg";
|
import EyeIcon from "../icons/eye.svg";
|
||||||
import EyeOffIcon from "../icons/eye-off.svg";
|
import EyeOffIcon from "../icons/eye-off.svg";
|
||||||
import DownIcon from "../icons/down.svg";
|
import DownIcon from "../icons/down.svg";
|
||||||
|
import ConfirmIcon from "../icons/confirm.svg";
|
||||||
|
import CancelIcon from "../icons/cancel.svg";
|
||||||
|
import MaxIcon from "../icons/max.svg";
|
||||||
|
import MinIcon from "../icons/min.svg";
|
||||||
|
|
||||||
|
import Locale from "../locales";
|
||||||
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import React, { HTMLProps, useEffect, useState } from "react";
|
import React, {
|
||||||
|
CSSProperties,
|
||||||
|
HTMLProps,
|
||||||
|
MouseEvent,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
|
|
||||||
export function Popover(props: {
|
export function Popover(props: {
|
||||||
@@ -19,10 +34,10 @@ export function Popover(props: {
|
|||||||
<div className={styles.popover}>
|
<div className={styles.popover}>
|
||||||
{props.children}
|
{props.children}
|
||||||
{props.open && (
|
{props.open && (
|
||||||
<div className={styles["popover-content"]}>
|
|
||||||
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
|
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
|
||||||
{props.content}
|
)}
|
||||||
</div>
|
{props.open && (
|
||||||
|
<div className={styles["popover-content"]}>{props.content}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -35,14 +50,23 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ListItem(props: {
|
export function ListItem(props: {
|
||||||
title: string;
|
title?: string;
|
||||||
subTitle?: string;
|
subTitle?: string | JSX.Element;
|
||||||
children?: JSX.Element | JSX.Element[];
|
children?: JSX.Element | JSX.Element[];
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onClick?: (e: MouseEvent) => void;
|
||||||
|
vertical?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles["list-item"] + ` ${props.className || ""}`}>
|
<div
|
||||||
|
className={
|
||||||
|
styles["list-item"] +
|
||||||
|
` ${props.vertical ? styles["vertical"] : ""} ` +
|
||||||
|
` ${props.className || ""}`
|
||||||
|
}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
<div className={styles["list-header"]}>
|
<div className={styles["list-header"]}>
|
||||||
{props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
|
{props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
|
||||||
<div className={styles["list-item-title"]}>
|
<div className={styles["list-item-title"]}>
|
||||||
@@ -59,14 +83,12 @@ export function ListItem(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function List(props: {
|
export function List(props: { children: React.ReactNode; id?: string }) {
|
||||||
children:
|
return (
|
||||||
| Array<JSX.Element | null | undefined>
|
<div className={styles.list} id={props.id}>
|
||||||
| JSX.Element
|
{props.children}
|
||||||
| null
|
</div>
|
||||||
| undefined;
|
);
|
||||||
}) {
|
|
||||||
return <div className={styles.list}>{props.children}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Loading() {
|
export function Loading() {
|
||||||
@@ -87,8 +109,10 @@ export function Loading() {
|
|||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
children?: JSX.Element | JSX.Element[];
|
children?: any;
|
||||||
actions?: JSX.Element[];
|
actions?: React.ReactNode[];
|
||||||
|
defaultMax?: boolean;
|
||||||
|
footer?: React.ReactNode;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
export function Modal(props: ModalProps) {
|
export function Modal(props: ModalProps) {
|
||||||
@@ -107,19 +131,37 @@ export function Modal(props: ModalProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [isMax, setMax] = useState(!!props.defaultMax);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["modal-container"]}>
|
<div
|
||||||
|
className={
|
||||||
|
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className={styles["modal-header"]}>
|
<div className={styles["modal-header"]}>
|
||||||
<div className={styles["modal-title"]}>{props.title}</div>
|
<div className={styles["modal-title"]}>{props.title}</div>
|
||||||
|
|
||||||
<div className={styles["modal-close-btn"]} onClick={props.onClose}>
|
<div className={styles["modal-header-actions"]}>
|
||||||
|
<div
|
||||||
|
className={styles["modal-header-action"]}
|
||||||
|
onClick={() => setMax(!isMax)}
|
||||||
|
>
|
||||||
|
{isMax ? <MinIcon /> : <MaxIcon />}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles["modal-header-action"]}
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles["modal-content"]}>{props.children}</div>
|
<div className={styles["modal-content"]}>{props.children}</div>
|
||||||
|
|
||||||
<div className={styles["modal-footer"]}>
|
<div className={styles["modal-footer"]}>
|
||||||
|
{props.footer}
|
||||||
<div className={styles["modal-actions"]}>
|
<div className={styles["modal-actions"]}>
|
||||||
{props.actions?.map((action, i) => (
|
{props.actions?.map((action, i) => (
|
||||||
<div key={i} className={styles["modal-action"]}>
|
<div key={i} className={styles["modal-action"]}>
|
||||||
@@ -223,9 +265,10 @@ export function Input(props: InputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
export function PasswordInput(
|
||||||
|
props: HTMLProps<HTMLInputElement> & { aria?: string },
|
||||||
|
) {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
function changeVisibility() {
|
function changeVisibility() {
|
||||||
setVisible(!visible);
|
setVisible(!visible);
|
||||||
}
|
}
|
||||||
@@ -233,6 +276,7 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
|||||||
return (
|
return (
|
||||||
<div className={"password-input-container"}>
|
<div className={"password-input-container"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
aria={props.aria}
|
||||||
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
||||||
onClick={changeVisibility}
|
onClick={changeVisibility}
|
||||||
className={"password-eye"}
|
className={"password-eye"}
|
||||||
@@ -248,13 +292,19 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
|||||||
|
|
||||||
export function Select(
|
export function Select(
|
||||||
props: React.DetailedHTMLProps<
|
props: React.DetailedHTMLProps<
|
||||||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
React.SelectHTMLAttributes<HTMLSelectElement> & {
|
||||||
|
align?: "left" | "center";
|
||||||
|
},
|
||||||
HTMLSelectElement
|
HTMLSelectElement
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
const { className, children, ...otherProps } = props;
|
const { className, children, align, ...otherProps } = props;
|
||||||
return (
|
return (
|
||||||
<div className={`${styles["select-with-icon"]} ${className}`}>
|
<div
|
||||||
|
className={`${styles["select-with-icon"]} ${
|
||||||
|
align === "left" ? styles["left-align-option"] : ""
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</select>
|
||||||
@@ -262,3 +312,269 @@ export function Select(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function showConfirm(content: any) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "modal-mask";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
|
||||||
|
const root = createRoot(div);
|
||||||
|
const closeModal = () => {
|
||||||
|
root.unmount();
|
||||||
|
div.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
root.render(
|
||||||
|
<Modal
|
||||||
|
title={Locale.UI.Confirm}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="cancel"
|
||||||
|
text={Locale.UI.Cancel}
|
||||||
|
onClick={() => {
|
||||||
|
resolve(false);
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
icon={<CancelIcon />}
|
||||||
|
tabIndex={0}
|
||||||
|
bordered
|
||||||
|
shadow
|
||||||
|
></IconButton>,
|
||||||
|
<IconButton
|
||||||
|
key="confirm"
|
||||||
|
text={Locale.UI.Confirm}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
resolve(true);
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
icon={<ConfirmIcon />}
|
||||||
|
tabIndex={0}
|
||||||
|
autoFocus
|
||||||
|
bordered
|
||||||
|
shadow
|
||||||
|
></IconButton>,
|
||||||
|
]}
|
||||||
|
onClose={closeModal}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptInput(props: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
rows?: number;
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = useState(props.value);
|
||||||
|
const onInput = (value: string) => {
|
||||||
|
props.onChange(value);
|
||||||
|
setInput(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={styles["modal-input"]}
|
||||||
|
autoFocus
|
||||||
|
value={input}
|
||||||
|
onInput={(e) => onInput(e.currentTarget.value)}
|
||||||
|
rows={props.rows ?? 3}
|
||||||
|
></textarea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPrompt(content: any, value = "", rows = 3) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "modal-mask";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
|
||||||
|
const root = createRoot(div);
|
||||||
|
const closeModal = () => {
|
||||||
|
root.unmount();
|
||||||
|
div.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
let userInput = value;
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<Modal
|
||||||
|
title={content}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="cancel"
|
||||||
|
text={Locale.UI.Cancel}
|
||||||
|
onClick={() => {
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
icon={<CancelIcon />}
|
||||||
|
bordered
|
||||||
|
shadow
|
||||||
|
tabIndex={0}
|
||||||
|
></IconButton>,
|
||||||
|
<IconButton
|
||||||
|
key="confirm"
|
||||||
|
text={Locale.UI.Confirm}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
resolve(userInput);
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
icon={<ConfirmIcon />}
|
||||||
|
bordered
|
||||||
|
shadow
|
||||||
|
tabIndex={0}
|
||||||
|
></IconButton>,
|
||||||
|
]}
|
||||||
|
onClose={closeModal}
|
||||||
|
>
|
||||||
|
<PromptInput
|
||||||
|
onChange={(val) => (userInput = val)}
|
||||||
|
value={value}
|
||||||
|
rows={rows}
|
||||||
|
></PromptInput>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showImageModal(
|
||||||
|
img: string,
|
||||||
|
defaultMax?: boolean,
|
||||||
|
style?: CSSProperties,
|
||||||
|
boxStyle?: CSSProperties,
|
||||||
|
) {
|
||||||
|
showModal({
|
||||||
|
title: Locale.Export.Image.Modal,
|
||||||
|
defaultMax: defaultMax,
|
||||||
|
children: (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt="preview"
|
||||||
|
style={
|
||||||
|
style ?? {
|
||||||
|
maxWidth: "100%",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
></img>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Selector<T>(props: {
|
||||||
|
items: Array<{
|
||||||
|
title: string;
|
||||||
|
subTitle?: string;
|
||||||
|
value: T;
|
||||||
|
disable?: boolean;
|
||||||
|
}>;
|
||||||
|
defaultSelectedValue?: T[] | T;
|
||||||
|
onSelection?: (selection: T[]) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
multiple?: boolean;
|
||||||
|
}) {
|
||||||
|
const [selectedValues, setSelectedValues] = useState<T[]>(
|
||||||
|
Array.isArray(props.defaultSelectedValue)
|
||||||
|
? props.defaultSelectedValue
|
||||||
|
: props.defaultSelectedValue !== undefined
|
||||||
|
? [props.defaultSelectedValue]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelection = (e: MouseEvent, value: T) => {
|
||||||
|
if (props.multiple) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newSelectedValues = selectedValues.includes(value)
|
||||||
|
? selectedValues.filter((v) => v !== value)
|
||||||
|
: [...selectedValues, value];
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
props.onSelection?.(newSelectedValues);
|
||||||
|
} else {
|
||||||
|
setSelectedValues([value]);
|
||||||
|
props.onSelection?.([value]);
|
||||||
|
props.onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
||||||
|
<div className={styles["selector-content"]}>
|
||||||
|
<List>
|
||||||
|
{props.items.map((item, i) => {
|
||||||
|
const selected = selectedValues.includes(item.value);
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
className={`${styles["selector-item"]} ${
|
||||||
|
item.disable && styles["selector-item-disabled"]
|
||||||
|
}`}
|
||||||
|
key={i}
|
||||||
|
title={item.title}
|
||||||
|
subTitle={item.subTitle}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (item.disable) {
|
||||||
|
e.stopPropagation();
|
||||||
|
} else {
|
||||||
|
handleSelection(e, item.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 10,
|
||||||
|
width: 10,
|
||||||
|
backgroundColor: "var(--primary)",
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function FullScreen(props: any) {
|
||||||
|
const { children, right = 10, top = 10, ...rest } = props;
|
||||||
|
const ref = useRef<HTMLDivElement>();
|
||||||
|
const [fullScreen, setFullScreen] = useState(false);
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
ref.current?.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScreenChange = (e: any) => {
|
||||||
|
if (e.target === ref.current) {
|
||||||
|
setFullScreen(!!document.fullscreenElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", handleScreenChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("fullscreenchange", handleScreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: "relative" }} {...rest}>
|
||||||
|
<div style={{ position: "absolute", right, top }}>
|
||||||
|
<IconButton
|
||||||
|
icon={fullScreen ? <MinIcon /> : <MaxIcon />}
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import tauriConfig from "../../src-tauri/tauri.conf.json";
|
||||||
|
import { DEFAULT_INPUT_TEMPLATE } from "../constant";
|
||||||
|
|
||||||
export const getBuildConfig = () => {
|
export const getBuildConfig = () => {
|
||||||
if (typeof process === "undefined") {
|
if (typeof process === "undefined") {
|
||||||
throw Error(
|
throw Error(
|
||||||
@@ -5,23 +8,38 @@ export const getBuildConfig = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMIT_ID: string = (() => {
|
const buildMode = process.env.BUILD_MODE ?? "standalone";
|
||||||
|
const isApp = !!process.env.BUILD_APP;
|
||||||
|
const version = "v" + tauriConfig.package.version;
|
||||||
|
|
||||||
|
const commitInfo = (() => {
|
||||||
try {
|
try {
|
||||||
const childProcess = require("child_process");
|
const childProcess = require("child_process");
|
||||||
return childProcess
|
const commitDate: string = childProcess
|
||||||
.execSync('git log -1 --format="%at000" --date=unix')
|
.execSync('git log -1 --format="%at000" --date=unix')
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
const commitHash: string = childProcess
|
||||||
|
.execSync('git log --pretty=format:"%H" -n 1')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return { commitDate, commitHash };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Build Config] No git or not from git repo.");
|
console.error("[Build Config] No git or not from git repo.");
|
||||||
return "unknown";
|
return {
|
||||||
|
commitDate: "unknown",
|
||||||
|
commitHash: "unknown",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitId: COMMIT_ID,
|
version,
|
||||||
buildMode: process.env.BUILD_MODE ?? "standalone",
|
...commitInfo,
|
||||||
isApp: !!process.env.BUILD_APP,
|
buildMode,
|
||||||
|
isApp,
|
||||||
|
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build";
|
|||||||
export function getClientConfig() {
|
export function getClientConfig() {
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
// client side
|
// client side
|
||||||
return JSON.parse(queryMeta("config")) as BuildConfig;
|
return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof process !== "undefined") {
|
if (typeof process !== "undefined") {
|
||||||
|
|||||||
@@ -1,17 +1,78 @@
|
|||||||
import md5 from "spark-md5";
|
import md5 from "spark-md5";
|
||||||
|
import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
interface ProcessEnv {
|
interface ProcessEnv {
|
||||||
|
PROXY_URL?: string; // docker only
|
||||||
|
|
||||||
OPENAI_API_KEY?: string;
|
OPENAI_API_KEY?: string;
|
||||||
CODE?: string;
|
CODE?: string;
|
||||||
|
|
||||||
BASE_URL?: string;
|
BASE_URL?: string;
|
||||||
PROXY_URL?: string;
|
OPENAI_ORG_ID?: string; // openai only
|
||||||
|
|
||||||
VERCEL?: string;
|
VERCEL?: string;
|
||||||
HIDE_USER_API_KEY?: string; // disable user's api key input
|
|
||||||
DISABLE_GPT4?: string; // allow user to use gpt-4 or not
|
|
||||||
BUILD_MODE?: "standalone" | "export";
|
BUILD_MODE?: "standalone" | "export";
|
||||||
BUILD_APP?: string; // is building desktop app
|
BUILD_APP?: string; // is building desktop app
|
||||||
|
|
||||||
|
HIDE_USER_API_KEY?: string; // disable user's api key input
|
||||||
|
DISABLE_GPT4?: string; // allow user to use gpt-4 or not
|
||||||
|
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
|
||||||
|
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
||||||
|
CUSTOM_MODELS?: string; // to control custom models
|
||||||
|
DEFAULT_MODEL?: string; // to control default model in every new chat window
|
||||||
|
|
||||||
|
// stability only
|
||||||
|
STABILITY_URL?: string;
|
||||||
|
STABILITY_API_KEY?: string;
|
||||||
|
|
||||||
|
// azure only
|
||||||
|
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
||||||
|
AZURE_API_KEY?: string;
|
||||||
|
AZURE_API_VERSION?: string;
|
||||||
|
|
||||||
|
// google only
|
||||||
|
GOOGLE_API_KEY?: string;
|
||||||
|
GOOGLE_URL?: string;
|
||||||
|
|
||||||
|
// google tag manager
|
||||||
|
GTM_ID?: string;
|
||||||
|
|
||||||
|
// anthropic only
|
||||||
|
ANTHROPIC_URL?: string;
|
||||||
|
ANTHROPIC_API_KEY?: string;
|
||||||
|
ANTHROPIC_API_VERSION?: string;
|
||||||
|
|
||||||
|
// baidu only
|
||||||
|
BAIDU_URL?: string;
|
||||||
|
BAIDU_API_KEY?: string;
|
||||||
|
BAIDU_SECRET_KEY?: string;
|
||||||
|
|
||||||
|
// bytedance only
|
||||||
|
BYTEDANCE_URL?: string;
|
||||||
|
BYTEDANCE_API_KEY?: string;
|
||||||
|
|
||||||
|
// alibaba only
|
||||||
|
ALIBABA_URL?: string;
|
||||||
|
ALIBABA_API_KEY?: string;
|
||||||
|
|
||||||
|
// tencent only
|
||||||
|
TENCENT_URL?: string;
|
||||||
|
TENCENT_SECRET_KEY?: string;
|
||||||
|
TENCENT_SECRET_ID?: string;
|
||||||
|
|
||||||
|
// moonshot only
|
||||||
|
MOONSHOT_URL?: string;
|
||||||
|
MOONSHOT_API_KEY?: string;
|
||||||
|
|
||||||
|
// iflytek only
|
||||||
|
IFLYTEK_URL?: string;
|
||||||
|
IFLYTEK_API_KEY?: string;
|
||||||
|
IFLYTEK_API_SECRET?: string;
|
||||||
|
|
||||||
|
// custom template for preprocessing user input
|
||||||
|
DEFAULT_INPUT_TEMPLATE?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +90,22 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function getApiKey(keys?: string) {
|
||||||
|
const apiKeyEnvVar = keys ?? "";
|
||||||
|
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||||
|
const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||||
|
const apiKey = apiKeys[randomIndex];
|
||||||
|
if (apiKey) {
|
||||||
|
console.log(
|
||||||
|
`[Server Config] using ${randomIndex + 1} of ${
|
||||||
|
apiKeys.length
|
||||||
|
} api key - ${apiKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
export const getServerSideConfig = () => {
|
export const getServerSideConfig = () => {
|
||||||
if (typeof process === "undefined") {
|
if (typeof process === "undefined") {
|
||||||
throw Error(
|
throw Error(
|
||||||
@@ -36,15 +113,122 @@ export const getServerSideConfig = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disableGPT4 = !!process.env.DISABLE_GPT4;
|
||||||
|
let customModels = process.env.CUSTOM_MODELS ?? "";
|
||||||
|
let defaultModel = process.env.DEFAULT_MODEL ?? "";
|
||||||
|
|
||||||
|
if (disableGPT4) {
|
||||||
|
if (customModels) customModels += ",";
|
||||||
|
customModels += DEFAULT_MODELS.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) &&
|
||||||
|
!m.name.startsWith("gpt-4o-mini"),
|
||||||
|
)
|
||||||
|
.map((m) => "-" + m.name)
|
||||||
|
.join(",");
|
||||||
|
if (
|
||||||
|
(defaultModel.startsWith("gpt-4") ||
|
||||||
|
defaultModel.startsWith("chatgpt-4o")) &&
|
||||||
|
!defaultModel.startsWith("gpt-4o-mini")
|
||||||
|
)
|
||||||
|
defaultModel = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStability = !!process.env.STABILITY_API_KEY;
|
||||||
|
|
||||||
|
const isAzure = !!process.env.AZURE_URL;
|
||||||
|
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
||||||
|
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
const isTencent = !!process.env.TENCENT_API_KEY;
|
||||||
|
|
||||||
|
const isBaidu = !!process.env.BAIDU_API_KEY;
|
||||||
|
const isBytedance = !!process.env.BYTEDANCE_API_KEY;
|
||||||
|
const isAlibaba = !!process.env.ALIBABA_API_KEY;
|
||||||
|
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
|
||||||
|
const isIflytek = !!process.env.IFLYTEK_API_KEY;
|
||||||
|
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
||||||
|
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||||
|
// const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||||
|
// const apiKey = apiKeys[randomIndex];
|
||||||
|
// console.log(
|
||||||
|
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
||||||
|
// );
|
||||||
|
|
||||||
|
const allowedWebDavEndpoints = (
|
||||||
|
process.env.WHITE_WEBDAV_ENDPOINTS ?? ""
|
||||||
|
).split(",");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
baseUrl: process.env.BASE_URL,
|
||||||
|
apiKey: getApiKey(process.env.OPENAI_API_KEY),
|
||||||
|
openaiOrgId: process.env.OPENAI_ORG_ID,
|
||||||
|
|
||||||
|
isStability,
|
||||||
|
stabilityUrl: process.env.STABILITY_URL,
|
||||||
|
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
|
||||||
|
|
||||||
|
isAzure,
|
||||||
|
azureUrl: process.env.AZURE_URL,
|
||||||
|
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
|
||||||
|
azureApiVersion: process.env.AZURE_API_VERSION,
|
||||||
|
|
||||||
|
isGoogle,
|
||||||
|
googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
|
||||||
|
googleUrl: process.env.GOOGLE_URL,
|
||||||
|
|
||||||
|
isAnthropic,
|
||||||
|
anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
|
||||||
|
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
||||||
|
anthropicUrl: process.env.ANTHROPIC_URL,
|
||||||
|
|
||||||
|
isBaidu,
|
||||||
|
baiduUrl: process.env.BAIDU_URL,
|
||||||
|
baiduApiKey: getApiKey(process.env.BAIDU_API_KEY),
|
||||||
|
baiduSecretKey: process.env.BAIDU_SECRET_KEY,
|
||||||
|
|
||||||
|
isBytedance,
|
||||||
|
bytedanceApiKey: getApiKey(process.env.BYTEDANCE_API_KEY),
|
||||||
|
bytedanceUrl: process.env.BYTEDANCE_URL,
|
||||||
|
|
||||||
|
isAlibaba,
|
||||||
|
alibabaUrl: process.env.ALIBABA_URL,
|
||||||
|
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
|
||||||
|
|
||||||
|
isTencent,
|
||||||
|
tencentUrl: process.env.TENCENT_URL,
|
||||||
|
tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY),
|
||||||
|
tencentSecretId: process.env.TENCENT_SECRET_ID,
|
||||||
|
|
||||||
|
isMoonshot,
|
||||||
|
moonshotUrl: process.env.MOONSHOT_URL,
|
||||||
|
moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
|
||||||
|
|
||||||
|
isIflytek,
|
||||||
|
iflytekUrl: process.env.IFLYTEK_URL,
|
||||||
|
iflytekApiKey: process.env.IFLYTEK_API_KEY,
|
||||||
|
iflytekApiSecret: process.env.IFLYTEK_API_SECRET,
|
||||||
|
|
||||||
|
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||||
|
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
|
||||||
|
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
|
||||||
|
cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
|
||||||
|
|
||||||
|
gtmId: process.env.GTM_ID,
|
||||||
|
gaId: process.env.GA_ID || DEFAULT_GA_ID,
|
||||||
|
|
||||||
|
needCode: ACCESS_CODES.size > 0,
|
||||||
code: process.env.CODE,
|
code: process.env.CODE,
|
||||||
codes: ACCESS_CODES,
|
codes: ACCESS_CODES,
|
||||||
needCode: ACCESS_CODES.size > 0,
|
|
||||||
baseUrl: process.env.BASE_URL,
|
|
||||||
proxyUrl: process.env.PROXY_URL,
|
proxyUrl: process.env.PROXY_URL,
|
||||||
isVercel: !!process.env.VERCEL,
|
isVercel: !!process.env.VERCEL,
|
||||||
|
|
||||||
hideUserApiKey: !!process.env.HIDE_USER_API_KEY,
|
hideUserApiKey: !!process.env.HIDE_USER_API_KEY,
|
||||||
enableGPT4: !process.env.DISABLE_GPT4,
|
disableGPT4,
|
||||||
|
hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY,
|
||||||
|
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||||
|
customModels,
|
||||||
|
defaultModel,
|
||||||
|
allowedWebDavEndpoints,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user