mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-23 10:23:41 +08:00
Compare commits
792 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55cacfb7e2 | ||
|
|
6a862372f7 | ||
|
|
81bd83eb44 | ||
|
|
b2b6fd81be | ||
|
|
f22cfd7b33 | ||
|
|
8111acff34 | ||
|
|
4cad55379d | ||
|
|
a3d3ce3f4c | ||
|
|
611e97e641 | ||
|
|
bfeea4ed49 | ||
|
|
bc71ae247b | ||
|
|
0112b54bc7 | ||
|
|
65810d918b | ||
|
|
4d535b1cd0 | ||
|
|
588d81e8f1 | ||
|
|
d4f499ee41 | ||
|
|
4d63d73b2e | ||
|
|
07c63497dc | ||
|
|
e440ff56c8 | ||
|
|
c89e4883b2 | ||
|
|
ac3d940de8 | ||
|
|
be59de56f0 | ||
|
|
a70e9a3c01 | ||
|
|
8aa9a500fd | ||
|
|
93652db688 | ||
|
|
8421c483e8 | ||
|
|
4ac27fdd4d | ||
|
|
b6b2c501fd | ||
|
|
ce13cf61a7 | ||
|
|
a3af563e89 | ||
|
|
e95c94d7be | ||
|
|
125a71fead | ||
|
|
b410ec399c | ||
|
|
7d51bfd42e | ||
|
|
0c14ce6417 | ||
|
|
f2a2b40d2c | ||
|
|
77be190d76 | ||
|
|
c56587c438 | ||
|
|
840c151ab9 | ||
|
|
0af04e0f2f | ||
|
|
d184eb6458 | ||
|
|
c5d9b1131e | ||
|
|
e13408dd24 | ||
|
|
aba4baf384 | ||
|
|
6d84f9d3ae | ||
|
|
63c5baaa80 | ||
|
|
defefba925 | ||
|
|
90c531c224 | ||
|
|
266e9efd2e | ||
|
|
57c88c0717 | ||
|
|
5b5dea1c59 | ||
|
|
d56566cd73 | ||
|
|
b5d104c908 | ||
|
|
f9e9129d52 | ||
|
|
2a8a18391e | ||
|
|
e1cb8e36fa | ||
|
|
b948d6bf86 | ||
|
|
fe67f79050 | ||
|
|
67338ff9b7 | ||
|
|
7380c8a2c1 | ||
|
|
e1ba8f1b0f | ||
|
|
c0062ff280 | ||
|
|
39e593da48 | ||
|
|
f8b10ad8b1 | ||
|
|
8a22c9d6db | ||
|
|
5f96804f3b | ||
|
|
13430ea3e2 | ||
|
|
664879b9df | ||
|
|
9df24e568b | ||
|
|
bc322be448 | ||
|
|
a867adaf04 | ||
|
|
0cb186846a | ||
|
|
e467ce028d | ||
|
|
cdfe907fb5 | ||
|
|
d91af7f983 | ||
|
|
c3108ad333 | ||
|
|
081daf937e | ||
|
|
0c3d4462ca | ||
|
|
3c859fc29f | ||
|
|
e1c7c54dfa | ||
|
|
87b5e3bf62 | ||
|
|
1d15666713 | ||
|
|
a127ae1fb4 | ||
|
|
ea1329f73e | ||
|
|
149d732cb7 | ||
|
|
210b29bfbe | ||
|
|
acc2e97aab | ||
|
|
93ac0e5017 | ||
|
|
ed8c3580c8 | ||
|
|
0a056a7c5c | ||
|
|
74c4711cdd | ||
|
|
eceec092cf | ||
|
|
42743410a8 | ||
|
|
0f04756d4c | ||
|
|
acdded8161 | ||
|
|
e939ce5a02 | ||
|
|
46a0b100f7 | ||
|
|
e27e8fb0e1 | ||
|
|
93c5320bf2 | ||
|
|
a433d1606c | ||
|
|
cc5e16b045 | ||
|
|
54f6feb2d7 | ||
|
|
e1ac0538b8 | ||
|
|
1a678cb4d8 | ||
|
|
83cea3a90d | ||
|
|
759a09a76c | ||
|
|
2623a92763 | ||
|
|
3932c594c7 | ||
|
|
b7acb89096 | ||
|
|
ef24d3e633 | ||
|
|
23350c842b | ||
|
|
a2adfbbd32 | ||
|
|
f22cec1eb4 | ||
|
|
e56216549e | ||
|
|
19facc7c85 | ||
|
|
b08ce5630c | ||
|
|
b41c012d27 | ||
|
|
a392daab71 | ||
|
|
0628ddfc6f | ||
|
|
7eda14f138 | ||
|
|
9a86c42c95 | ||
|
|
819d249a09 | ||
|
|
8d66fedb1f | ||
|
|
7cf89b53ce | ||
|
|
459c373f13 | ||
|
|
1d14a991ee | ||
|
|
05ef5adfa7 | ||
|
|
38fa3056df | ||
|
|
289aeec8af | ||
|
|
7d71da938f | ||
|
|
f8f6954115 | ||
|
|
6e03f32871 | ||
|
|
18a6571883 | ||
|
|
14f444e1f0 | ||
|
|
2b0f2e5f9d | ||
|
|
4629b39c29 | ||
|
|
d33e772fa5 | ||
|
|
89136fba32 | ||
|
|
8b4ca133fd | ||
|
|
a4c9eaf6cd | ||
|
|
50e63109a3 | ||
|
|
48a1e8a584 | ||
|
|
e44ebe3f0e | ||
|
|
108069a0c6 | ||
|
|
d5bda2904d | ||
|
|
283caba8ce | ||
|
|
b78e5db817 | ||
|
|
46c469b2d7 | ||
|
|
c00ebbea4f | ||
|
|
c526ff80b5 | ||
|
|
0037b0c944 | ||
|
|
6f81bb3b8a | ||
|
|
7bdc45ed3e | ||
|
|
88cd3ac122 | ||
|
|
4988d2ee26 | ||
|
|
8deb7a92ee | ||
|
|
db060d732a | ||
|
|
522627820a | ||
|
|
cf46d5ad63 | ||
|
|
a4941521d0 | ||
|
|
f6e1f8398b | ||
|
|
d544eead38 | ||
|
|
fbb9385f23 | ||
|
|
18144c3d9c | ||
|
|
64aa760e58 | ||
|
|
e0bbb8bb68 | ||
|
|
6667ee1c7f | ||
|
|
6ded4e96e7 | ||
|
|
85cdcab850 | ||
|
|
f4c9410c29 | ||
|
|
adf7d8200b | ||
|
|
3086a2fa77 | ||
|
|
f526d6f560 | ||
|
|
106461a1e7 | ||
|
|
c4e19dbc59 | ||
|
|
f3603e59fa | ||
|
|
8e2484fcdf | ||
|
|
00d6cb27f7 | ||
|
|
b844045d23 | ||
|
|
e49fe976d9 | ||
|
|
14f751965f | ||
|
|
0ec423389f | ||
|
|
820ab54e2d | ||
|
|
a6c1eb27a8 | ||
|
|
0dc4071ccc | ||
|
|
4d3949718a | ||
|
|
aef535f1a7 | ||
|
|
686a80e727 | ||
|
|
e49466fa05 | ||
|
|
4b93370814 | ||
|
|
5733e3c588 | ||
|
|
44fc5b5cbf | ||
|
|
2d3f7c922f | ||
|
|
fe8cca3730 | ||
|
|
fbb7a1e853 | ||
|
|
fb2c15567d | ||
|
|
c2c52a1f60 | ||
|
|
106ddc17cd | ||
|
|
17d5209738 | ||
|
|
d66bfc6352 | ||
|
|
4d75b23ed1 | ||
|
|
36bfa2ef7c | ||
|
|
afe12c212e | ||
|
|
adf97c6d8b | ||
|
|
7a8d557ea3 | ||
|
|
d3f0a77830 | ||
|
|
0581e37236 | ||
|
|
44383a8b33 | ||
|
|
7c466c9b9c | ||
|
|
a0fa4d7e72 | ||
|
|
d357b45e84 | ||
|
|
d0bd1bf8fd | ||
|
|
86ffa1e643 | ||
|
|
b0d28eb77e | ||
|
|
736cbdbdd1 | ||
|
|
613d67eada | ||
|
|
89cea18955 | ||
|
|
56bc77d20b | ||
|
|
6d93d37963 | ||
|
|
24df85cf9d | ||
|
|
a4d7a2c6e3 | ||
|
|
49d42bb45d | ||
|
|
4f49626303 | ||
|
|
45db20c1c3 | ||
|
|
82994843f5 | ||
|
|
1110a087a0 | ||
|
|
f0b3e10a6c | ||
|
|
f89872b833 | ||
|
|
90ced92876 | ||
|
|
2c74559010 | ||
|
|
e3ca7e8b44 | ||
|
|
4745706c42 | ||
|
|
801dc412f9 | ||
|
|
c7c2c0211a | ||
|
|
65bb962fc0 | ||
|
|
e791cd441d | ||
|
|
8455fefc8a | ||
|
|
06f897f32f | ||
|
|
deb1e76c41 | ||
|
|
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 | ||
|
|
064e964d75 | ||
|
|
47fb40d572 | ||
|
|
b7892b58f5 | ||
|
|
77f037a3c4 | ||
|
|
248d27680d | ||
|
|
4c84182e7a | ||
|
|
9e18cc260b | ||
|
|
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 | ||
|
|
4c63ee23cd | ||
|
|
c75d9e3de4 | ||
|
|
df222ded12 | ||
|
|
7dc0f81d3f | ||
|
|
23793e834d | ||
|
|
f4f3c6ad5a | ||
|
|
775794e0e4 | ||
|
|
03268ce4d8 | ||
|
|
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 | ||
|
|
26c2598f56 | ||
|
|
ee22fba448 | ||
|
|
49151dabf5 | ||
|
|
eb7c7cdcb6 | ||
|
|
4b84fb328c | ||
|
|
5267ad46da | ||
|
|
94bc880b7f | ||
|
|
bab3e0bc9b | ||
|
|
b3a324b6f5 | ||
|
|
a1117cd4ee | ||
|
|
6ece818d69 | ||
|
|
5df09d5e2a | ||
|
|
33450ce429 | ||
|
|
e2f0206d88 | ||
|
|
3767b2c7f9 | ||
|
|
dd1030139b | ||
|
|
d61cb98ac7 | ||
|
|
a7ceb61e27 | ||
|
|
74b915a790 | ||
|
|
01ea690421 | ||
|
|
17cc9284a0 | ||
|
|
498d0f0b8b | ||
|
|
2b0153807c | ||
|
|
d726c71141 | ||
|
|
a16725ac17 | ||
|
|
54401162bd | ||
|
|
7fde9327a2 | ||
|
|
bbbf59c74a | ||
|
|
34034be0e3 | ||
|
|
d21481173e | ||
|
|
fa6ebadc7b | ||
|
|
a51fb24f36 | ||
|
|
74986803db | ||
|
|
24bf7950d8 | ||
|
|
4d6b981a54 |
@@ -7,6 +7,11 @@ CODE=your-password
|
|||||||
# You can start service behind a proxy. (optional)
|
# You can start service behind a proxy. (optional)
|
||||||
PROXY_URL=http://localhost:7890
|
PROXY_URL=http://localhost:7890
|
||||||
|
|
||||||
|
# Enable MCP functionality (optional)
|
||||||
|
# Default: Empty (disabled)
|
||||||
|
# Set to "true" to enable MCP functionality
|
||||||
|
ENABLE_MCP=
|
||||||
|
|
||||||
# (optional)
|
# (optional)
|
||||||
# Default: Empty
|
# Default: Empty
|
||||||
# Google Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
# Google Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
||||||
@@ -66,4 +71,4 @@ ANTHROPIC_API_VERSION=
|
|||||||
ANTHROPIC_URL=
|
ANTHROPIC_URL=
|
||||||
|
|
||||||
### (optional)
|
### (optional)
|
||||||
WHITE_WEBDEV_ENDPOINTS=
|
WHITE_WEBDAV_ENDPOINTS=
|
||||||
@@ -1 +1,3 @@
|
|||||||
public/serviceWorker.js
|
public/serviceWorker.js
|
||||||
|
app/mcp/mcp_config.json
|
||||||
|
app/mcp/mcp_config.default.json
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
.github/workflows/deploy_preview.yml
vendored
6
.github/workflows/deploy_preview.yml
vendored
@@ -3,9 +3,7 @@ name: VercelPreviewDeployment
|
|||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- review_requested
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }}
|
VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }}
|
||||||
@@ -49,7 +47,7 @@ jobs:
|
|||||||
run: npm install --global vercel@latest
|
run: npm install --global vercel@latest
|
||||||
|
|
||||||
- name: Cache dependencies
|
- name: Cache dependencies
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4
|
||||||
id: cache-npm
|
id: cache-npm
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
|
|||||||
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
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -44,3 +44,8 @@ dev
|
|||||||
|
|
||||||
*.key
|
*.key
|
||||||
*.key.pub
|
*.key.pub
|
||||||
|
|
||||||
|
masks.json
|
||||||
|
|
||||||
|
# mcp config
|
||||||
|
app/mcp/mcp_config.json
|
||||||
|
|||||||
@@ -34,12 +34,16 @@ ENV PROXY_URL=""
|
|||||||
ENV OPENAI_API_KEY=""
|
ENV OPENAI_API_KEY=""
|
||||||
ENV GOOGLE_API_KEY=""
|
ENV GOOGLE_API_KEY=""
|
||||||
ENV CODE=""
|
ENV CODE=""
|
||||||
|
ENV ENABLE_MCP=""
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/.next/server ./.next/server
|
COPY --from=builder /app/.next/server ./.next/server
|
||||||
|
|
||||||
|
RUN mkdir -p /app/app/mcp && chmod 777 /app/app/mcp
|
||||||
|
COPY --from=builder /app/app/mcp/mcp_config.default.json /app/app/mcp/mcp_config.json
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD if [ -n "$PROXY_URL" ]; then \
|
CMD if [ -n "$PROXY_URL" ]; then \
|
||||||
|
|||||||
200
README.md
200
README.md
@@ -1,26 +1,32 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href='#企业版'>
|
<a href='https://nextchat.dev/chat'>
|
||||||
<img src="./docs/images/ent.svg" alt="icon"/>
|
<img src="https://github.com/user-attachments/assets/287c510f-f508-478e-ade3-54d30453dc18" width="1000" alt="icon"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
|
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
|
||||||
|
|
||||||
English / [简体中文](./README_CN.md)
|
English / [简体中文](./README_CN.md)
|
||||||
|
|
||||||
One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support.
|
<a href="https://trendshift.io/repositories/5973" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5973" alt="ChatGPTNextWeb%2FChatGPT-Next-Web | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。
|
|
||||||
|
|
||||||
|
|
||||||
|
One-Click to get a well-designed cross-platform ChatGPT web UI, with Claude, GPT4 & Gemini Pro support.
|
||||||
|
|
||||||
|
[![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://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)
|
[NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [Web App Demo](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://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)
|
|
||||||
|
|
||||||
|
[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/
|
[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
|
||||||
@@ -28,10 +34,18 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
|
|||||||
[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
|
||||||
|
|
||||||
[<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)
|
[<img src="https://vercel.com/button" alt="Deploy on Vercel" 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) [<img src="https://img.shields.io/badge/BT_Deploy-Install-20a53a" alt="BT Deply Install" height="30">](https://www.bt.cn/new/download.html)
|
||||||
|
|
||||||
|
[<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## 🫣 NextChat Support MCP !
|
||||||
|
> Before build, please set env ENABLE_MCP=true
|
||||||
|
|
||||||
|
<img src="https://github.com/user-attachments/assets/d8851f40-4e36-4335-b1a4-ec1e11488c7e"/>
|
||||||
|
|
||||||
|
|
||||||
## Enterprise Edition
|
## Enterprise Edition
|
||||||
|
|
||||||
Meeting Your Company's Privatization and Customization Deployment Requirements:
|
Meeting Your Company's Privatization and Customization Deployment Requirements:
|
||||||
@@ -45,20 +59,12 @@ Meeting Your Company's Privatization and Customization Deployment Requirements:
|
|||||||
|
|
||||||
For enterprise inquiries, please contact: **business@nextchat.dev**
|
For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||||
|
|
||||||
## 企业版
|
## Screenshots
|
||||||
|
|
||||||
满足企业用户私有化部署和个性化定制需求:
|

|
||||||
- **品牌定制**:企业量身定制 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">
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -88,52 +94,25 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
|||||||
- [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)
|
||||||
|
- [x] Supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
|
||||||
|
- [ ] local knowledge base
|
||||||
|
|
||||||
## What's New
|
## What's New
|
||||||
|
- 🚀 v2.15.8 Now supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
|
||||||
|
- 🚀 v2.15.4 The Application supports using Tauri fetch LLM API, MORE SECURITY! [#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379)
|
||||||
|
- 🚀 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.10.1 support Google Gemini Pro model.
|
||||||
- 🚀 v2.9.11 you can use azure endpoint now.
|
- 🚀 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.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/).
|
- 🚀 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/).
|
||||||
|
|
||||||
## 主要功能
|
|
||||||
|
|
||||||
- 在 1 分钟内使用 Vercel **免费一键部署**
|
|
||||||
- 提供体积极小(~5MB)的跨平台客户端(Linux/Windows/MacOS), [下载地址](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
|
|
||||||
- 完整的 Markdown 支持:LaTex 公式、Mermaid 流程图、代码高亮等等
|
|
||||||
- 精心设计的 UI,响应式设计,支持深色模式,支持 PWA
|
|
||||||
- 极快的首屏加载速度(~100kb),支持流式响应
|
|
||||||
- 隐私安全,所有数据保存在用户浏览器本地
|
|
||||||
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
|
|
||||||
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
|
||||||
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
|
||||||
- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
|
|
||||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
|
||||||
|
|
||||||
## 开发计划
|
|
||||||
|
|
||||||
- [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
|
||||||
- [x] 允许用户自行编辑内置 Prompt 列表
|
|
||||||
- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993)
|
|
||||||
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
|
||||||
- [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 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
|
|
||||||
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
|
||||||
|
|
||||||
## 最新动态
|
|
||||||
|
|
||||||
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
|
||||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
|
||||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
|
||||||
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
|
||||||
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
> [简体中文 > 如何开始使用](./README_CN.md#开始使用)
|
|
||||||
|
|
||||||
1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||||
2. Click
|
2. Click
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web), remember that `CODE` is your page password;
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web), remember that `CODE` is your page password;
|
||||||
@@ -141,14 +120,10 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
|||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
[简体中文 > 常见问题](./docs/faq-cn.md)
|
|
||||||
|
|
||||||
[English > FAQ](./docs/faq-en.md)
|
[English > FAQ](./docs/faq-en.md)
|
||||||
|
|
||||||
## Keep Updated
|
## Keep Updated
|
||||||
|
|
||||||
> [简体中文 > 如何保持代码更新](./README_CN.md#保持更新)
|
|
||||||
|
|
||||||
If you have deployed your own project with just one click following the steps above, you may encounter the issue of "Updates Available" constantly showing up. This is because Vercel will create a new project for you by default instead of forking this project, resulting in the inability to detect updates correctly.
|
If you have deployed your own project with just one click following the steps above, you may encounter the issue of "Updates Available" constantly showing up. This is because Vercel will create a new project for you by default instead of forking this project, resulting in the inability to detect updates correctly.
|
||||||
|
|
||||||
We recommend that you follow the steps below to re-deploy:
|
We recommend that you follow the steps below to re-deploy:
|
||||||
@@ -159,7 +134,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:
|
||||||
|
|
||||||
@@ -175,8 +150,6 @@ You can star or watch this project or follow author to get release notifications
|
|||||||
|
|
||||||
## Access Password
|
## Access Password
|
||||||
|
|
||||||
> [简体中文 > 如何增加访问密码](./README_CN.md#配置页面访问密码)
|
|
||||||
|
|
||||||
This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this:
|
This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -187,8 +160,6 @@ After adding or modifying this environment variable, please redeploy the project
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量)
|
|
||||||
|
|
||||||
### `CODE` (optional)
|
### `CODE` (optional)
|
||||||
|
|
||||||
Access password, separated by comma.
|
Access password, separated by comma.
|
||||||
@@ -271,6 +242,34 @@ Alibaba Cloud Api Key.
|
|||||||
|
|
||||||
Alibaba Cloud Api Url.
|
Alibaba Cloud Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_URL` (Optional)
|
||||||
|
|
||||||
|
iflytek Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_KEY` (Optional)
|
||||||
|
|
||||||
|
iflytek Api Key.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_SECRET` (Optional)
|
||||||
|
|
||||||
|
iflytek Api Secret.
|
||||||
|
|
||||||
|
### `CHATGLM_API_KEY` (optional)
|
||||||
|
|
||||||
|
ChatGLM Api Key.
|
||||||
|
|
||||||
|
### `CHATGLM_URL` (optional)
|
||||||
|
|
||||||
|
ChatGLM Api Url.
|
||||||
|
|
||||||
|
### `DEEPSEEK_API_KEY` (optional)
|
||||||
|
|
||||||
|
DeepSeek Api Key.
|
||||||
|
|
||||||
|
### `DEEPSEEK_URL` (optional)
|
||||||
|
|
||||||
|
DeepSeek Api Url.
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (optional)
|
### `HIDE_USER_API_KEY` (optional)
|
||||||
|
|
||||||
> Default: Empty
|
> Default: Empty
|
||||||
@@ -304,9 +303,9 @@ To control custom models, use `+` to add a custom model, use `-` to hide a model
|
|||||||
|
|
||||||
User `-all` to disable all default models, `+all` to enable all default models.
|
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.
|
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.
|
> 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.
|
> 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.
|
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.
|
> Example: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` will show option `Doubao-lite-4k(ByteDance)` in model list.
|
||||||
@@ -315,7 +314,14 @@ For ByteDance: use `modelName@bytedance=deploymentName` to customize model name
|
|||||||
|
|
||||||
Change default model
|
Change default model
|
||||||
|
|
||||||
### `WHITE_WEBDEV_ENDPOINTS` (optional)
|
### `VISION_MODELS` (optional)
|
||||||
|
|
||||||
|
> Default: Empty
|
||||||
|
> Example: `gpt-4-vision,claude-3-opus,my-custom-model` means add vision capabilities to these models in addition to the default pattern matches (which detect models containing keywords like "vision", "claude-3", "gemini-1.5", etc).
|
||||||
|
|
||||||
|
Add additional models to have vision capabilities, beyond the default pattern matching. Multiple models should be separated by commas.
|
||||||
|
|
||||||
|
### `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:
|
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
|
- Each address must be a complete endpoint
|
||||||
@@ -326,13 +332,26 @@ You can use this option if you want to increase the number of webdav service add
|
|||||||
|
|
||||||
Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
### `ENABLE_MCP` (optional)
|
||||||
|
|
||||||
|
Enable MCP(Model Context Protocol)Feature
|
||||||
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
NodeJS >= 18, Docker >= 20
|
NodeJS >= 18, Docker >= 20
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
> [简体中文 > 如何进行二次开发](./README_CN.md#开发)
|
|
||||||
|
|
||||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||
@@ -357,7 +376,6 @@ yarn dev
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
> [简体中文 > 如何部署到私人服务器](./README_CN.md#部署)
|
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (Recommended)
|
||||||
|
|
||||||
@@ -386,6 +404,16 @@ If your proxy needs password, use:
|
|||||||
-e PROXY_URL="http://127.0.0.1:7890 user pass"
|
-e PROXY_URL="http://127.0.0.1:7890 user pass"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If enable MCP, use:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e OPENAI_API_KEY=sk-xxxx \
|
||||||
|
-e CODE=your-password \
|
||||||
|
-e ENABLE_MCP=true \
|
||||||
|
yidadaa/chatgpt-next-web
|
||||||
|
```
|
||||||
|
|
||||||
### Shell
|
### Shell
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -406,11 +434,7 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s
|
|||||||
- [How to use Vercel (No English)](./docs/vercel-cn.md)
|
- [How to use Vercel (No English)](./docs/vercel-cn.md)
|
||||||
- [User Manual (Only Chinese, WIP)](./docs/user-manual-cn.md)
|
- [User Manual (Only Chinese, WIP)](./docs/user-manual-cn.md)
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||

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

|
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
@@ -422,37 +446,7 @@ If you want to add a new translation, read this [document](./docs/translation.md
|
|||||||
|
|
||||||
## Special Thanks
|
## Special Thanks
|
||||||
|
|
||||||
### Sponsor
|
|
||||||
|
|
||||||
> 仅列出捐赠金额 >= 100RMB 的用户。
|
|
||||||
|
|
||||||
[@mushan0x0](https://github.com/mushan0x0)
|
|
||||||
[@ClarenceDan](https://github.com/ClarenceDan)
|
|
||||||
[@zhangjia](https://github.com/zhangjia)
|
|
||||||
[@hoochanlon](https://github.com/hoochanlon)
|
|
||||||
[@relativequantum](https://github.com/relativequantum)
|
|
||||||
[@desenmeng](https://github.com/desenmeng)
|
|
||||||
[@webees](https://github.com/webees)
|
|
||||||
[@chazzhou](https://github.com/chazzhou)
|
|
||||||
[@hauy](https://github.com/hauy)
|
|
||||||
[@Corwin006](https://github.com/Corwin006)
|
|
||||||
[@yankunsong](https://github.com/yankunsong)
|
|
||||||
[@ypwhs](https://github.com/ypwhs)
|
|
||||||
[@fxxxchao](https://github.com/fxxxchao)
|
|
||||||
[@hotic](https://github.com/hotic)
|
|
||||||
[@WingCH](https://github.com/WingCH)
|
|
||||||
[@jtung4](https://github.com/jtung4)
|
|
||||||
[@micozhu](https://github.com/micozhu)
|
|
||||||
[@jhansion](https://github.com/jhansion)
|
|
||||||
[@Sha1rholder](https://github.com/Sha1rholder)
|
|
||||||
[@AnsonHyq](https://github.com/AnsonHyq)
|
|
||||||
[@synwith](https://github.com/synwith)
|
|
||||||
[@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)
|
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
|
|
||||||
|
|||||||
79
README_CN.md
79
README_CN.md
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
<h1 align="center">NextChat</h1>
|
<h1 align="center">NextChat</h1>
|
||||||
|
|
||||||
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
|
一键免费部署你的私人 ChatGPT 网页应用,支持 Claude, GPT4 & Gemini Pro 模型。
|
||||||
|
|
||||||
[企业版](#%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)
|
[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)
|
||||||
|
|
||||||
[<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)
|
[<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)
|
||||||
|
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
|
|
||||||
企业版咨询: **business@nextchat.dev**
|
企业版咨询: **business@nextchat.dev**
|
||||||
|
|
||||||
<img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601">
|
<img width="300" src="https://github.com/user-attachments/assets/bb29a11d-ff75-48a8-b1f8-d2d7238cf987">
|
||||||
|
|
||||||
|
|
||||||
## 开始使用
|
## 开始使用
|
||||||
|
|
||||||
@@ -54,7 +55,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,启用之后即可开启每小时定时自动更新:
|
||||||
|
|
||||||
@@ -172,6 +173,35 @@ ByteDance Api Url.
|
|||||||
|
|
||||||
阿里云(千问)Api Url.
|
阿里云(千问)Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_URL` (可选)
|
||||||
|
|
||||||
|
讯飞星火Api Url.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_KEY` (可选)
|
||||||
|
|
||||||
|
讯飞星火Api Key.
|
||||||
|
|
||||||
|
### `IFLYTEK_API_SECRET` (可选)
|
||||||
|
|
||||||
|
讯飞星火Api Secret.
|
||||||
|
|
||||||
|
### `CHATGLM_API_KEY` (可选)
|
||||||
|
|
||||||
|
ChatGLM Api Key.
|
||||||
|
|
||||||
|
### `CHATGLM_URL` (可选)
|
||||||
|
|
||||||
|
ChatGLM Api Url.
|
||||||
|
|
||||||
|
### `DEEPSEEK_API_KEY` (可选)
|
||||||
|
|
||||||
|
DeepSeek Api Key.
|
||||||
|
|
||||||
|
### `DEEPSEEK_URL` (可选)
|
||||||
|
|
||||||
|
DeepSeek Api Url.
|
||||||
|
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (可选)
|
### `HIDE_USER_API_KEY` (可选)
|
||||||
|
|
||||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||||
@@ -188,7 +218,7 @@ ByteDance Api Url.
|
|||||||
|
|
||||||
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
|
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
### `WHITE_WEBDEV_ENDPOINTS` (可选)
|
### `WHITE_WEBDAV_ENDPOINTS` (可选)
|
||||||
|
|
||||||
如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求:
|
如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求:
|
||||||
- 每一个地址必须是一个完整的 endpoint
|
- 每一个地址必须是一个完整的 endpoint
|
||||||
@@ -202,9 +232,9 @@ ByteDance Api Url.
|
|||||||
|
|
||||||
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
||||||
|
|
||||||
在Azure的模式下,支持使用`modelName@azure=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
在Azure的模式下,支持使用`modelName@Azure=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
||||||
> 示例:`+gpt-3.5-turbo@azure=gpt35`这个配置会在模型列表显示一个`gpt35(Azure)`的选项。
|
> 示例:`+gpt-3.5-turbo@Azure=gpt35`这个配置会在模型列表显示一个`gpt35(Azure)`的选项。
|
||||||
> 如果你只能使用Azure模式,那么设置 `-all,+gpt-3.5-turbo@azure=gpt35` 则可以让对话的默认使用 `gpt35(Azure)`
|
> 如果你只能使用Azure模式,那么设置 `-all,+gpt-3.5-turbo@Azure=gpt35` 则可以让对话的默认使用 `gpt35(Azure)`
|
||||||
|
|
||||||
在ByteDance的模式下,支持使用`modelName@bytedance=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
在ByteDance的模式下,支持使用`modelName@bytedance=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
||||||
> 示例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx`这个配置会在模型列表显示一个`Doubao-lite-4k(ByteDance)`的选项
|
> 示例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx`这个配置会在模型列表显示一个`Doubao-lite-4k(ByteDance)`的选项
|
||||||
@@ -214,10 +244,30 @@ ByteDance Api Url.
|
|||||||
|
|
||||||
更改默认模型
|
更改默认模型
|
||||||
|
|
||||||
|
### `VISION_MODELS` (可选)
|
||||||
|
|
||||||
|
> 默认值:空
|
||||||
|
> 示例:`gpt-4-vision,claude-3-opus,my-custom-model` 表示为这些模型添加视觉能力,作为对默认模式匹配的补充(默认会检测包含"vision"、"claude-3"、"gemini-1.5"等关键词的模型)。
|
||||||
|
|
||||||
|
在默认模式匹配之外,添加更多具有视觉能力的模型。多个模型用逗号分隔。
|
||||||
|
|
||||||
### `DEFAULT_INPUT_TEMPLATE` (可选)
|
### `DEFAULT_INPUT_TEMPLATE` (可选)
|
||||||
|
|
||||||
自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
|
自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
|
||||||
|
|
||||||
|
### `STABILITY_API_KEY` (optional)
|
||||||
|
|
||||||
|
Stability API密钥
|
||||||
|
|
||||||
|
### `STABILITY_URL` (optional)
|
||||||
|
|
||||||
|
自定义的Stability API请求地址
|
||||||
|
|
||||||
|
### `ENABLE_MCP` (optional)
|
||||||
|
|
||||||
|
启用MCP(Model Context Protocol)功能
|
||||||
|
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
点击下方按钮,开始二次开发:
|
点击下方按钮,开始二次开发:
|
||||||
@@ -241,6 +291,9 @@ BASE_URL=https://b.nextweb.fun/api/proxy
|
|||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
|
### 宝塔面板部署
|
||||||
|
> [简体中文 > 如何通过宝塔一键部署](./docs/bt-cn.md)
|
||||||
|
|
||||||
### 容器部署 (推荐)
|
### 容器部署 (推荐)
|
||||||
|
|
||||||
> Docker 版本需要在 20 及其以上,否则会提示找不到镜像。
|
> Docker 版本需要在 20 及其以上,否则会提示找不到镜像。
|
||||||
@@ -267,6 +320,16 @@ docker run -d -p 3000:3000 \
|
|||||||
yidadaa/chatgpt-next-web
|
yidadaa/chatgpt-next-web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如需启用 MCP 功能,可以使用:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e OPENAI_API_KEY=sk-xxxx \
|
||||||
|
-e CODE=页面访问密码 \
|
||||||
|
-e ENABLE_MCP=true \
|
||||||
|
yidadaa/chatgpt-next-web
|
||||||
|
```
|
||||||
|
|
||||||
如果你的本地代理需要账号密码,可以使用:
|
如果你的本地代理需要账号密码,可以使用:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
|
|||||||
17
README_JA.md
17
README_JA.md
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。
|
ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。
|
||||||
|
|
||||||
[企業版](#企業版) / [デモ](https://chat-gpt-next-web.vercel.app/) / [フィードバック](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discordに参加](https://discord.gg/zrhvHCr79N)
|
[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)
|
[<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)
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
### 自動更新を開く
|
### 自動更新を開く
|
||||||
|
|
||||||
> Upstream Sync の実行エラーが発生した場合は、手動で Sync Fork してください!
|
> Upstream Sync の実行エラーが発生した場合は、[手動で Sync Fork](./README_JA.md#手動でコードを更新する) してください!
|
||||||
|
|
||||||
プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります:
|
プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります:
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ ByteDance API の URL。
|
|||||||
|
|
||||||
リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。
|
リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。
|
||||||
|
|
||||||
### `WHITE_WEBDEV_ENDPOINTS` (オプション)
|
### `WHITE_WEBDAV_ENDPOINTS` (オプション)
|
||||||
|
|
||||||
アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件:
|
アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件:
|
||||||
- 各アドレスは完全なエンドポイントでなければなりません。
|
- 各アドレスは完全なエンドポイントでなければなりません。
|
||||||
@@ -207,8 +207,8 @@ ByteDance API の URL。
|
|||||||
|
|
||||||
モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。
|
モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。
|
||||||
|
|
||||||
Azure モードでは、`modelName@azure=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
Azure モードでは、`modelName@Azure=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
||||||
> 例:`+gpt-3.5-turbo@azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。
|
> 例:`+gpt-3.5-turbo@Azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。
|
||||||
|
|
||||||
ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
||||||
> 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。
|
> 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。
|
||||||
@@ -217,6 +217,13 @@ ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデ
|
|||||||
|
|
||||||
デフォルトのモデルを変更します。
|
デフォルトのモデルを変更します。
|
||||||
|
|
||||||
|
### `VISION_MODELS` (オプション)
|
||||||
|
|
||||||
|
> デフォルト:空
|
||||||
|
> 例:`gpt-4-vision,claude-3-opus,my-custom-model` は、これらのモデルにビジョン機能を追加します。これはデフォルトのパターンマッチング("vision"、"claude-3"、"gemini-1.5"などのキーワードを含むモデルを検出)に加えて適用されます。
|
||||||
|
|
||||||
|
デフォルトのパターンマッチングに加えて、追加のモデルにビジョン機能を付与します。複数のモデルはカンマで区切ります。
|
||||||
|
|
||||||
### `DEFAULT_INPUT_TEMPLATE` (オプション)
|
### `DEFAULT_INPUT_TEMPLATE` (オプション)
|
||||||
|
|
||||||
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。
|
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。
|
||||||
|
|||||||
79
app/api/[provider]/[...path]/route.ts
Normal file
79
app/api/[provider]/[...path]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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 deepseekHandler } from "../../deepseek";
|
||||||
|
import { handle as xaiHandler } from "../../xai";
|
||||||
|
import { handle as chatglmHandler } from "../../glm";
|
||||||
|
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.DeepSeek:
|
||||||
|
return deepseekHandler(req, { params });
|
||||||
|
case ApiPath.XAI:
|
||||||
|
return xaiHandler(req, { params });
|
||||||
|
case ApiPath.ChatGLM:
|
||||||
|
return chatglmHandler(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",
|
||||||
|
];
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
import {
|
import {
|
||||||
Alibaba,
|
|
||||||
ALIBABA_BASE_URL,
|
ALIBABA_BASE_URL,
|
||||||
ApiPath,
|
ApiPath,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
@@ -9,12 +8,11 @@ import {
|
|||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/api/auth";
|
import { auth } from "@/app/api/auth";
|
||||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
import { isModelNotavailableInServer } from "@/app/utils/model";
|
||||||
import type { RequestPayload } from "@/app/client/platforms/openai";
|
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
async function handle(
|
export async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -40,30 +38,6 @@ async function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function request(req: NextRequest) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -115,7 +89,7 @@ async function request(req: NextRequest) {
|
|||||||
|
|
||||||
// not undefined and is false
|
// not undefined and is false
|
||||||
if (
|
if (
|
||||||
isModelAvailableInServer(
|
isModelNotavailableInServer(
|
||||||
serverConfig.customModels,
|
serverConfig.customModels,
|
||||||
jsonBody?.model as string,
|
jsonBody?.model as string,
|
||||||
ServiceProvider.Alibaba as string,
|
ServiceProvider.Alibaba as string,
|
||||||
@@ -3,19 +3,18 @@ import {
|
|||||||
ANTHROPIC_BASE_URL,
|
ANTHROPIC_BASE_URL,
|
||||||
Anthropic,
|
Anthropic,
|
||||||
ApiPath,
|
ApiPath,
|
||||||
DEFAULT_MODELS,
|
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
} from "@/app/constant";
|
} from "@/app/constant";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "../../auth";
|
import { auth } from "./auth";
|
||||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
import { isModelNotavailableInServer } from "@/app/utils/model";
|
||||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||||
|
|
||||||
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
||||||
|
|
||||||
async function handle(
|
export async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -56,30 +55,6 @@ async function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handle;
|
|
||||||
export const POST = handle;
|
|
||||||
|
|
||||||
export const runtime = "edge";
|
|
||||||
export const preferredRegion = [
|
|
||||||
"arn1",
|
|
||||||
"bom1",
|
|
||||||
"cdg1",
|
|
||||||
"cle1",
|
|
||||||
"cpt1",
|
|
||||||
"dub1",
|
|
||||||
"fra1",
|
|
||||||
"gru1",
|
|
||||||
"hnd1",
|
|
||||||
"iad1",
|
|
||||||
"icn1",
|
|
||||||
"kix1",
|
|
||||||
"lhr1",
|
|
||||||
"pdx1",
|
|
||||||
"sfo1",
|
|
||||||
"sin1",
|
|
||||||
"syd1",
|
|
||||||
];
|
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
async function request(req: NextRequest) {
|
async function request(req: NextRequest) {
|
||||||
@@ -122,6 +97,7 @@ async function request(req: NextRequest) {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
|
"anthropic-dangerous-direct-browser-access": "true",
|
||||||
[authHeaderName]: authValue,
|
[authHeaderName]: authValue,
|
||||||
"anthropic-version":
|
"anthropic-version":
|
||||||
req.headers.get("anthropic-version") ||
|
req.headers.get("anthropic-version") ||
|
||||||
@@ -146,7 +122,7 @@ async function request(req: NextRequest) {
|
|||||||
|
|
||||||
// not undefined and is false
|
// not undefined and is false
|
||||||
if (
|
if (
|
||||||
isModelAvailableInServer(
|
isModelNotavailableInServer(
|
||||||
serverConfig.customModels,
|
serverConfig.customModels,
|
||||||
jsonBody?.model as string,
|
jsonBody?.model as string,
|
||||||
ServiceProvider.Anthropic as string,
|
ServiceProvider.Anthropic as string,
|
||||||
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";
|
||||||
@@ -67,6 +67,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
|||||||
let systemApiKey: string | undefined;
|
let systemApiKey: string | undefined;
|
||||||
|
|
||||||
switch (modelProvider) {
|
switch (modelProvider) {
|
||||||
|
case ModelProvider.Stability:
|
||||||
|
systemApiKey = serverConfig.stabilityApiKey;
|
||||||
|
break;
|
||||||
case ModelProvider.GeminiPro:
|
case ModelProvider.GeminiPro:
|
||||||
systemApiKey = serverConfig.googleApiKey;
|
systemApiKey = serverConfig.googleApiKey;
|
||||||
break;
|
break;
|
||||||
@@ -82,6 +85,22 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
|||||||
case ModelProvider.Qwen:
|
case ModelProvider.Qwen:
|
||||||
systemApiKey = serverConfig.alibabaApiKey;
|
systemApiKey = serverConfig.alibabaApiKey;
|
||||||
break;
|
break;
|
||||||
|
case ModelProvider.Moonshot:
|
||||||
|
systemApiKey = serverConfig.moonshotApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.Iflytek:
|
||||||
|
systemApiKey =
|
||||||
|
serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
|
||||||
|
break;
|
||||||
|
case ModelProvider.DeepSeek:
|
||||||
|
systemApiKey = serverConfig.deepseekApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.XAI:
|
||||||
|
systemApiKey = serverConfig.xaiApiKey;
|
||||||
|
break;
|
||||||
|
case ModelProvider.ChatGLM:
|
||||||
|
systemApiKey = serverConfig.chatglmApiKey;
|
||||||
|
break;
|
||||||
case ModelProvider.GPT:
|
case ModelProvider.GPT:
|
||||||
default:
|
default:
|
||||||
if (req.nextUrl.pathname.includes("azure/deployments")) {
|
if (req.nextUrl.pathname.includes("azure/deployments")) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
import { ModelProvider } from "@/app/constant";
|
import { ModelProvider } from "@/app/constant";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "../../auth";
|
import { auth } from "./auth";
|
||||||
import { requestOpenai } from "../../common";
|
import { requestOpenai } from "./common";
|
||||||
|
|
||||||
async function handle(
|
export async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -31,27 +30,3 @@ async function handle(
|
|||||||
return NextResponse.json(prettyObject(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",
|
|
||||||
];
|
|
||||||
@@ -3,18 +3,17 @@ import {
|
|||||||
BAIDU_BASE_URL,
|
BAIDU_BASE_URL,
|
||||||
ApiPath,
|
ApiPath,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
BAIDU_OATUH_URL,
|
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
} from "@/app/constant";
|
} from "@/app/constant";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/api/auth";
|
import { auth } from "@/app/api/auth";
|
||||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
import { isModelNotavailableInServer } from "@/app/utils/model";
|
||||||
import { getAccessToken } from "@/app/utils/baidu";
|
import { getAccessToken } from "@/app/utils/baidu";
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
async function handle(
|
export async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -52,30 +51,6 @@ async function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function request(req: NextRequest) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -129,7 +104,7 @@ async function request(req: NextRequest) {
|
|||||||
|
|
||||||
// not undefined and is false
|
// not undefined and is false
|
||||||
if (
|
if (
|
||||||
isModelAvailableInServer(
|
isModelNotavailableInServer(
|
||||||
serverConfig.customModels,
|
serverConfig.customModels,
|
||||||
jsonBody?.model as string,
|
jsonBody?.model as string,
|
||||||
ServiceProvider.Baidu as string,
|
ServiceProvider.Baidu as string,
|
||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/api/auth";
|
import { auth } from "@/app/api/auth";
|
||||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
import { isModelNotavailableInServer } from "@/app/utils/model";
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
async function handle(
|
export async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -38,30 +38,6 @@ async function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function request(req: NextRequest) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -112,7 +88,7 @@ async function request(req: NextRequest) {
|
|||||||
|
|
||||||
// not undefined and is false
|
// not undefined and is false
|
||||||
if (
|
if (
|
||||||
isModelAvailableInServer(
|
isModelNotavailableInServer(
|
||||||
serverConfig.customModels,
|
serverConfig.customModels,
|
||||||
jsonBody?.model as string,
|
jsonBody?.model as string,
|
||||||
ServiceProvider.ByteDance as string,
|
ServiceProvider.ByteDance as string,
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSideConfig } from "../config/server";
|
import { getServerSideConfig } from "../config/server";
|
||||||
import {
|
import { OPENAI_BASE_URL, ServiceProvider } from "../constant";
|
||||||
DEFAULT_MODELS,
|
|
||||||
OPENAI_BASE_URL,
|
|
||||||
GEMINI_BASE_URL,
|
|
||||||
ServiceProvider,
|
|
||||||
} from "../constant";
|
|
||||||
import { isModelAvailableInServer } from "../utils/model";
|
|
||||||
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
|
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
|
||||||
|
import { getModelProvider, isModelNotavailableInServer } from "../utils/model";
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
@@ -32,10 +27,7 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
authHeaderName = "Authorization";
|
authHeaderName = "Authorization";
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
let path = `${req.nextUrl.pathname}`.replaceAll("/api/openai/", "");
|
||||||
"/api/openai/",
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
let baseUrl =
|
let baseUrl =
|
||||||
(isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL;
|
(isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL;
|
||||||
@@ -79,7 +71,7 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
|
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
|
||||||
.forEach((m) => {
|
.forEach((m) => {
|
||||||
const [fullName, displayName] = m.split("=");
|
const [fullName, displayName] = m.split("=");
|
||||||
const [_, providerName] = fullName.split("@");
|
const [_, providerName] = getModelProvider(fullName);
|
||||||
if (providerName === "azure" && !displayName) {
|
if (providerName === "azure" && !displayName) {
|
||||||
const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
|
const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
|
||||||
"deployments/",
|
"deployments/",
|
||||||
@@ -126,15 +118,14 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
|
|
||||||
// not undefined and is false
|
// not undefined and is false
|
||||||
if (
|
if (
|
||||||
isModelAvailableInServer(
|
isModelNotavailableInServer(
|
||||||
serverConfig.customModels,
|
serverConfig.customModels,
|
||||||
jsonBody?.model as string,
|
jsonBody?.model as string,
|
||||||
ServiceProvider.OpenAI as string,
|
[
|
||||||
) ||
|
ServiceProvider.OpenAI,
|
||||||
isModelAvailableInServer(
|
ServiceProvider.Azure,
|
||||||
serverConfig.customModels,
|
jsonBody?.model as string, // support provider-unspecified model
|
||||||
jsonBody?.model as string,
|
],
|
||||||
ServiceProvider.Azure as string,
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const DANGER_CONFIG = {
|
|||||||
disableFastLink: serverConfig.disableFastLink,
|
disableFastLink: serverConfig.disableFastLink,
|
||||||
customModels: serverConfig.customModels,
|
customModels: serverConfig.customModels,
|
||||||
defaultModel: serverConfig.defaultModel,
|
defaultModel: serverConfig.defaultModel,
|
||||||
|
visionModels: serverConfig.visionModels,
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
128
app/api/deepseek.ts
Normal file
128
app/api/deepseek.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
DEEPSEEK_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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[DeepSeek Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.DeepSeek);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[DeepSeek] ", 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.DeepSeek, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.deepseekUrl || DEEPSEEK_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 (
|
||||||
|
isModelNotavailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.DeepSeek as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[DeepSeek] 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/glm.ts
Normal file
129
app/api/glm.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
CHATGLM_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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[GLM Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.ChatGLM);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[GLM] ", 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.ChatGLM, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.chatglmUrl || CHATGLM_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}`;
|
||||||
|
console.log("[Fetch Url] ", fetchUrl);
|
||||||
|
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 (
|
||||||
|
isModelNotavailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.ChatGLM as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[GLM] 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,19 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "../../auth";
|
import { auth } from "./auth";
|
||||||
import { getServerSideConfig } from "@/app/config/server";
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
import {
|
import { ApiPath, GEMINI_BASE_URL, ModelProvider } from "@/app/constant";
|
||||||
ApiPath,
|
|
||||||
GEMINI_BASE_URL,
|
|
||||||
Google,
|
|
||||||
ModelProvider,
|
|
||||||
} from "@/app/constant";
|
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
async function handle(
|
export async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { provider: string; path: string[] } },
|
||||||
) {
|
) {
|
||||||
console.log("[Google Route] params ", params);
|
console.log("[Google Route] params ", params);
|
||||||
|
|
||||||
@@ -28,7 +23,8 @@ async function handle(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const bearToken = req.headers.get("Authorization") ?? "";
|
const bearToken =
|
||||||
|
req.headers.get("x-goog-api-key") || req.headers.get("Authorization") || "";
|
||||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||||
|
|
||||||
const apiKey = token ? token : serverConfig.googleApiKey;
|
const apiKey = token ? token : serverConfig.googleApiKey;
|
||||||
@@ -96,8 +92,8 @@ async function request(req: NextRequest, apiKey: string) {
|
|||||||
},
|
},
|
||||||
10 * 60 * 1000,
|
10 * 60 * 1000,
|
||||||
);
|
);
|
||||||
const fetchUrl = `${baseUrl}${path}?key=${apiKey}${
|
const fetchUrl = `${baseUrl}${path}${
|
||||||
req?.nextUrl?.searchParams?.get("alt") === "sse" ? "&alt=sse" : ""
|
req?.nextUrl?.searchParams?.get("alt") === "sse" ? "?alt=sse" : ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
console.log("[Fetch Url] ", fetchUrl);
|
console.log("[Fetch Url] ", fetchUrl);
|
||||||
@@ -105,6 +101,9 @@ async function request(req: NextRequest, apiKey: string) {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
|
"x-goog-api-key":
|
||||||
|
req.headers.get("x-goog-api-key") ||
|
||||||
|
(req.headers.get("Authorization") ?? "").replace("Bearer ", ""),
|
||||||
},
|
},
|
||||||
method: req.method,
|
method: req.method,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
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 { isModelNotavailableInServer } 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 (
|
||||||
|
isModelNotavailableInServer(
|
||||||
|
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 { isModelNotavailableInServer } 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 (
|
||||||
|
isModelNotavailableInServer(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,26 @@ import { getServerSideConfig } from "@/app/config/server";
|
|||||||
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "../../auth";
|
import { auth } from "./auth";
|
||||||
import { requestOpenai } from "../../common";
|
import { requestOpenai } from "./common";
|
||||||
|
|
||||||
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
const ALLOWED_PATH = new Set(Object.values(OpenaiPath));
|
||||||
|
|
||||||
function getModels(remoteModelRes: OpenAIListModelResponse) {
|
function getModels(remoteModelRes: OpenAIListModelResponse) {
|
||||||
const config = getServerSideConfig();
|
const config = getServerSideConfig();
|
||||||
|
|
||||||
if (config.disableGPT4) {
|
if (config.disableGPT4) {
|
||||||
remoteModelRes.data = remoteModelRes.data.filter(
|
remoteModelRes.data = remoteModelRes.data.filter(
|
||||||
(m) => !m.id.startsWith("gpt-4"),
|
(m) =>
|
||||||
|
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o") || m.id.startsWith("o1")) ||
|
||||||
|
m.id.startsWith("gpt-4o-mini"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return remoteModelRes;
|
return remoteModelRes;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle(
|
export async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -32,7 +34,7 @@ async function handle(
|
|||||||
|
|
||||||
const subpath = params.path.join("/");
|
const subpath = params.path.join("/");
|
||||||
|
|
||||||
if (!ALLOWD_PATH.has(subpath)) {
|
if (!ALLOWED_PATH.has(subpath)) {
|
||||||
console.log("[OpenAI Route] forbidden path ", subpath);
|
console.log("[OpenAI Route] forbidden path ", subpath);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -70,27 +72,3 @@ async function handle(
|
|||||||
return NextResponse.json(prettyObject(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",
|
|
||||||
];
|
|
||||||
89
app/api/proxy.ts
Normal file
89
app/api/proxy.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSideConfig } from "@/app/config/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 });
|
||||||
|
}
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// if dalle3 use openai api key
|
||||||
|
const baseUrl = req.headers.get("x-base-url");
|
||||||
|
if (baseUrl?.includes("api.openai.com")) {
|
||||||
|
if (!serverConfig.apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "OpenAI API key not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headers.set("Authorization", `Bearer ${serverConfig.apiKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ const config = getServerSideConfig();
|
|||||||
|
|
||||||
const mergedAllowedWebDavEndpoints = [
|
const mergedAllowedWebDavEndpoints = [
|
||||||
...internalAllowedWebDavEndpoints,
|
...internalAllowedWebDavEndpoints,
|
||||||
...config.allowedWebDevEndpoints,
|
...config.allowedWebDavEndpoints,
|
||||||
].filter((domain) => Boolean(domain.trim()));
|
].filter((domain) => Boolean(domain.trim()));
|
||||||
|
|
||||||
const normalizeUrl = (url: string) => {
|
const normalizeUrl = (url: string) => {
|
||||||
@@ -29,6 +29,7 @@ async function handle(
|
|||||||
|
|
||||||
const requestUrl = new URL(req.url);
|
const requestUrl = new URL(req.url);
|
||||||
let endpoint = requestUrl.searchParams.get("endpoint");
|
let endpoint = requestUrl.searchParams.get("endpoint");
|
||||||
|
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
|
||||||
|
|
||||||
// Validate the endpoint to prevent potential SSRF attacks
|
// Validate the endpoint to prevent potential SSRF attacks
|
||||||
if (
|
if (
|
||||||
@@ -37,9 +38,13 @@ async function handle(
|
|||||||
const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
|
const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
|
||||||
const normalizedEndpoint = normalizeUrl(endpoint as string);
|
const normalizedEndpoint = normalizeUrl(endpoint as string);
|
||||||
|
|
||||||
return normalizedEndpoint &&
|
return (
|
||||||
|
normalizedEndpoint &&
|
||||||
normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
|
normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
|
||||||
normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname);
|
normalizedEndpoint.pathname.startsWith(
|
||||||
|
normalizedAllowedEndpoint.pathname,
|
||||||
|
)
|
||||||
|
);
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -61,7 +66,11 @@ async function handle(
|
|||||||
const targetPath = `${endpoint}${endpointPath}`;
|
const targetPath = `${endpoint}${endpointPath}`;
|
||||||
|
|
||||||
// only allow MKCOL, GET, PUT
|
// only allow MKCOL, GET, PUT
|
||||||
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
|
if (
|
||||||
|
proxy_method !== "MKCOL" &&
|
||||||
|
proxy_method !== "GET" &&
|
||||||
|
proxy_method !== "PUT"
|
||||||
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -74,7 +83,7 @@ async function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for MKCOL request, only allow request ${folder}
|
// for MKCOL request, only allow request ${folder}
|
||||||
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
|
if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -87,7 +96,7 @@ async function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for GET request, only allow request ending with fileName
|
// for GET request, only allow request ending with fileName
|
||||||
if (req.method === "GET" && !targetPath.endsWith(fileName)) {
|
if (proxy_method === "GET" && !targetPath.endsWith(fileName)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -100,7 +109,7 @@ async function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for PUT request, only allow request ending with fileName
|
// for PUT request, only allow request ending with fileName
|
||||||
if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
|
if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -114,7 +123,7 @@ async function handle(
|
|||||||
|
|
||||||
const targetUrl = targetPath;
|
const targetUrl = targetPath;
|
||||||
|
|
||||||
const method = req.method;
|
const method = proxy_method || req.method;
|
||||||
const shouldNotHaveBody = ["get", "head"].includes(
|
const shouldNotHaveBody = ["get", "head"].includes(
|
||||||
method?.toLowerCase() ?? "",
|
method?.toLowerCase() ?? "",
|
||||||
);
|
);
|
||||||
@@ -139,7 +148,7 @@ async function handle(
|
|||||||
"[Any Proxy]",
|
"[Any Proxy]",
|
||||||
targetUrl,
|
targetUrl,
|
||||||
{
|
{
|
||||||
method: req.method,
|
method: method,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: fetchResult?.status,
|
status: fetchResult?.status,
|
||||||
|
|||||||
128
app/api/xai.ts
Normal file
128
app/api/xai.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
import {
|
||||||
|
XAI_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 { isModelNotavailableInServer } from "@/app/utils/model";
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
export async function handle(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } },
|
||||||
|
) {
|
||||||
|
console.log("[XAI Route] params ", params);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = auth(req, ModelProvider.XAI);
|
||||||
|
if (authResult.error) {
|
||||||
|
return NextResponse.json(authResult, {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(req);
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[XAI] ", 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.XAI, "");
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.xaiUrl || XAI_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 (
|
||||||
|
isModelNotavailableInServer(
|
||||||
|
serverConfig.customModels,
|
||||||
|
jsonBody?.model as string,
|
||||||
|
ServiceProvider.XAI as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[XAI] 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,22 +1,34 @@
|
|||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import {
|
import {
|
||||||
ACCESS_CODE_PREFIX,
|
ACCESS_CODE_PREFIX,
|
||||||
Azure,
|
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
} from "../constant";
|
} from "../constant";
|
||||||
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
|
import {
|
||||||
import { ChatGPTApi } from "./platforms/openai";
|
ChatMessageTool,
|
||||||
|
ChatMessage,
|
||||||
|
ModelType,
|
||||||
|
useAccessStore,
|
||||||
|
useChatStore,
|
||||||
|
} from "../store";
|
||||||
|
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
|
||||||
import { GeminiProApi } from "./platforms/google";
|
import { GeminiProApi } from "./platforms/google";
|
||||||
import { ClaudeApi } from "./platforms/anthropic";
|
import { ClaudeApi } from "./platforms/anthropic";
|
||||||
import { ErnieApi } from "./platforms/baidu";
|
import { ErnieApi } from "./platforms/baidu";
|
||||||
import { DoubaoApi } from "./platforms/bytedance";
|
import { DoubaoApi } from "./platforms/bytedance";
|
||||||
import { QwenApi } from "./platforms/alibaba";
|
import { QwenApi } from "./platforms/alibaba";
|
||||||
|
import { HunyuanApi } from "./platforms/tencent";
|
||||||
|
import { MoonshotApi } from "./platforms/moonshot";
|
||||||
|
import { SparkApi } from "./platforms/iflytek";
|
||||||
|
import { DeepSeekApi } from "./platforms/deepseek";
|
||||||
|
import { XAIApi } from "./platforms/xai";
|
||||||
|
import { ChatGLMApi } from "./platforms/glm";
|
||||||
|
|
||||||
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 {
|
export interface MultimodalContent {
|
||||||
@@ -40,6 +52,18 @@ export interface LLMConfig {
|
|||||||
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 {
|
||||||
@@ -47,9 +71,11 @@ export interface ChatOptions {
|
|||||||
config: LLMConfig;
|
config: LLMConfig;
|
||||||
|
|
||||||
onUpdate?: (message: string, chunk: string) => void;
|
onUpdate?: (message: string, chunk: string) => void;
|
||||||
onFinish: (message: string) => void;
|
onFinish: (message: string, responseRes: Response) => 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 {
|
||||||
@@ -62,16 +88,19 @@ export interface LLMModel {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
provider: LLMModelProvider;
|
provider: LLMModelProvider;
|
||||||
|
sorted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMModelProvider {
|
export interface LLMModelProvider {
|
||||||
id: string;
|
id: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
providerType: 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[]>;
|
abstract models(): Promise<LLMModel[]>;
|
||||||
}
|
}
|
||||||
@@ -117,6 +146,24 @@ export class ClientApi {
|
|||||||
case ModelProvider.Qwen:
|
case ModelProvider.Qwen:
|
||||||
this.llm = new QwenApi();
|
this.llm = new QwenApi();
|
||||||
break;
|
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;
|
||||||
|
case ModelProvider.DeepSeek:
|
||||||
|
this.llm = new DeepSeekApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.XAI:
|
||||||
|
this.llm = new XAIApi();
|
||||||
|
break;
|
||||||
|
case ModelProvider.ChatGLM:
|
||||||
|
this.llm = new ChatGLMApi();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
this.llm = new ChatGPTApi();
|
this.llm = new ChatGPTApi();
|
||||||
}
|
}
|
||||||
@@ -168,24 +215,45 @@ export class ClientApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHeaders() {
|
export function getBearerToken(
|
||||||
|
apiKey: string,
|
||||||
|
noBearer: boolean = false,
|
||||||
|
): string {
|
||||||
|
return validString(apiKey)
|
||||||
|
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validString(x: string): boolean {
|
||||||
|
return x?.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeaders(ignoreHeaders: boolean = false) {
|
||||||
const accessStore = useAccessStore.getState();
|
const accessStore = useAccessStore.getState();
|
||||||
const chatStore = useChatStore.getState();
|
const chatStore = useChatStore.getState();
|
||||||
const headers: Record<string, string> = {
|
let headers: Record<string, string> = {};
|
||||||
"Content-Type": "application/json",
|
if (!ignoreHeaders) {
|
||||||
Accept: "application/json",
|
headers = {
|
||||||
};
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const clientConfig = getClientConfig();
|
const clientConfig = getClientConfig();
|
||||||
|
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
const modelConfig = chatStore.currentSession().mask.modelConfig;
|
const modelConfig = chatStore.currentSession().mask.modelConfig;
|
||||||
const isGoogle = modelConfig.providerName == ServiceProvider.Google;
|
const isGoogle = modelConfig.providerName === ServiceProvider.Google;
|
||||||
const isAzure = modelConfig.providerName === ServiceProvider.Azure;
|
const isAzure = modelConfig.providerName === ServiceProvider.Azure;
|
||||||
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
|
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
|
||||||
const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
|
const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
|
||||||
const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
|
const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
|
||||||
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
|
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
|
||||||
|
const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
|
||||||
|
const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
|
||||||
|
const isDeepSeek = modelConfig.providerName === ServiceProvider.DeepSeek;
|
||||||
|
const isXAI = modelConfig.providerName === ServiceProvider.XAI;
|
||||||
|
const isChatGLM = modelConfig.providerName === ServiceProvider.ChatGLM;
|
||||||
const isEnabledAccessControl = accessStore.enabledAccessControl();
|
const isEnabledAccessControl = accessStore.enabledAccessControl();
|
||||||
const apiKey = isGoogle
|
const apiKey = isGoogle
|
||||||
? accessStore.googleApiKey
|
? accessStore.googleApiKey
|
||||||
@@ -197,6 +265,18 @@ export function getHeaders() {
|
|||||||
? accessStore.bytedanceApiKey
|
? accessStore.bytedanceApiKey
|
||||||
: isAlibaba
|
: isAlibaba
|
||||||
? accessStore.alibabaApiKey
|
? accessStore.alibabaApiKey
|
||||||
|
: isMoonshot
|
||||||
|
? accessStore.moonshotApiKey
|
||||||
|
: isXAI
|
||||||
|
? accessStore.xaiApiKey
|
||||||
|
: isDeepSeek
|
||||||
|
? accessStore.deepseekApiKey
|
||||||
|
: isChatGLM
|
||||||
|
? accessStore.chatglmApiKey
|
||||||
|
: isIflytek
|
||||||
|
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
|
||||||
|
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
|
||||||
|
: ""
|
||||||
: accessStore.openaiApiKey;
|
: accessStore.openaiApiKey;
|
||||||
return {
|
return {
|
||||||
isGoogle,
|
isGoogle,
|
||||||
@@ -205,40 +285,50 @@ export function getHeaders() {
|
|||||||
isBaidu,
|
isBaidu,
|
||||||
isByteDance,
|
isByteDance,
|
||||||
isAlibaba,
|
isAlibaba,
|
||||||
|
isMoonshot,
|
||||||
|
isIflytek,
|
||||||
|
isDeepSeek,
|
||||||
|
isXAI,
|
||||||
|
isChatGLM,
|
||||||
apiKey,
|
apiKey,
|
||||||
isEnabledAccessControl,
|
isEnabledAccessControl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthHeader(): string {
|
function getAuthHeader(): string {
|
||||||
return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization";
|
return isAzure
|
||||||
|
? "api-key"
|
||||||
|
: isAnthropic
|
||||||
|
? "x-api-key"
|
||||||
|
: isGoogle
|
||||||
|
? "x-goog-api-key"
|
||||||
|
: "Authorization";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBearerToken(apiKey: string, noBearer: boolean = false): string {
|
|
||||||
return validString(apiKey)
|
|
||||||
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function validString(x: string): boolean {
|
|
||||||
return x?.length > 0;
|
|
||||||
}
|
|
||||||
const {
|
const {
|
||||||
isGoogle,
|
isGoogle,
|
||||||
isAzure,
|
isAzure,
|
||||||
isAnthropic,
|
isAnthropic,
|
||||||
isBaidu,
|
isBaidu,
|
||||||
|
isByteDance,
|
||||||
|
isAlibaba,
|
||||||
|
isMoonshot,
|
||||||
|
isIflytek,
|
||||||
|
isDeepSeek,
|
||||||
|
isXAI,
|
||||||
|
isChatGLM,
|
||||||
apiKey,
|
apiKey,
|
||||||
isEnabledAccessControl,
|
isEnabledAccessControl,
|
||||||
} = getConfig();
|
} = getConfig();
|
||||||
// when using google api in app, not set auth header
|
|
||||||
if (isGoogle && clientConfig?.isApp) return headers;
|
|
||||||
// when using baidu api in app, not set auth header
|
// when using baidu api in app, not set auth header
|
||||||
if (isBaidu && clientConfig?.isApp) return headers;
|
if (isBaidu && clientConfig?.isApp) return headers;
|
||||||
|
|
||||||
const authHeader = getAuthHeader();
|
const authHeader = getAuthHeader();
|
||||||
|
|
||||||
const bearerToken = getBearerToken(apiKey, isAzure || isAnthropic);
|
const bearerToken = getBearerToken(
|
||||||
|
apiKey,
|
||||||
|
isAzure || isAnthropic || isGoogle,
|
||||||
|
);
|
||||||
|
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
headers[authHeader] = bearerToken;
|
headers[authHeader] = bearerToken;
|
||||||
@@ -263,6 +353,18 @@ export function getClientApi(provider: ServiceProvider): ClientApi {
|
|||||||
return new ClientApi(ModelProvider.Doubao);
|
return new ClientApi(ModelProvider.Doubao);
|
||||||
case ServiceProvider.Alibaba:
|
case ServiceProvider.Alibaba:
|
||||||
return new ClientApi(ModelProvider.Qwen);
|
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);
|
||||||
|
case ServiceProvider.DeepSeek:
|
||||||
|
return new ClientApi(ModelProvider.DeepSeek);
|
||||||
|
case ServiceProvider.XAI:
|
||||||
|
return new ClientApi(ModelProvider.XAI);
|
||||||
|
case ServiceProvider.ChatGLM:
|
||||||
|
return new ClientApi(ModelProvider.ChatGLM);
|
||||||
default:
|
default:
|
||||||
return new ClientApi(ModelProvider.GPT);
|
return new ClientApi(ModelProvider.GPT);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getHeaders,
|
getHeaders,
|
||||||
LLMApi,
|
LLMApi,
|
||||||
LLMModel,
|
LLMModel,
|
||||||
|
SpeechOptions,
|
||||||
MultimodalContent,
|
MultimodalContent,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import Locale from "../../locales";
|
import Locale from "../../locales";
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { getClientConfig } from "@/app/config/client";
|
import { getClientConfig } from "@/app/config/client";
|
||||||
import { getMessageTextContent } from "@/app/utils";
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
export interface OpenAIListModelResponse {
|
export interface OpenAIListModelResponse {
|
||||||
object: string;
|
object: string;
|
||||||
@@ -83,6 +85,10 @@ export class QwenApi implements LLMApi {
|
|||||||
return res?.output?.choices?.at(0)?.message?.content ?? "";
|
return res?.output?.choices?.at(0)?.message?.content ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions) {
|
async chat(options: ChatOptions) {
|
||||||
const messages = options.messages.map((v) => ({
|
const messages = options.messages.map((v) => ({
|
||||||
role: v.role,
|
role: v.role,
|
||||||
@@ -137,6 +143,7 @@ export class QwenApi implements LLMApi {
|
|||||||
let responseText = "";
|
let responseText = "";
|
||||||
let remainText = "";
|
let remainText = "";
|
||||||
let finished = false;
|
let finished = false;
|
||||||
|
let responseRes: Response;
|
||||||
|
|
||||||
// animate response to make it looks smooth
|
// animate response to make it looks smooth
|
||||||
function animateResponseText() {
|
function animateResponseText() {
|
||||||
@@ -166,13 +173,14 @@ export class QwenApi implements LLMApi {
|
|||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
finished = true;
|
finished = true;
|
||||||
options.onFinish(responseText + remainText);
|
options.onFinish(responseText + remainText, responseRes);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.signal.onabort = finish;
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
fetchEventSource(chatPath, {
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
...chatPayload,
|
...chatPayload,
|
||||||
async onopen(res) {
|
async onopen(res) {
|
||||||
clearTimeout(requestTimeoutId);
|
clearTimeout(requestTimeoutId);
|
||||||
@@ -181,6 +189,7 @@ export class QwenApi implements LLMApi {
|
|||||||
"[Alibaba] request response content type: ",
|
"[Alibaba] request response content type: ",
|
||||||
contentType,
|
contentType,
|
||||||
);
|
);
|
||||||
|
responseRes = res;
|
||||||
|
|
||||||
if (contentType?.startsWith("text/plain")) {
|
if (contentType?.startsWith("text/plain")) {
|
||||||
responseText = await res.clone().text();
|
responseText = await res.clone().text();
|
||||||
@@ -247,7 +256,7 @@ export class QwenApi implements LLMApi {
|
|||||||
|
|
||||||
const resJson = await res.json();
|
const resJson = await res.json();
|
||||||
const message = this.extractMessage(resJson);
|
const message = this.extractMessage(resJson);
|
||||||
options.onFinish(message);
|
options.onFinish(message, res);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Request] failed to make a chat request", e);
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
|
import { Anthropic, ApiPath } from "@/app/constant";
|
||||||
import { ChatOptions, getHeaders, LLMApi, MultimodalContent } from "../api";
|
import { ChatOptions, getHeaders, LLMApi, SpeechOptions } from "../api";
|
||||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
|
||||||
import { getClientConfig } from "@/app/config/client";
|
|
||||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
|
||||||
import {
|
import {
|
||||||
EventStreamContentType,
|
useAccessStore,
|
||||||
fetchEventSource,
|
useAppConfig,
|
||||||
} from "@fortaine/fetch-event-source";
|
useChatStore,
|
||||||
|
usePluginStore,
|
||||||
import Locale from "../../locales";
|
ChatMessageTool,
|
||||||
import { prettyObject } from "@/app/utils/format";
|
} from "@/app/store";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { ANTHROPIC_BASE_URL } from "@/app/constant";
|
||||||
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||||
import { preProcessImageContent } from "@/app/utils/chat";
|
import { preProcessImageContent, stream } from "@/app/utils/chat";
|
||||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||||
|
import { RequestPayload } from "./openai";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
export type MultiBlockContent = {
|
export type MultiBlockContent = {
|
||||||
type: "image" | "text";
|
type: "image" | "text";
|
||||||
@@ -73,6 +74,10 @@ const ClaudeMapper = {
|
|||||||
const keys = ["claude-2, claude-instant-1"];
|
const keys = ["claude-2, claude-instant-1"];
|
||||||
|
|
||||||
export class ClaudeApi implements LLMApi {
|
export class ClaudeApi implements LLMApi {
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
extractMessage(res: any) {
|
extractMessage(res: any) {
|
||||||
console.log("[Response] claude response: ", res);
|
console.log("[Response] claude response: ", res);
|
||||||
|
|
||||||
@@ -191,120 +196,135 @@ export class ClaudeApi implements LLMApi {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
options.onController?.(controller);
|
options.onController?.(controller);
|
||||||
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shouldStream) {
|
if (shouldStream) {
|
||||||
try {
|
let index = -1;
|
||||||
const context = {
|
const [tools, funcs] = usePluginStore
|
||||||
text: "",
|
.getState()
|
||||||
finished: false,
|
.getAsTools(
|
||||||
};
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
|
);
|
||||||
const finish = () => {
|
return stream(
|
||||||
if (!context.finished) {
|
path,
|
||||||
options.onFinish(context.text);
|
requestBody,
|
||||||
context.finished = true;
|
{
|
||||||
}
|
...getHeaders(),
|
||||||
};
|
"anthropic-version": accessStore.anthropicApiVersion,
|
||||||
|
},
|
||||||
controller.signal.onabort = finish;
|
// @ts-ignore
|
||||||
fetchEventSource(path, {
|
tools.map((tool) => ({
|
||||||
...payload,
|
name: tool?.function?.name,
|
||||||
async onopen(res) {
|
description: tool?.function?.description,
|
||||||
const contentType = res.headers.get("content-type");
|
input_schema: tool?.function?.parameters,
|
||||||
console.log("response content type: ", contentType);
|
})),
|
||||||
|
funcs,
|
||||||
if (contentType?.startsWith("text/plain")) {
|
controller,
|
||||||
context.text = await res.clone().text();
|
// parseSSE
|
||||||
return finish();
|
(text: string, runTools: ChatMessageTool[]) => {
|
||||||
}
|
// console.log("parseSSE", text, runTools);
|
||||||
|
let chunkJson:
|
||||||
if (
|
| undefined
|
||||||
!res.ok ||
|
| {
|
||||||
!res.headers
|
type: "content_block_delta" | "content_block_stop";
|
||||||
.get("content-type")
|
content_block?: {
|
||||||
?.startsWith(EventStreamContentType) ||
|
type: "tool_use";
|
||||||
res.status !== 200
|
id: string;
|
||||||
) {
|
name: string;
|
||||||
const responseTexts = [context.text];
|
|
||||||
let extraInfo = await res.clone().text();
|
|
||||||
try {
|
|
||||||
const resJson = await res.clone().json();
|
|
||||||
extraInfo = prettyObject(resJson);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
responseTexts.push(Locale.Error.Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extraInfo) {
|
|
||||||
responseTexts.push(extraInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
context.text = responseTexts.join("\n\n");
|
|
||||||
|
|
||||||
return finish();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onmessage(msg) {
|
|
||||||
let chunkJson:
|
|
||||||
| undefined
|
|
||||||
| {
|
|
||||||
type: "content_block_delta" | "content_block_stop";
|
|
||||||
delta?: {
|
|
||||||
type: "text_delta";
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
index: number;
|
|
||||||
};
|
};
|
||||||
try {
|
delta?: {
|
||||||
chunkJson = JSON.parse(msg.data);
|
type: "text_delta" | "input_json_delta";
|
||||||
} catch (e) {
|
text?: string;
|
||||||
console.error("[Response] parse error", msg.data);
|
partial_json?: string;
|
||||||
}
|
};
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
chunkJson = JSON.parse(text);
|
||||||
|
|
||||||
if (!chunkJson || chunkJson.type === "content_block_stop") {
|
if (chunkJson?.content_block?.type == "tool_use") {
|
||||||
return finish();
|
index += 1;
|
||||||
}
|
const id = chunkJson?.content_block.id;
|
||||||
|
const name = chunkJson?.content_block.name;
|
||||||
const { delta } = chunkJson;
|
runTools.push({
|
||||||
if (delta?.text) {
|
id,
|
||||||
context.text += delta.text;
|
type: "function",
|
||||||
options.onUpdate?.(context.text, delta.text);
|
function: {
|
||||||
}
|
name,
|
||||||
},
|
arguments: "",
|
||||||
onclose() {
|
},
|
||||||
finish();
|
});
|
||||||
},
|
}
|
||||||
onerror(e) {
|
if (
|
||||||
options.onError?.(e);
|
chunkJson?.delta?.type == "input_json_delta" &&
|
||||||
throw e;
|
chunkJson?.delta?.partial_json
|
||||||
},
|
) {
|
||||||
openWhenHidden: true,
|
// @ts-ignore
|
||||||
});
|
runTools[index]["function"]["arguments"] +=
|
||||||
} catch (e) {
|
chunkJson?.delta?.partial_json;
|
||||||
console.error("failed to chat", e);
|
}
|
||||||
options.onError?.(e as Error);
|
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 {
|
} 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 {
|
try {
|
||||||
controller.signal.onabort = () => options.onFinish("");
|
controller.signal.onabort = () =>
|
||||||
|
options.onFinish("", new Response(null, { status: 400 }));
|
||||||
|
|
||||||
const res = await fetch(path, payload);
|
const res = await fetch(path, payload);
|
||||||
const resJson = await res.json();
|
const resJson = await res.json();
|
||||||
|
|
||||||
const message = this.extractMessage(resJson);
|
const message = this.extractMessage(resJson);
|
||||||
options.onFinish(message);
|
options.onFinish(message, res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("failed to chat", e);
|
console.error("failed to chat", e);
|
||||||
options.onError?.(e as Error);
|
options.onError?.(e as Error);
|
||||||
@@ -370,9 +390,7 @@ export class ClaudeApi implements LLMApi {
|
|||||||
if (baseUrl.trim().length === 0) {
|
if (baseUrl.trim().length === 0) {
|
||||||
const isApp = !!getClientConfig()?.isApp;
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
|
||||||
baseUrl = isApp
|
baseUrl = isApp ? ANTHROPIC_BASE_URL : ApiPath.Anthropic;
|
||||||
? DEFAULT_API_HOST + "/api/proxy/anthropic"
|
|
||||||
: ApiPath.Anthropic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
LLMApi,
|
LLMApi,
|
||||||
LLMModel,
|
LLMModel,
|
||||||
MultimodalContent,
|
MultimodalContent,
|
||||||
|
SpeechOptions,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import Locale from "../../locales";
|
import Locale from "../../locales";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { getClientConfig } from "@/app/config/client";
|
import { getClientConfig } from "@/app/config/client";
|
||||||
import { getMessageTextContent } from "@/app/utils";
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
export interface OpenAIListModelResponse {
|
export interface OpenAIListModelResponse {
|
||||||
object: string;
|
object: string;
|
||||||
@@ -75,18 +77,30 @@ export class ErnieApi implements LLMApi {
|
|||||||
return [baseUrl, path].join("/");
|
return [baseUrl, path].join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions) {
|
async chat(options: ChatOptions) {
|
||||||
const messages = options.messages.map((v) => ({
|
const messages = options.messages.map((v) => ({
|
||||||
role: v.role,
|
// "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),
|
content: getMessageTextContent(v),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// "error_code": 336006, "error_msg": "the length of messages must be an odd number",
|
// "error_code": 336006, "error_msg": "the length of messages must be an odd number",
|
||||||
if (messages.length % 2 === 0) {
|
if (messages.length % 2 === 0) {
|
||||||
messages.unshift({
|
if (messages.at(0)?.role === "user") {
|
||||||
role: "user",
|
messages.splice(1, 0, {
|
||||||
content: " ",
|
role: "assistant",
|
||||||
});
|
content: " ",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.unshift({
|
||||||
|
role: "user",
|
||||||
|
content: " ",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelConfig = {
|
const modelConfig = {
|
||||||
@@ -148,6 +162,7 @@ export class ErnieApi implements LLMApi {
|
|||||||
let responseText = "";
|
let responseText = "";
|
||||||
let remainText = "";
|
let remainText = "";
|
||||||
let finished = false;
|
let finished = false;
|
||||||
|
let responseRes: Response;
|
||||||
|
|
||||||
// animate response to make it looks smooth
|
// animate response to make it looks smooth
|
||||||
function animateResponseText() {
|
function animateResponseText() {
|
||||||
@@ -177,19 +192,20 @@ export class ErnieApi implements LLMApi {
|
|||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
finished = true;
|
finished = true;
|
||||||
options.onFinish(responseText + remainText);
|
options.onFinish(responseText + remainText, responseRes);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.signal.onabort = finish;
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
fetchEventSource(chatPath, {
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
...chatPayload,
|
...chatPayload,
|
||||||
async onopen(res) {
|
async onopen(res) {
|
||||||
clearTimeout(requestTimeoutId);
|
clearTimeout(requestTimeoutId);
|
||||||
const contentType = res.headers.get("content-type");
|
const contentType = res.headers.get("content-type");
|
||||||
console.log("[Baidu] request response content type: ", contentType);
|
console.log("[Baidu] request response content type: ", contentType);
|
||||||
|
responseRes = res;
|
||||||
if (contentType?.startsWith("text/plain")) {
|
if (contentType?.startsWith("text/plain")) {
|
||||||
responseText = await res.clone().text();
|
responseText = await res.clone().text();
|
||||||
return finish();
|
return finish();
|
||||||
@@ -252,7 +268,7 @@ export class ErnieApi implements LLMApi {
|
|||||||
|
|
||||||
const resJson = await res.json();
|
const resJson = await res.json();
|
||||||
const message = resJson?.result;
|
const message = resJson?.result;
|
||||||
options.onFinish(message);
|
options.onFinish(message, res);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Request] failed to make a chat request", e);
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
LLMApi,
|
LLMApi,
|
||||||
LLMModel,
|
LLMModel,
|
||||||
MultimodalContent,
|
MultimodalContent,
|
||||||
|
SpeechOptions,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import Locale from "../../locales";
|
import Locale from "../../locales";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { getClientConfig } from "@/app/config/client";
|
import { getClientConfig } from "@/app/config/client";
|
||||||
import { getMessageTextContent } from "@/app/utils";
|
import { getMessageTextContent } from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
export interface OpenAIListModelResponse {
|
export interface OpenAIListModelResponse {
|
||||||
object: string;
|
object: string;
|
||||||
@@ -77,6 +79,10 @@ export class DoubaoApi implements LLMApi {
|
|||||||
return res.choices?.at(0)?.message?.content ?? "";
|
return res.choices?.at(0)?.message?.content ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions) {
|
async chat(options: ChatOptions) {
|
||||||
const messages = options.messages.map((v) => ({
|
const messages = options.messages.map((v) => ({
|
||||||
role: v.role,
|
role: v.role,
|
||||||
@@ -124,6 +130,7 @@ export class DoubaoApi implements LLMApi {
|
|||||||
let responseText = "";
|
let responseText = "";
|
||||||
let remainText = "";
|
let remainText = "";
|
||||||
let finished = false;
|
let finished = false;
|
||||||
|
let responseRes: Response;
|
||||||
|
|
||||||
// animate response to make it looks smooth
|
// animate response to make it looks smooth
|
||||||
function animateResponseText() {
|
function animateResponseText() {
|
||||||
@@ -153,13 +160,14 @@ export class DoubaoApi implements LLMApi {
|
|||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
finished = true;
|
finished = true;
|
||||||
options.onFinish(responseText + remainText);
|
options.onFinish(responseText + remainText, responseRes);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.signal.onabort = finish;
|
controller.signal.onabort = finish;
|
||||||
|
|
||||||
fetchEventSource(chatPath, {
|
fetchEventSource(chatPath, {
|
||||||
|
fetch: fetch as any,
|
||||||
...chatPayload,
|
...chatPayload,
|
||||||
async onopen(res) {
|
async onopen(res) {
|
||||||
clearTimeout(requestTimeoutId);
|
clearTimeout(requestTimeoutId);
|
||||||
@@ -168,7 +176,7 @@ export class DoubaoApi implements LLMApi {
|
|||||||
"[ByteDance] request response content type: ",
|
"[ByteDance] request response content type: ",
|
||||||
contentType,
|
contentType,
|
||||||
);
|
);
|
||||||
|
responseRes = res;
|
||||||
if (contentType?.startsWith("text/plain")) {
|
if (contentType?.startsWith("text/plain")) {
|
||||||
responseText = await res.clone().text();
|
responseText = await res.clone().text();
|
||||||
return finish();
|
return finish();
|
||||||
@@ -234,7 +242,7 @@ export class DoubaoApi implements LLMApi {
|
|||||||
|
|
||||||
const resJson = await res.json();
|
const resJson = await res.json();
|
||||||
const message = this.extractMessage(resJson);
|
const message = this.extractMessage(resJson);
|
||||||
options.onFinish(message);
|
options.onFinish(message, res);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Request] failed to make a chat request", e);
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
|||||||
200
app/client/platforms/deepseek.ts
Normal file
200
app/client/platforms/deepseek.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
// azure and openai, using same models. so using same LLMApi.
|
||||||
|
import {
|
||||||
|
ApiPath,
|
||||||
|
DEEPSEEK_BASE_URL,
|
||||||
|
DeepSeek,
|
||||||
|
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";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
export class DeepSeekApi implements LLMApi {
|
||||||
|
private disableListModels = true;
|
||||||
|
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.deepseekUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
const apiPath = ApiPath.DeepSeek;
|
||||||
|
baseUrl = isApp ? DEEPSEEK_BASE_URL : apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.DeepSeek)) {
|
||||||
|
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(DeepSeek.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, res);
|
||||||
|
}
|
||||||
|
} 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
293
app/client/platforms/glm.ts
Normal file
293
app/client/platforms/glm.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
ApiPath,
|
||||||
|
CHATGLM_BASE_URL,
|
||||||
|
ChatGLM,
|
||||||
|
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, isVisionModel } from "@/app/utils";
|
||||||
|
import { RequestPayload } from "./openai";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
import { preProcessImageContent } from "@/app/utils/chat";
|
||||||
|
|
||||||
|
interface BasePayload {
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatPayload extends BasePayload {
|
||||||
|
messages: ChatOptions["messages"];
|
||||||
|
stream?: boolean;
|
||||||
|
temperature?: number;
|
||||||
|
presence_penalty?: number;
|
||||||
|
frequency_penalty?: number;
|
||||||
|
top_p?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageGenerationPayload extends BasePayload {
|
||||||
|
prompt: string;
|
||||||
|
size?: string;
|
||||||
|
user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoGenerationPayload extends BasePayload {
|
||||||
|
prompt: string;
|
||||||
|
duration?: number;
|
||||||
|
resolution?: string;
|
||||||
|
user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelType = "chat" | "image" | "video";
|
||||||
|
|
||||||
|
export class ChatGLMApi implements LLMApi {
|
||||||
|
private disableListModels = true;
|
||||||
|
|
||||||
|
private getModelType(model: string): ModelType {
|
||||||
|
if (model.startsWith("cogview-")) return "image";
|
||||||
|
if (model.startsWith("cogvideo-")) return "video";
|
||||||
|
return "chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getModelPath(type: ModelType): string {
|
||||||
|
switch (type) {
|
||||||
|
case "image":
|
||||||
|
return ChatGLM.ImagePath;
|
||||||
|
case "video":
|
||||||
|
return ChatGLM.VideoPath;
|
||||||
|
default:
|
||||||
|
return ChatGLM.ChatPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPayload(
|
||||||
|
messages: ChatOptions["messages"],
|
||||||
|
modelConfig: any,
|
||||||
|
options: ChatOptions,
|
||||||
|
): BasePayload {
|
||||||
|
const modelType = this.getModelType(modelConfig.model);
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
const prompt =
|
||||||
|
typeof lastMessage.content === "string"
|
||||||
|
? lastMessage.content
|
||||||
|
: lastMessage.content.map((c) => c.text).join("\n");
|
||||||
|
|
||||||
|
switch (modelType) {
|
||||||
|
case "image":
|
||||||
|
return {
|
||||||
|
model: modelConfig.model,
|
||||||
|
prompt,
|
||||||
|
size: options.config.size,
|
||||||
|
} as ImageGenerationPayload;
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
} as ChatPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseResponse(modelType: ModelType, json: any): string {
|
||||||
|
switch (modelType) {
|
||||||
|
case "image": {
|
||||||
|
const imageUrl = json.data?.[0]?.url;
|
||||||
|
return imageUrl ? `` : "";
|
||||||
|
}
|
||||||
|
case "video": {
|
||||||
|
const videoUrl = json.data?.[0]?.url;
|
||||||
|
return videoUrl ? `<video controls src="${videoUrl}"></video>` : "";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return this.extractMessage(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.chatglmUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
const apiPath = ApiPath.ChatGLM;
|
||||||
|
baseUrl = isApp ? CHATGLM_BASE_URL : apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ChatGLM)) {
|
||||||
|
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 visionModel = isVisionModel(options.config.model);
|
||||||
|
const messages: ChatOptions["messages"] = [];
|
||||||
|
for (const v of options.messages) {
|
||||||
|
const content = visionModel
|
||||||
|
? await preProcessImageContent(v.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 modelType = this.getModelType(modelConfig.model);
|
||||||
|
const requestPayload = this.createPayload(messages, modelConfig, options);
|
||||||
|
const path = this.path(this.getModelPath(modelType));
|
||||||
|
|
||||||
|
console.log(`[Request] glm ${modelType} payload: `, requestPayload);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: getHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modelType === "image" || modelType === "video") {
|
||||||
|
const res = await fetch(path, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
console.log(`[Response] glm ${modelType}:`, resJson);
|
||||||
|
const message = this.parseResponse(modelType, resJson);
|
||||||
|
options.onFinish(message, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
if (shouldStream) {
|
||||||
|
const [tools, funcs] = usePluginStore
|
||||||
|
.getState()
|
||||||
|
.getAsTools(
|
||||||
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
|
);
|
||||||
|
return stream(
|
||||||
|
path,
|
||||||
|
requestPayload,
|
||||||
|
getHeaders(),
|
||||||
|
tools as any,
|
||||||
|
funcs,
|
||||||
|
controller,
|
||||||
|
// parseSSE
|
||||||
|
(text: string, runTools: ChatMessageTool[]) => {
|
||||||
|
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
|
||||||
|
(
|
||||||
|
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(path, chatPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
|
const resJson = await res.json();
|
||||||
|
const message = this.extractMessage(resJson);
|
||||||
|
options.onFinish(message, res);
|
||||||
|
}
|
||||||
|
} 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,23 +1,35 @@
|
|||||||
import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||||
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
|
|
||||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
|
||||||
import { getClientConfig } from "@/app/config/client";
|
|
||||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
|
||||||
import Locale from "../../locales";
|
|
||||||
import {
|
import {
|
||||||
EventStreamContentType,
|
ChatOptions,
|
||||||
fetchEventSource,
|
getHeaders,
|
||||||
} from "@fortaine/fetch-event-source";
|
LLMApi,
|
||||||
import { prettyObject } from "@/app/utils/format";
|
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 {
|
import {
|
||||||
getMessageTextContent,
|
getMessageTextContent,
|
||||||
getMessageImages,
|
getMessageImages,
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
} from "@/app/utils";
|
} from "@/app/utils";
|
||||||
import { preProcessImageContent } from "@/app/utils/chat";
|
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 {
|
export class GeminiProApi implements LLMApi {
|
||||||
path(path: string): string {
|
path(path: string, shouldStream = false): string {
|
||||||
const accessStore = useAccessStore.getState();
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
let baseUrl = "";
|
let baseUrl = "";
|
||||||
@@ -25,11 +37,9 @@ export class GeminiProApi implements LLMApi {
|
|||||||
baseUrl = accessStore.googleUrl;
|
baseUrl = accessStore.googleUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
if (baseUrl.length === 0) {
|
if (baseUrl.length === 0) {
|
||||||
const isApp = !!getClientConfig()?.isApp;
|
baseUrl = isApp ? GEMINI_BASE_URL : ApiPath.Google;
|
||||||
baseUrl = isApp
|
|
||||||
? DEFAULT_API_HOST + `/api/proxy/google?key=${accessStore.googleApiKey}`
|
|
||||||
: ApiPath.Google;
|
|
||||||
}
|
}
|
||||||
if (baseUrl.endsWith("/")) {
|
if (baseUrl.endsWith("/")) {
|
||||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
@@ -41,19 +51,35 @@ export class GeminiProApi implements LLMApi {
|
|||||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
let chatPath = [baseUrl, path].join("/");
|
let chatPath = [baseUrl, path].join("/");
|
||||||
|
if (shouldStream) {
|
||||||
|
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
||||||
|
}
|
||||||
|
|
||||||
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
|
||||||
return chatPath;
|
return chatPath;
|
||||||
}
|
}
|
||||||
extractMessage(res: any) {
|
extractMessage(res: any) {
|
||||||
console.log("[Response] gemini-pro response: ", res);
|
console.log("[Response] gemini-pro response: ", res);
|
||||||
|
|
||||||
|
const getTextFromParts = (parts: any[]) => {
|
||||||
|
if (!Array.isArray(parts)) return "";
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part) => part?.text || "")
|
||||||
|
.filter((text) => text.trim() !== "")
|
||||||
|
.join("\n\n");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
|
getTextFromParts(res?.candidates?.at(0)?.content?.parts) ||
|
||||||
|
getTextFromParts(res?.at(0)?.candidates?.at(0)?.content?.parts) ||
|
||||||
res?.error?.message ||
|
res?.error?.message ||
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions): Promise<void> {
|
async chat(options: ChatOptions): Promise<void> {
|
||||||
const apiClient = this;
|
const apiClient = this;
|
||||||
let multimodal = false;
|
let multimodal = false;
|
||||||
@@ -106,6 +132,9 @@ export class GeminiProApi implements LLMApi {
|
|||||||
// if (visionModel && messages.length > 1) {
|
// if (visionModel && messages.length > 1) {
|
||||||
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
|
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
const modelConfig = {
|
const modelConfig = {
|
||||||
...useAppConfig.getState().modelConfig,
|
...useAppConfig.getState().modelConfig,
|
||||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
@@ -127,19 +156,19 @@ export class GeminiProApi implements LLMApi {
|
|||||||
safetySettings: [
|
safetySettings: [
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_HARASSMENT",
|
category: "HARM_CATEGORY_HARASSMENT",
|
||||||
threshold: "BLOCK_ONLY_HIGH",
|
threshold: accessStore.googleSafetySettings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_HATE_SPEECH",
|
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||||
threshold: "BLOCK_ONLY_HIGH",
|
threshold: accessStore.googleSafetySettings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||||
threshold: "BLOCK_ONLY_HIGH",
|
threshold: accessStore.googleSafetySettings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||||
threshold: "BLOCK_ONLY_HIGH",
|
threshold: accessStore.googleSafetySettings,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -149,7 +178,10 @@ export class GeminiProApi implements LLMApi {
|
|||||||
options.onController?.(controller);
|
options.onController?.(controller);
|
||||||
try {
|
try {
|
||||||
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
|
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
|
||||||
const chatPath = this.path(Google.ChatPath(modelConfig.model));
|
const chatPath = this.path(
|
||||||
|
Google.ChatPath(modelConfig.model),
|
||||||
|
shouldStream,
|
||||||
|
);
|
||||||
|
|
||||||
const chatPayload = {
|
const chatPayload = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -165,114 +197,87 @@ export class GeminiProApi implements LLMApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldStream) {
|
if (shouldStream) {
|
||||||
let responseText = "";
|
const [tools, funcs] = usePluginStore
|
||||||
let remainText = "";
|
.getState()
|
||||||
let finished = false;
|
.getAsTools(
|
||||||
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
|
);
|
||||||
|
return stream(
|
||||||
|
chatPath,
|
||||||
|
requestPayload,
|
||||||
|
getHeaders(),
|
||||||
|
// @ts-ignore
|
||||||
|
tools.length > 0
|
||||||
|
? // @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 finish = () => {
|
const functionCall = chunkJson?.candidates
|
||||||
if (!finished) {
|
?.at(0)
|
||||||
finished = true;
|
?.content.parts.at(0)?.functionCall;
|
||||||
options.onFinish(responseText + remainText);
|
if (functionCall) {
|
||||||
}
|
const { name, args } = functionCall;
|
||||||
};
|
runTools.push({
|
||||||
|
id: nanoid(),
|
||||||
// animate response to make it looks smooth
|
type: "function",
|
||||||
function animateResponseText() {
|
function: {
|
||||||
if (finished || controller.signal.aborted) {
|
name,
|
||||||
responseText += remainText;
|
arguments: JSON.stringify(args), // utils.chat call function, using JSON.parse
|
||||||
finish();
|
},
|
||||||
return;
|
});
|
||||||
}
|
}
|
||||||
|
return chunkJson?.candidates
|
||||||
if (remainText.length > 0) {
|
?.at(0)
|
||||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
?.content.parts?.map((part: { text: string }) => part.text)
|
||||||
const fetchText = remainText.slice(0, fetchCount);
|
.join("\n\n");
|
||||||
responseText += fetchText;
|
},
|
||||||
remainText = remainText.slice(fetchCount);
|
// processToolMessage, include tool_calls message and tool call results
|
||||||
options.onUpdate?.(responseText, fetchText);
|
(
|
||||||
}
|
requestPayload: RequestPayload,
|
||||||
|
toolCallMessage: any,
|
||||||
requestAnimationFrame(animateResponseText);
|
toolCallResult: any[],
|
||||||
}
|
) => {
|
||||||
|
// @ts-ignore
|
||||||
// start animaion
|
requestPayload?.contents?.splice(
|
||||||
animateResponseText();
|
// @ts-ignore
|
||||||
|
requestPayload?.contents?.length,
|
||||||
controller.signal.onabort = finish;
|
0,
|
||||||
|
{
|
||||||
fetchEventSource(chatPath, {
|
role: "model",
|
||||||
...chatPayload,
|
parts: toolCallMessage.tool_calls.map(
|
||||||
async onopen(res) {
|
(tool: ChatMessageTool) => ({
|
||||||
clearTimeout(requestTimeoutId);
|
functionCall: {
|
||||||
const contentType = res.headers.get("content-type");
|
name: tool?.function?.name,
|
||||||
console.log(
|
args: JSON.parse(tool?.function?.arguments as string),
|
||||||
"[Gemini] request response content type: ",
|
},
|
||||||
contentType,
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
...toolCallResult.map((result) => ({
|
||||||
|
role: "function",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: result.name,
|
||||||
|
response: {
|
||||||
|
name: result.name,
|
||||||
|
content: result.content, // TODO just text content...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
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) {
|
options,
|
||||||
if (msg.data === "[DONE]" || finished) {
|
);
|
||||||
return finish();
|
|
||||||
}
|
|
||||||
const text = msg.data;
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
const delta = apiClient.extractMessage(json);
|
|
||||||
|
|
||||||
if (delta) {
|
|
||||||
remainText += delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockReason = json?.promptFeedback?.blockReason;
|
|
||||||
if (blockReason) {
|
|
||||||
// being blocked
|
|
||||||
console.log(`[Google] [Safety Ratings] result:`, blockReason);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[Request] parse error", text, msg);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onclose() {
|
|
||||||
finish();
|
|
||||||
},
|
|
||||||
onerror(e) {
|
|
||||||
options.onError?.(e);
|
|
||||||
throw e;
|
|
||||||
},
|
|
||||||
openWhenHidden: true,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(chatPath, chatPayload);
|
const res = await fetch(chatPath, chatPayload);
|
||||||
clearTimeout(requestTimeoutId);
|
clearTimeout(requestTimeoutId);
|
||||||
@@ -287,7 +292,7 @@ export class GeminiProApi implements LLMApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = apiClient.extractMessage(resJson);
|
const message = apiClient.extractMessage(resJson);
|
||||||
options.onFinish(message);
|
options.onFinish(message, res);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Request] failed to make a chat request", e);
|
console.log("[Request] failed to make a chat request", e);
|
||||||
|
|||||||
253
app/client/platforms/iflytek.ts
Normal file
253
app/client/platforms/iflytek.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"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;
|
||||||
|
let responseRes: Response;
|
||||||
|
|
||||||
|
// 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, responseRes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
responseRes = res;
|
||||||
|
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, res);
|
||||||
|
}
|
||||||
|
} 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/client/platforms/moonshot.ts
Normal file
200
app/client/platforms/moonshot.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"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";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
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, res);
|
||||||
|
}
|
||||||
|
} 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,29 @@
|
|||||||
// azure and openai, using same models. so using same LLMApi.
|
// azure and openai, using same models. so using same LLMApi.
|
||||||
import {
|
import {
|
||||||
ApiPath,
|
ApiPath,
|
||||||
DEFAULT_API_HOST,
|
OPENAI_BASE_URL,
|
||||||
DEFAULT_MODELS,
|
DEFAULT_MODELS,
|
||||||
OpenaiPath,
|
OpenaiPath,
|
||||||
Azure,
|
Azure,
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
} from "@/app/constant";
|
} from "@/app/constant";
|
||||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
import {
|
||||||
|
ChatMessageTool,
|
||||||
|
useAccessStore,
|
||||||
|
useAppConfig,
|
||||||
|
useChatStore,
|
||||||
|
usePluginStore,
|
||||||
|
} from "@/app/store";
|
||||||
import { collectModelsWithDefaultModel } from "@/app/utils/model";
|
import { collectModelsWithDefaultModel } from "@/app/utils/model";
|
||||||
import { preProcessImageContent } from "@/app/utils/chat";
|
import {
|
||||||
|
preProcessImageContent,
|
||||||
|
uploadImage,
|
||||||
|
base64Image2Blob,
|
||||||
|
stream,
|
||||||
|
} from "@/app/utils/chat";
|
||||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||||
|
import { ModelSize, DalleQuality, DalleStyle } from "@/app/typing";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatOptions,
|
ChatOptions,
|
||||||
@@ -21,19 +33,16 @@ import {
|
|||||||
LLMModel,
|
LLMModel,
|
||||||
LLMUsage,
|
LLMUsage,
|
||||||
MultimodalContent,
|
MultimodalContent,
|
||||||
|
SpeechOptions,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import Locale from "../../locales";
|
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 { getClientConfig } from "@/app/config/client";
|
||||||
import {
|
import {
|
||||||
getMessageTextContent,
|
getMessageTextContent,
|
||||||
getMessageImages,
|
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
|
isDalle3 as _isDalle3,
|
||||||
} from "@/app/utils";
|
} from "@/app/utils";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
export interface OpenAIListModelResponse {
|
export interface OpenAIListModelResponse {
|
||||||
object: string;
|
object: string;
|
||||||
@@ -56,6 +65,17 @@ export interface RequestPayload {
|
|||||||
frequency_penalty: number;
|
frequency_penalty: number;
|
||||||
top_p: number;
|
top_p: number;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
|
max_completion_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DalleRequestPayload {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
response_format: "url" | "b64_json";
|
||||||
|
n: number;
|
||||||
|
size: ModelSize;
|
||||||
|
quality: DalleQuality;
|
||||||
|
style: DalleStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatGPTApi implements LLMApi {
|
export class ChatGPTApi implements LLMApi {
|
||||||
@@ -80,7 +100,7 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
if (baseUrl.length === 0) {
|
if (baseUrl.length === 0) {
|
||||||
const isApp = !!getClientConfig()?.isApp;
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
|
const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
|
||||||
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
|
baseUrl = isApp ? OPENAI_BASE_URL : apiPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseUrl.endsWith("/")) {
|
if (baseUrl.endsWith("/")) {
|
||||||
@@ -100,20 +120,69 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
|
return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
extractMessage(res: any) {
|
async extractMessage(res: any) {
|
||||||
return res.choices?.at(0)?.message?.content ?? "";
|
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 = {
|
||||||
|
model: options.model,
|
||||||
|
input: options.input,
|
||||||
|
voice: options.voice,
|
||||||
|
response_format: options.response_format,
|
||||||
|
speed: options.speed,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Request] openai speech payload: ", requestPayload);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const speechPath = this.path(OpenaiPath.SpeechPath);
|
||||||
|
const speechPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: getHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(speechPath, speechPayload);
|
||||||
|
clearTimeout(requestTimeoutId);
|
||||||
|
return await res.arrayBuffer();
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Request] failed to make a speech request", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions) {
|
async chat(options: ChatOptions) {
|
||||||
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);
|
|
||||||
messages.push({ role: v.role, content });
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelConfig = {
|
const modelConfig = {
|
||||||
...useAppConfig.getState().modelConfig,
|
...useAppConfig.getState().modelConfig,
|
||||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
@@ -123,26 +192,62 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestPayload: RequestPayload = {
|
let requestPayload: RequestPayload | DalleRequestPayload;
|
||||||
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.
|
|
||||||
};
|
|
||||||
|
|
||||||
// add max_tokens to vision model
|
const isDalle3 = _isDalle3(options.config.model);
|
||||||
if (visionModel && modelConfig.model.includes("preview")) {
|
const isO1 = options.config.model.startsWith("o1");
|
||||||
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
if (isDalle3) {
|
||||||
|
const prompt = getMessageTextContent(
|
||||||
|
options.messages.slice(-1)?.pop() as any,
|
||||||
|
);
|
||||||
|
requestPayload = {
|
||||||
|
model: options.config.model,
|
||||||
|
prompt,
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
|
||||||
|
requestPayload = {
|
||||||
|
messages,
|
||||||
|
stream: options.config.stream,
|
||||||
|
model: modelConfig.model,
|
||||||
|
temperature: !isO1 ? modelConfig.temperature : 1,
|
||||||
|
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
|
||||||
|
frequency_penalty: !isO1 ? modelConfig.frequency_penalty : 0,
|
||||||
|
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.
|
||||||
|
};
|
||||||
|
|
||||||
|
// O1 使用 max_completion_tokens 控制token数 (https://platform.openai.com/docs/guides/reasoning#controlling-costs)
|
||||||
|
if (isO1) {
|
||||||
|
requestPayload["max_completion_tokens"] = modelConfig.max_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add max_tokens to vision model
|
||||||
|
if (visionModel) {
|
||||||
|
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[Request] openai payload: ", requestPayload);
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
const shouldStream = !!options.config.stream;
|
const shouldStream = !isDalle3 && !!options.config.stream;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
options.onController?.(controller);
|
options.onController?.(controller);
|
||||||
|
|
||||||
@@ -168,157 +273,101 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
model?.provider?.providerName === ServiceProvider.Azure,
|
model?.provider?.providerName === ServiceProvider.Azure,
|
||||||
);
|
);
|
||||||
chatPath = this.path(
|
chatPath = this.path(
|
||||||
Azure.ChatPath(
|
(isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
|
||||||
(model?.displayName ?? model?.name) as string,
|
(model?.displayName ?? model?.name) as string,
|
||||||
useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
|
useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
chatPath = this.path(OpenaiPath.ChatPath);
|
chatPath = this.path(
|
||||||
|
isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const chatPayload = {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(requestPayload),
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: getHeaders(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// make a fetch request
|
|
||||||
const requestTimeoutId = setTimeout(
|
|
||||||
() => controller.abort(),
|
|
||||||
REQUEST_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldStream) {
|
if (shouldStream) {
|
||||||
let responseText = "";
|
let index = -1;
|
||||||
let remainText = "";
|
const [tools, funcs] = usePluginStore
|
||||||
let finished = false;
|
.getState()
|
||||||
|
.getAsTools(
|
||||||
// animate response to make it looks smooth
|
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||||
function animateResponseText() {
|
);
|
||||||
if (finished || controller.signal.aborted) {
|
// console.log("getAsTools", tools, funcs);
|
||||||
responseText += remainText;
|
stream(
|
||||||
console.log("[Response Animation] finished");
|
chatPath,
|
||||||
if (responseText?.length === 0) {
|
requestPayload,
|
||||||
options.onError?.(new Error("empty response from server"));
|
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 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
runTools[index]["function"]["arguments"] += args;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return choices[0]?.delta?.content;
|
||||||
}
|
},
|
||||||
|
// processToolMessage, include tool_calls message and tool call results
|
||||||
if (remainText.length > 0) {
|
(
|
||||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
requestPayload: RequestPayload,
|
||||||
const fetchText = remainText.slice(0, fetchCount);
|
toolCallMessage: any,
|
||||||
responseText += fetchText;
|
toolCallResult: any[],
|
||||||
remainText = remainText.slice(fetchCount);
|
) => {
|
||||||
options.onUpdate?.(responseText, fetchText);
|
// reset index value
|
||||||
}
|
index = -1;
|
||||||
|
// @ts-ignore
|
||||||
requestAnimationFrame(animateResponseText);
|
requestPayload?.messages?.splice(
|
||||||
}
|
// @ts-ignore
|
||||||
|
requestPayload?.messages?.length,
|
||||||
// start animaion
|
0,
|
||||||
animateResponseText();
|
toolCallMessage,
|
||||||
|
...toolCallResult,
|
||||||
const finish = () => {
|
);
|
||||||
if (!finished) {
|
},
|
||||||
finished = true;
|
options,
|
||||||
options.onFinish(responseText + remainText);
|
);
|
||||||
}
|
} else {
|
||||||
|
const chatPayload = {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: getHeaders(),
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.signal.onabort = finish;
|
// make a fetch request
|
||||||
|
const requestTimeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 4 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
|
||||||
|
);
|
||||||
|
|
||||||
fetchEventSource(chatPath, {
|
|
||||||
...chatPayload,
|
|
||||||
async onopen(res) {
|
|
||||||
clearTimeout(requestTimeoutId);
|
|
||||||
const contentType = res.headers.get("content-type");
|
|
||||||
console.log(
|
|
||||||
"[OpenAI] request response content type: ",
|
|
||||||
contentType,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (contentType?.startsWith("text/plain")) {
|
|
||||||
responseText = await res.clone().text();
|
|
||||||
return finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!res.ok ||
|
|
||||||
!res.headers
|
|
||||||
.get("content-type")
|
|
||||||
?.startsWith(EventStreamContentType) ||
|
|
||||||
res.status !== 200
|
|
||||||
) {
|
|
||||||
const responseTexts = [responseText];
|
|
||||||
let extraInfo = await res.clone().text();
|
|
||||||
try {
|
|
||||||
const resJson = await res.clone().json();
|
|
||||||
extraInfo = prettyObject(resJson);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
responseTexts.push(Locale.Error.Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extraInfo) {
|
|
||||||
responseTexts.push(extraInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
responseText = responseTexts.join("\n\n");
|
|
||||||
|
|
||||||
return finish();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onmessage(msg) {
|
|
||||||
if (msg.data === "[DONE]" || finished) {
|
|
||||||
return finish();
|
|
||||||
}
|
|
||||||
const text = msg.data;
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
const choices = json.choices as Array<{
|
|
||||||
delta: { content: string };
|
|
||||||
}>;
|
|
||||||
const delta = choices[0]?.delta?.content;
|
|
||||||
const textmoderation = json?.prompt_filter_results;
|
|
||||||
|
|
||||||
if (delta) {
|
|
||||||
remainText += delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
textmoderation &&
|
|
||||||
textmoderation.length > 0 &&
|
|
||||||
ServiceProvider.Azure
|
|
||||||
) {
|
|
||||||
const contentFilterResults =
|
|
||||||
textmoderation[0]?.content_filter_results;
|
|
||||||
console.log(
|
|
||||||
`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
|
|
||||||
contentFilterResults,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
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);
|
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, res);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Request] failed to make a chat request", e);
|
console.log("[Request] failed to make a chat request", e);
|
||||||
@@ -404,20 +453,26 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resJson = (await res.json()) as OpenAIListModelResponse;
|
const resJson = (await res.json()) as OpenAIListModelResponse;
|
||||||
const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
|
const chatModels = resJson.data?.filter(
|
||||||
|
(m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"),
|
||||||
|
);
|
||||||
console.log("[Models]", chatModels);
|
console.log("[Models]", chatModels);
|
||||||
|
|
||||||
if (!chatModels) {
|
if (!chatModels) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场
|
||||||
|
let seq = 1000; //同 Constant.ts 中的排序保持一致
|
||||||
return chatModels.map((m) => ({
|
return chatModels.map((m) => ({
|
||||||
name: m.id,
|
name: m.id,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
provider: {
|
provider: {
|
||||||
id: "openai",
|
id: "openai",
|
||||||
providerName: "OpenAI",
|
providerName: "OpenAI",
|
||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
|
sorted: 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
274
app/client/platforms/tencent.ts
Normal file
274
app/client/platforms/tencent.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"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;
|
||||||
|
let responseRes: Response;
|
||||||
|
|
||||||
|
// 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, responseRes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
responseRes = res;
|
||||||
|
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, res);
|
||||||
|
}
|
||||||
|
} 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
193
app/client/platforms/xai.ts
Normal file
193
app/client/platforms/xai.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
// azure and openai, using same models. so using same LLMApi.
|
||||||
|
import { ApiPath, XAI_BASE_URL, XAI, 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";
|
||||||
|
import { fetch } from "@/app/utils/stream";
|
||||||
|
|
||||||
|
export class XAIApi implements LLMApi {
|
||||||
|
private disableListModels = true;
|
||||||
|
|
||||||
|
path(path: string): string {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.xaiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.length === 0) {
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
const apiPath = ApiPath.XAI;
|
||||||
|
baseUrl = isApp ? XAI_BASE_URL : apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.XAI)) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Request] xai payload: ", requestPayload);
|
||||||
|
|
||||||
|
const shouldStream = !!options.config.stream;
|
||||||
|
const controller = new AbortController();
|
||||||
|
options.onController?.(controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chatPath = this.path(XAI.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, res);
|
||||||
|
}
|
||||||
|
} 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,16 +38,20 @@ interface ChatCommands {
|
|||||||
next?: Command;
|
next?: Command;
|
||||||
prev?: Command;
|
prev?: Command;
|
||||||
clear?: Command;
|
clear?: Command;
|
||||||
|
fork?: Command;
|
||||||
del?: Command;
|
del?: Command;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatCommandPrefix = ":";
|
// Compatible with Chinese colon character ":"
|
||||||
|
export const ChatCommandPrefix = /^[::]/;
|
||||||
|
|
||||||
export function useChatCommand(commands: ChatCommands = {}) {
|
export function useChatCommand(commands: ChatCommands = {}) {
|
||||||
function extract(userInput: string) {
|
function extract(userInput: string) {
|
||||||
return (
|
const match = userInput.match(ChatCommandPrefix);
|
||||||
userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput
|
if (match) {
|
||||||
) as keyof ChatCommands;
|
return userInput.slice(1) as keyof ChatCommands;
|
||||||
|
}
|
||||||
|
return userInput as keyof ChatCommands;
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(userInput: string) {
|
function search(userInput: string) {
|
||||||
@@ -57,7 +61,7 @@ export function useChatCommand(commands: ChatCommands = {}) {
|
|||||||
.filter((c) => c.startsWith(input))
|
.filter((c) => c.startsWith(input))
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
title: desc[c as keyof ChatCommands],
|
title: desc[c as keyof ChatCommands],
|
||||||
content: ChatCommandPrefix + c,
|
content: ":" + c,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,37 @@
|
|||||||
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 { useEffect } from "react";
|
|
||||||
import { getClientConfig } from "../config/client";
|
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";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const storage = safeLocalStorage();
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
const goHome = () => navigate(Path.Home);
|
const goHome = () => navigate(Path.Home);
|
||||||
const goChat = () => navigate(Path.Chat);
|
const goChat = () => navigate(Path.Chat);
|
||||||
|
const goSaas = () => {
|
||||||
|
trackAuthorizationPageButtonToCPaymentClick();
|
||||||
|
window.location.href = SAAS_CHAT_URL;
|
||||||
|
};
|
||||||
|
|
||||||
const resetAccessCode = () => {
|
const resetAccessCode = () => {
|
||||||
accessStore.update((access) => {
|
accessStore.update((access) => {
|
||||||
access.openaiApiKey = "";
|
access.openaiApiKey = "";
|
||||||
@@ -32,43 +48,58 @@ export function AuthPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["auth-page"]}>
|
<div className={styles["auth-page"]}>
|
||||||
<div className={`no-dark ${styles["auth-logo"]}`}>
|
<TopBanner></TopBanner>
|
||||||
|
<div className={styles["auth-header"]}>
|
||||||
|
<IconButton
|
||||||
|
icon={<LeftIcon />}
|
||||||
|
text={Locale.Auth.Return}
|
||||||
|
onClick={() => navigate(Path.Home)}
|
||||||
|
></IconButton>
|
||||||
|
</div>
|
||||||
|
<div className={clsx("no-dark", styles["auth-logo"])}>
|
||||||
<BotIcon />
|
<BotIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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}
|
||||||
placeholder={Locale.Auth.Input}
|
aria-label={Locale.Auth.Input}
|
||||||
value={accessStore.accessCode}
|
value={accessStore.accessCode}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Auth.Input}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
accessStore.update(
|
accessStore.update(
|
||||||
(access) => (access.accessCode = e.currentTarget.value),
|
(access) => (access.accessCode = e.currentTarget.value),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!accessStore.hideUserApiKey ? (
|
{!accessStore.hideUserApiKey ? (
|
||||||
<>
|
<>
|
||||||
<div className={styles["auth-tips"]}>{Locale.Auth.SubTips}</div>
|
<div className={styles["auth-tips"]}>{Locale.Auth.SubTips}</div>
|
||||||
<input
|
<PasswordInput
|
||||||
className={styles["auth-input"]}
|
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||||
type="password"
|
aria={Locale.Settings.ShowPassword}
|
||||||
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
aria-label={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||||
value={accessStore.openaiApiKey}
|
value={accessStore.openaiApiKey}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
accessStore.update(
|
accessStore.update(
|
||||||
(access) => (access.openaiApiKey = e.currentTarget.value),
|
(access) => (access.openaiApiKey = e.currentTarget.value),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<PasswordInput
|
||||||
className={styles["auth-input"]}
|
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||||
type="password"
|
aria={Locale.Settings.ShowPassword}
|
||||||
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
aria-label={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||||
value={accessStore.googleApiKey}
|
value={accessStore.googleApiKey}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
accessStore.update(
|
accessStore.update(
|
||||||
(access) => (access.googleApiKey = e.currentTarget.value),
|
(access) => (access.googleApiKey = e.currentTarget.value),
|
||||||
@@ -85,13 +116,74 @@ export function AuthPage() {
|
|||||||
onClick={goChat}
|
onClick={goChat}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
text={Locale.Auth.Later}
|
text={Locale.Auth.SaasTips}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetAccessCode();
|
goSaas();
|
||||||
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={clsx(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;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export type ButtonType = "primary" | "danger" | null;
|
export type ButtonType = "primary" | "danger" | null;
|
||||||
|
|
||||||
@@ -16,35 +18,48 @@ export function IconButton(props: {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
|
aria?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={
|
className={clsx(
|
||||||
styles["icon-button"] +
|
"clickable",
|
||||||
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
|
styles["icon-button"],
|
||||||
props.className ?? ""
|
{
|
||||||
} clickable ${styles[props.type ?? ""]}`
|
[styles.border]: props.bordered,
|
||||||
}
|
[styles.shadow]: props.shadow,
|
||||||
|
},
|
||||||
|
styles[props.type ?? ""],
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={props.tabIndex}
|
tabIndex={props.tabIndex}
|
||||||
autoFocus={props.autoFocus}
|
autoFocus={props.autoFocus}
|
||||||
|
style={props.style}
|
||||||
|
aria-label={props.aria}
|
||||||
>
|
>
|
||||||
{props.icon && (
|
{props.icon && (
|
||||||
<div
|
<div
|
||||||
className={
|
aria-label={props.text || props.title}
|
||||||
styles["icon-button-icon"] +
|
className={clsx(styles["icon-button-icon"], {
|
||||||
` ${props.type === "primary" && "no-dark"}`
|
"no-dark": props.type === "primary",
|
||||||
}
|
})}
|
||||||
>
|
>
|
||||||
{props.icon}
|
{props.icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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,13 +11,14 @@ import {
|
|||||||
import { useChatStore } from "../store";
|
import { useChatStore } from "../store";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { Link, useLocation, 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 { showConfirm } from "./ui-lib";
|
||||||
import { useMobileScreen } from "../utils";
|
import { useMobileScreen } from "../utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export function ChatItem(props: {
|
export function ChatItem(props: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -46,11 +46,11 @@ export function ChatItem(props: {
|
|||||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
className={`${styles["chat-item"]} ${
|
className={clsx(styles["chat-item"], {
|
||||||
props.selected &&
|
[styles["chat-item-selected"]]:
|
||||||
(currentPath === Path.Chat || currentPath === Path.Home) &&
|
props.selected &&
|
||||||
styles["chat-item-selected"]
|
(currentPath === Path.Chat || currentPath === Path.Home),
|
||||||
}`}
|
})}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
ref={(ele) => {
|
ref={(ele) => {
|
||||||
draggableRef.current = ele;
|
draggableRef.current = ele;
|
||||||
@@ -64,7 +64,7 @@ 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={clsx(styles["chat-item-avatar"], "no-dark")}>
|
||||||
<MaskAvatar
|
<MaskAvatar
|
||||||
avatar={props.mask.avatar}
|
avatar={props.mask.avatar}
|
||||||
model={props.mask.modelConfig.model}
|
model={props.mask.modelConfig.model}
|
||||||
|
|||||||
@@ -45,6 +45,14 @@
|
|||||||
.chat-input-actions {
|
.chat-input-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
&-end {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-action {
|
.chat-input-action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -62,10 +70,6 @@
|
|||||||
width: var(--icon-width);
|
width: var(--icon-width);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
@@ -231,10 +235,12 @@
|
|||||||
|
|
||||||
animation: slide-in ease 0.3s;
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
$linear: linear-gradient(to right,
|
$linear: linear-gradient(
|
||||||
rgba(0, 0, 0, 0),
|
to right,
|
||||||
rgba(0, 0, 0, 1),
|
rgba(0, 0, 0, 0),
|
||||||
rgba(0, 0, 0, 0));
|
rgba(0, 0, 0, 1),
|
||||||
|
rgba(0, 0, 0, 0)
|
||||||
|
);
|
||||||
mask-image: $linear;
|
mask-image: $linear;
|
||||||
|
|
||||||
@mixin show {
|
@mixin show {
|
||||||
@@ -346,6 +352,12 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-model-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--black);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-container {
|
.chat-message-container {
|
||||||
@@ -367,7 +379,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-user>.chat-message-container {
|
.chat-message-user > .chat-message-container {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +419,21 @@
|
|||||||
margin-top: 5px;
|
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 {
|
.chat-message-item {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -422,6 +449,25 @@
|
|||||||
transition: all ease 0.3s;
|
transition: all ease 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-message-audio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
border: var(--border-in-light);
|
||||||
|
position: relative;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: text;
|
||||||
|
word-break: break-word;
|
||||||
|
box-sizing: border-box;
|
||||||
|
audio {
|
||||||
|
height: 30px; /* 调整高度 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message-item-image {
|
.chat-message-item-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -450,9 +496,8 @@
|
|||||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
$calc-image-width: calc(100vw/3*2/var(--image-count));
|
$calc-image-width: calc(100vw / 3 * 2 / var(--image-count));
|
||||||
|
|
||||||
.chat-message-item-image-multi {
|
.chat-message-item-image-multi {
|
||||||
width: $calc-image-width;
|
width: $calc-image-width;
|
||||||
@@ -460,13 +505,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-item-image {
|
.chat-message-item-image {
|
||||||
max-width: calc(100vw/3*2);
|
max-width: calc(100vw / 3 * 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 600px) {
|
@media screen and (min-width: 600px) {
|
||||||
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
$max-image-width: calc(
|
||||||
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
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 {
|
.chat-message-item-image-multi {
|
||||||
width: $image-width;
|
width: $image-width;
|
||||||
@@ -476,7 +526,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-item-image {
|
.chat-message-item-image {
|
||||||
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
max-width: calc(calc(1200px - var(--sidebar-width)) / 3 * 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +544,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-user>.chat-message-container>.chat-message-item {
|
.chat-message-user > .chat-message-container > .chat-message-item {
|
||||||
background-color: var(--second);
|
background-color: var(--second);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -605,7 +655,8 @@
|
|||||||
min-height: 68px;
|
min-height: 68px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input:focus {}
|
.chat-input:focus {
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-send {
|
.chat-input-send {
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
@@ -625,3 +676,78 @@
|
|||||||
bottom: 30px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
.chat-body-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.chat-side-panel {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--white);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 10;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
&-show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,9 @@ 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") ||
|
||||||
|
props.model?.startsWith("o1") ? (
|
||||||
<BlackBotIcon className="user-avatar" />
|
<BlackBotIcon className="user-avatar" />
|
||||||
) : (
|
) : (
|
||||||
<BotIcon className="user-avatar" />
|
<BotIcon className="user-avatar" />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"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";
|
||||||
@@ -6,6 +8,7 @@ import { ISSUE_URL } from "../constant";
|
|||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { useSyncStore } from "../store/sync";
|
import { useSyncStore } from "../store/sync";
|
||||||
|
import { useChatStore } from "../store/chat";
|
||||||
|
|
||||||
interface IErrorBoundaryState {
|
interface IErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -28,8 +31,7 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
|||||||
try {
|
try {
|
||||||
useSyncStore.getState().export();
|
useSyncStore.getState().export();
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.clear();
|
useChatStore.getState().clearAllData();
|
||||||
location.reload();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { ChatMessage, ModelType, 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 {
|
import {
|
||||||
@@ -40,6 +40,7 @@ import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
|||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import { type ClientApi, getClientApi } from "../client/api";
|
import { type ClientApi, getClientApi } from "../client/api";
|
||||||
import { getMessageTextContent } from "../utils";
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
@@ -118,9 +119,10 @@ function Steps<
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`${styles["step"]} ${
|
className={clsx("clickable", styles["step"], {
|
||||||
styles[i <= props.index ? "step-finished" : ""]
|
[styles["step-finished"]]: i <= props.index,
|
||||||
} ${i === props.index && styles["step-current"]} clickable`}
|
[styles["step-current"]]: i === props.index,
|
||||||
|
})}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onStepChange?.(i);
|
props.onStepChange?.(i);
|
||||||
}}
|
}}
|
||||||
@@ -525,11 +527,11 @@ export function ImagePreviewer(props: {
|
|||||||
messages={props.messages}
|
messages={props.messages}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`${styles["preview-body"]} ${styles["default-theme"]}`}
|
className={clsx(styles["preview-body"], styles["default-theme"])}
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
>
|
>
|
||||||
<div className={styles["chat-info"]}>
|
<div className={styles["chat-info"]}>
|
||||||
<div className={styles["logo"] + " no-dark"}>
|
<div className={clsx(styles["logo"], "no-dark")}>
|
||||||
<NextImage
|
<NextImage
|
||||||
src={ChatGptIcon.src}
|
src={ChatGptIcon.src}
|
||||||
alt="logo"
|
alt="logo"
|
||||||
@@ -541,7 +543,7 @@ export function ImagePreviewer(props: {
|
|||||||
<div>
|
<div>
|
||||||
<div className={styles["main-title"]}>NextChat</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} />
|
||||||
@@ -570,7 +572,7 @@ export function ImagePreviewer(props: {
|
|||||||
{props.messages.map((m, i) => {
|
{props.messages.map((m, i) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles["message"] + " " + styles["message-" + m.role]}
|
className={clsx(styles["message"], styles["message-" + m.role])}
|
||||||
key={i}
|
key={i}
|
||||||
>
|
>
|
||||||
<div className={styles["avatar"]}>
|
<div className={styles["avatar"]}>
|
||||||
@@ -583,6 +585,7 @@ export function ImagePreviewer(props: {
|
|||||||
<Markdown
|
<Markdown
|
||||||
content={getMessageTextContent(m)}
|
content={getMessageTextContent(m)}
|
||||||
fontSize={config.fontSize}
|
fontSize={config.fontSize}
|
||||||
|
fontFamily={config.fontFamily}
|
||||||
defaultShow
|
defaultShow
|
||||||
/>
|
/>
|
||||||
{getMessageImages(m).length == 1 && (
|
{getMessageImages(m).length == 1 && (
|
||||||
|
|||||||
@@ -137,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 {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
require("../polyfill");
|
require("../polyfill");
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import styles from "./home.module.scss";
|
import styles from "./home.module.scss";
|
||||||
|
|
||||||
import BotIcon from "../icons/bot.svg";
|
import BotIcon from "../icons/bot.svg";
|
||||||
@@ -19,8 +18,8 @@ import { getISOLang, getLang } from "../locales";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
HashRouter as Router,
|
HashRouter as Router,
|
||||||
Routes,
|
|
||||||
Route,
|
Route,
|
||||||
|
Routes,
|
||||||
useLocation,
|
useLocation,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { SideBar } from "./sidebar";
|
import { SideBar } from "./sidebar";
|
||||||
@@ -29,16 +28,22 @@ import { AuthPage } from "./auth";
|
|||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import { type ClientApi, getClientApi } from "../client/api";
|
import { type ClientApi, getClientApi } from "../client/api";
|
||||||
import { useAccessStore } from "../store";
|
import { useAccessStore } from "../store";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { initializeMcpSystem, isMcpEnabled } from "../mcp/actions";
|
||||||
|
|
||||||
export function Loading(props: { noLogo?: boolean }) {
|
export function Loading(props: { noLogo?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles["loading-content"] + " no-dark"}>
|
<div className={clsx("no-dark", styles["loading-content"])}>
|
||||||
{!props.noLogo && <BotIcon />}
|
{!props.noLogo && <BotIcon />}
|
||||||
<LoadingIcon />
|
<LoadingIcon />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 />,
|
||||||
});
|
});
|
||||||
@@ -55,6 +60,28 @@ 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 />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const McpMarketPage = dynamic(
|
||||||
|
async () => (await import("./mcp-market")).McpMarketPage,
|
||||||
|
{
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export function useSwitchTheme() {
|
export function useSwitchTheme() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
|
|
||||||
@@ -122,11 +149,23 @@ const loadAsyncGoogleFont = () => {
|
|||||||
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 =
|
const shouldTightBorder =
|
||||||
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
||||||
@@ -135,34 +174,48 @@ function Screen() {
|
|||||||
loadAsyncGoogleFont();
|
loadAsyncGoogleFont();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (isArtifact) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/artifacts/:id" element={<Artifacts />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isAuth) return <AuthPage />;
|
||||||
|
if (isSd) return <Sd />;
|
||||||
|
if (isSdNew) return <Sd />;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SideBar
|
||||||
|
className={clsx({
|
||||||
|
[styles["sidebar-show"]]: isHome,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<WindowContent>
|
||||||
|
<Routes>
|
||||||
|
<Route path={Path.Home} element={<Chat />} />
|
||||||
|
<Route path={Path.NewChat} element={<NewChat />} />
|
||||||
|
<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.Settings} element={<Settings />} />
|
||||||
|
<Route path={Path.McpMarket} element={<McpMarketPage />} />
|
||||||
|
</Routes>
|
||||||
|
</WindowContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={clsx(styles.container, {
|
||||||
styles.container +
|
[styles["tight-container"]]: shouldTightBorder,
|
||||||
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
|
[styles["rtl-screen"]]: getLang() === "ar",
|
||||||
getLang() === "ar" ? styles["rtl-screen"] : ""
|
})}
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isAuth ? (
|
{renderContent()}
|
||||||
<>
|
|
||||||
<AuthPage />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
|
||||||
|
|
||||||
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
|
||||||
<Routes>
|
|
||||||
<Route path={Path.Home} element={<Chat />} />
|
|
||||||
<Route path={Path.NewChat} element={<NewChat />} />
|
|
||||||
<Route path={Path.Masks} element={<MaskPage />} />
|
|
||||||
<Route path={Path.Chat} element={<Chat />} />
|
|
||||||
<Route path={Path.Settings} element={<Settings />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -189,6 +242,20 @@ export function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[Config] got config from build time", getClientConfig());
|
console.log("[Config] got config from build time", getClientConfig());
|
||||||
useAccessStore.getState().fetch();
|
useAccessStore.getState().fetch();
|
||||||
|
|
||||||
|
const initMcp = async () => {
|
||||||
|
try {
|
||||||
|
const enabled = await isMcpEnabled();
|
||||||
|
if (enabled) {
|
||||||
|
console.log("[MCP] initializing...");
|
||||||
|
await initializeMcpSystem();
|
||||||
|
console.log("[MCP] initialized");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[MCP] failed to initialize:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initMcp();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!useHasHydrated()) {
|
if (!useHasHydrated()) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styles from "./input-range.module.scss";
|
import styles from "./input-range.module.scss";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface InputRangeProps {
|
interface InputRangeProps {
|
||||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||||
@@ -9,6 +10,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 +21,13 @@ export function InputRange({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
|
aria,
|
||||||
}: InputRangeProps) {
|
}: InputRangeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
<div className={clsx(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}
|
||||||
|
|||||||
@@ -6,13 +6,24 @@ 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, useMemo } 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 } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { showImageModal } from "./ui-lib";
|
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";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export function Mermaid(props: { code: string }) {
|
export function Mermaid(props: { code: string }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -47,7 +58,7 @@ export function Mermaid(props: { code: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="no-dark mermaid"
|
className={clsx("no-dark", "mermaid")}
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
@@ -62,58 +73,159 @@ 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") ||
|
||||||
|
refText?.startsWith("<svg") ||
|
||||||
|
refText?.startsWith("<?xml")
|
||||||
|
) {
|
||||||
|
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 escapeDollarNumber(text: string) {
|
function CustomCode(props: { children: any; className?: string }) {
|
||||||
let escapedText = "";
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const enableCodeFold =
|
||||||
|
session.mask?.enableCodeFold !== false && config.enableCodeFold;
|
||||||
|
|
||||||
for (let i = 0; i < text.length; i += 1) {
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
let char = text[i];
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const nextChar = text[i + 1] || " ";
|
const [showToggle, setShowToggle] = useState(false);
|
||||||
|
|
||||||
if (char === "$" && nextChar >= "0" && nextChar <= "9") {
|
useEffect(() => {
|
||||||
char = "\\$";
|
if (ref.current) {
|
||||||
|
const codeHeight = ref.current.scrollHeight;
|
||||||
|
setShowToggle(codeHeight > 400);
|
||||||
|
ref.current.scrollTop = ref.current.scrollHeight;
|
||||||
}
|
}
|
||||||
|
}, [props.children]);
|
||||||
|
|
||||||
escapedText += char;
|
const toggleCollapsed = () => {
|
||||||
}
|
setCollapsed((collapsed) => !collapsed);
|
||||||
|
};
|
||||||
|
const renderShowMoreButton = () => {
|
||||||
|
if (showToggle && enableCodeFold && collapsed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("show-hide-button", {
|
||||||
|
collapsed,
|
||||||
|
expanded: !collapsed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<code
|
||||||
|
className={clsx(props?.className)}
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
maxHeight: enableCodeFold && collapsed ? "400px" : "none",
|
||||||
|
overflowY: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</code>
|
||||||
|
|
||||||
return escapedText;
|
{renderShowMoreButton()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeBrackets(text: string) {
|
function escapeBrackets(text: string) {
|
||||||
@@ -134,9 +246,30 @@ function escapeBrackets(text: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tryWrapHtmlCode(text: string) {
|
||||||
|
// try add wrap html code (fixed: html codeblock include 2 newline)
|
||||||
|
// ignore embed codeblock
|
||||||
|
if (text.includes("```")) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
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(() => {
|
const escapedContent = useMemo(() => {
|
||||||
return escapeBrackets(escapeDollarNumber(props.content));
|
return tryWrapHtmlCode(escapeBrackets(props.content));
|
||||||
}, [props.content]);
|
}, [props.content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,9 +287,24 @@ function _MarkDownContent(props: { content: string }) {
|
|||||||
]}
|
]}
|
||||||
components={{
|
components={{
|
||||||
pre: PreCode,
|
pre: PreCode,
|
||||||
|
code: CustomCode,
|
||||||
p: (pProps) => <p {...pProps} dir="auto" />,
|
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} />;
|
||||||
@@ -175,6 +323,7 @@ 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>,
|
||||||
@@ -186,6 +335,7 @@ export function Markdown(
|
|||||||
className="markdown-body"
|
className="markdown-body"
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${props.fontSize ?? 14}px`,
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
|
fontFamily: props.fontFamily || "inherit",
|
||||||
}}
|
}}
|
||||||
ref={mdRef}
|
ref={mdRef}
|
||||||
onContextMenu={props.onContextMenu}
|
onContextMenu={props.onContextMenu}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ 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 {
|
import {
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
downloadAs,
|
downloadAs,
|
||||||
@@ -48,7 +48,6 @@ 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 { nanoid } from "nanoid";
|
|
||||||
import {
|
import {
|
||||||
DragDropContext,
|
DragDropContext,
|
||||||
Droppable,
|
Droppable,
|
||||||
@@ -56,6 +55,7 @@ import {
|
|||||||
OnDragEndResponder,
|
OnDragEndResponder,
|
||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
import { getMessageTextContent } from "../utils";
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
// drag and drop helper function
|
// drag and drop helper function
|
||||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||||
@@ -127,6 +127,8 @@ 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" }}
|
||||||
>
|
>
|
||||||
@@ -139,6 +141,7 @@ export function MaskConfig(props: {
|
|||||||
</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) =>
|
||||||
@@ -153,6 +156,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) => {
|
||||||
@@ -163,12 +167,48 @@ 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 ? (
|
{!props.shouldSyncFromGlobal ? (
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Mask.Config.Share.Title}
|
title={Locale.Mask.Config.Share.Title}
|
||||||
subTitle={Locale.Mask.Config.Share.SubTitle}
|
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
aria={Locale.Mask.Config.Share.Title}
|
||||||
icon={<CopyIcon />}
|
icon={<CopyIcon />}
|
||||||
text={Locale.Mask.Config.Share.Action}
|
text={Locale.Mask.Config.Share.Action}
|
||||||
onClick={copyMaskLink}
|
onClick={copyMaskLink}
|
||||||
@@ -182,6 +222,7 @@ export function MaskConfig(props: {
|
|||||||
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={async (e) => {
|
onChange={async (e) => {
|
||||||
@@ -404,16 +445,7 @@ export function MaskPage() {
|
|||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
const [filterLang, setFilterLang] = useState<Lang | undefined>(
|
const filterLang = maskStore.language;
|
||||||
() => localStorage.getItem("Mask-language") as Lang | undefined,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (filterLang) {
|
|
||||||
localStorage.setItem("Mask-language", filterLang);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem("Mask-language");
|
|
||||||
}
|
|
||||||
}, [filterLang]);
|
|
||||||
|
|
||||||
const allMasks = maskStore
|
const allMasks = maskStore
|
||||||
.getAll()
|
.getAll()
|
||||||
@@ -520,9 +552,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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -557,7 +589,7 @@ export function MaskPage() {
|
|||||||
</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>
|
||||||
<div className={styles["mask-info"] + " one-line"}>
|
<div className={clsx(styles["mask-info"], "one-line")}>
|
||||||
{`${Locale.Mask.Item.Info(m.context.length)} / ${
|
{`${Locale.Mask.Item.Info(m.context.length)} / ${
|
||||||
ALL_LANG_OPTIONS[m.lang]
|
ALL_LANG_OPTIONS[m.lang]
|
||||||
} / ${m.modelConfig.model}`}
|
} / ${m.modelConfig.model}`}
|
||||||
|
|||||||
657
app/components/mcp-market.module.scss
Normal file
657
app/components/mcp-market.module.scss
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
@import "../styles/animation.scss";
|
||||||
|
|
||||||
|
.mcp-market-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: normal;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-market-page-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.loading-container,
|
||||||
|
.empty-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 200px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--white);
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.empty-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--black);
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-market-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-market-item {
|
||||||
|
padding: 20px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
background-color: var(--white);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
position: relative;
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
|
||||||
|
&[data-status="stopping"] {
|
||||||
|
background-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-status="starting"] {
|
||||||
|
background-color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-status="error"] {
|
||||||
|
background-color: #f87171;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-market-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.mcp-market-title {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 20px;
|
||||||
|
max-width: calc(100% - 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-market-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.server-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.stopped {
|
||||||
|
background-color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.initializing {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-link {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background: var(--gray);
|
||||||
|
color: var(--black);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-market-info {
|
||||||
|
color: var(--black);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-market-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
|
.array-input-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--gray-50);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--gray-100);
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: var(--white);
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--gray-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.icon-button.add-path-button) {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 4px;
|
||||||
|
filter: brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.path-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-button {
|
||||||
|
padding: 8px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--black-50);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
padding: 8px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--black-50);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--black);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.config-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.config-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--black);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
|
.array-input-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--gray-50);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--gray-100);
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: var(--white);
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--gray-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.icon-button) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--gray-100);
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.icon-button.add-path-button) {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 4px;
|
||||||
|
filter: brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-item {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--gray-300) !important;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.tool-item {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.tool-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--black);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
line-height: 1.6;
|
||||||
|
padding-left: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global {
|
||||||
|
.modal-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background-color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: var(--black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-sub-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-pulse {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
755
app/components/mcp-market.tsx
Normal file
755
app/components/mcp-market.tsx
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
import { IconButton } from "./button";
|
||||||
|
import { ErrorBoundary } from "./error";
|
||||||
|
import styles from "./mcp-market.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 RestartIcon from "../icons/reload.svg";
|
||||||
|
import EyeIcon from "../icons/eye.svg";
|
||||||
|
import GithubIcon from "../icons/github.svg";
|
||||||
|
import { List, ListItem, Modal, showToast } from "./ui-lib";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
addMcpServer,
|
||||||
|
getClientsStatus,
|
||||||
|
getClientTools,
|
||||||
|
getMcpConfigFromFile,
|
||||||
|
isMcpEnabled,
|
||||||
|
pauseMcpServer,
|
||||||
|
restartAllClients,
|
||||||
|
resumeMcpServer,
|
||||||
|
} from "../mcp/actions";
|
||||||
|
import {
|
||||||
|
ListToolsResponse,
|
||||||
|
McpConfigData,
|
||||||
|
PresetServer,
|
||||||
|
ServerConfig,
|
||||||
|
ServerStatusResponse,
|
||||||
|
} from "../mcp/types";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import PlayIcon from "../icons/play.svg";
|
||||||
|
import StopIcon from "../icons/pause.svg";
|
||||||
|
import { Path } from "../constant";
|
||||||
|
|
||||||
|
interface ConfigProperty {
|
||||||
|
type: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
minItems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpMarketPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [userConfig, setUserConfig] = useState<Record<string, any>>({});
|
||||||
|
const [editingServerId, setEditingServerId] = useState<string | undefined>();
|
||||||
|
const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null);
|
||||||
|
const [viewingServerId, setViewingServerId] = useState<string | undefined>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [config, setConfig] = useState<McpConfigData>();
|
||||||
|
const [clientStatuses, setClientStatuses] = useState<
|
||||||
|
Record<string, ServerStatusResponse>
|
||||||
|
>({});
|
||||||
|
const [loadingPresets, setLoadingPresets] = useState(true);
|
||||||
|
const [presetServers, setPresetServers] = useState<PresetServer[]>([]);
|
||||||
|
const [loadingStates, setLoadingStates] = useState<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查 MCP 是否启用
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMcpStatus = async () => {
|
||||||
|
const enabled = await isMcpEnabled();
|
||||||
|
setMcpEnabled(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
navigate(Path.Home);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkMcpStatus();
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// 添加状态轮询
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mcpEnabled || !config) return;
|
||||||
|
|
||||||
|
const updateStatuses = async () => {
|
||||||
|
const statuses = await getClientsStatus();
|
||||||
|
setClientStatuses(statuses);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即执行一次
|
||||||
|
updateStatuses();
|
||||||
|
// 每 1000ms 轮询一次
|
||||||
|
const timer = setInterval(updateStatuses, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [mcpEnabled, config]);
|
||||||
|
|
||||||
|
// 加载预设服务器
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPresetServers = async () => {
|
||||||
|
if (!mcpEnabled) return;
|
||||||
|
try {
|
||||||
|
setLoadingPresets(true);
|
||||||
|
const response = await fetch("https://nextchat.club/mcp/list");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load preset servers");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setPresetServers(data?.data ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load preset servers:", error);
|
||||||
|
showToast("Failed to load preset servers");
|
||||||
|
} finally {
|
||||||
|
setLoadingPresets(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadPresetServers();
|
||||||
|
}, [mcpEnabled]);
|
||||||
|
|
||||||
|
// 加载初始状态
|
||||||
|
useEffect(() => {
|
||||||
|
const loadInitialState = async () => {
|
||||||
|
if (!mcpEnabled) return;
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const config = await getMcpConfigFromFile();
|
||||||
|
setConfig(config);
|
||||||
|
|
||||||
|
// 获取所有客户端的状态
|
||||||
|
const statuses = await getClientsStatus();
|
||||||
|
setClientStatuses(statuses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load initial state:", error);
|
||||||
|
showToast("Failed to load initial state");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadInitialState();
|
||||||
|
}, [mcpEnabled]);
|
||||||
|
|
||||||
|
// 加载当前编辑服务器的配置
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingServerId || !config) return;
|
||||||
|
const currentConfig = config.mcpServers[editingServerId];
|
||||||
|
if (currentConfig) {
|
||||||
|
// 从当前配置中提取用户配置
|
||||||
|
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||||
|
if (preset?.configSchema) {
|
||||||
|
const userConfig: Record<string, any> = {};
|
||||||
|
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
||||||
|
if (mapping.type === "spread") {
|
||||||
|
// For spread types, extract the array from args.
|
||||||
|
const startPos = mapping.position ?? 0;
|
||||||
|
userConfig[key] = currentConfig.args.slice(startPos);
|
||||||
|
} else if (mapping.type === "single") {
|
||||||
|
// For single types, get a single value
|
||||||
|
userConfig[key] = currentConfig.args[mapping.position ?? 0];
|
||||||
|
} else if (
|
||||||
|
mapping.type === "env" &&
|
||||||
|
mapping.key &&
|
||||||
|
currentConfig.env
|
||||||
|
) {
|
||||||
|
// For env types, get values from environment variables
|
||||||
|
userConfig[key] = currentConfig.env[mapping.key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setUserConfig(userConfig);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserConfig({});
|
||||||
|
}
|
||||||
|
}, [editingServerId, config, presetServers]);
|
||||||
|
|
||||||
|
if (!mcpEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查服务器是否已添加
|
||||||
|
const isServerAdded = (id: string) => {
|
||||||
|
return id in (config?.mcpServers ?? {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存服务器配置
|
||||||
|
const saveServerConfig = async () => {
|
||||||
|
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||||
|
if (!preset || !preset.configSchema || !editingServerId) return;
|
||||||
|
|
||||||
|
const savingServerId = editingServerId;
|
||||||
|
setEditingServerId(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState(savingServerId, "Updating configuration...");
|
||||||
|
// 构建服务器配置
|
||||||
|
const args = [...preset.baseArgs];
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
|
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
||||||
|
const value = userConfig[key];
|
||||||
|
if (mapping.type === "spread" && Array.isArray(value)) {
|
||||||
|
const pos = mapping.position ?? 0;
|
||||||
|
args.splice(pos, 0, ...value);
|
||||||
|
} else if (
|
||||||
|
mapping.type === "single" &&
|
||||||
|
mapping.position !== undefined
|
||||||
|
) {
|
||||||
|
args[mapping.position] = value;
|
||||||
|
} else if (
|
||||||
|
mapping.type === "env" &&
|
||||||
|
mapping.key &&
|
||||||
|
typeof value === "string"
|
||||||
|
) {
|
||||||
|
env[mapping.key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverConfig: ServerConfig = {
|
||||||
|
command: preset.command,
|
||||||
|
args,
|
||||||
|
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newConfig = await addMcpServer(savingServerId, serverConfig);
|
||||||
|
setConfig(newConfig);
|
||||||
|
showToast("Server configuration updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(
|
||||||
|
error instanceof Error ? error.message : "Failed to save configuration",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState(savingServerId, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取服务器支持的 Tools
|
||||||
|
const loadTools = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const result = await getClientTools(id);
|
||||||
|
if (result) {
|
||||||
|
setTools(result);
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to load tools");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast("Failed to load tools");
|
||||||
|
console.error(error);
|
||||||
|
setTools(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新加载状态的辅助函数
|
||||||
|
const updateLoadingState = (id: string, message: string | null) => {
|
||||||
|
setLoadingStates((prev) => {
|
||||||
|
if (message === null) {
|
||||||
|
const { [id]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return { ...prev, [id]: message };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 修改添加服务器函数
|
||||||
|
const addServer = async (preset: PresetServer) => {
|
||||||
|
if (!preset.configurable) {
|
||||||
|
try {
|
||||||
|
const serverId = preset.id;
|
||||||
|
updateLoadingState(serverId, "Creating MCP client...");
|
||||||
|
|
||||||
|
const serverConfig: ServerConfig = {
|
||||||
|
command: preset.command,
|
||||||
|
args: [...preset.baseArgs],
|
||||||
|
};
|
||||||
|
const newConfig = await addMcpServer(preset.id, serverConfig);
|
||||||
|
setConfig(newConfig);
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
const statuses = await getClientsStatus();
|
||||||
|
setClientStatuses(statuses);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState(preset.id, null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果需要配置,打开配置对话框
|
||||||
|
setEditingServerId(preset.id);
|
||||||
|
setUserConfig({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 修改暂停服务器函数
|
||||||
|
const pauseServer = async (id: string) => {
|
||||||
|
try {
|
||||||
|
updateLoadingState(id, "Stopping server...");
|
||||||
|
const newConfig = await pauseMcpServer(id);
|
||||||
|
setConfig(newConfig);
|
||||||
|
showToast("Server stopped successfully");
|
||||||
|
} catch (error) {
|
||||||
|
showToast("Failed to stop server");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState(id, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restart server
|
||||||
|
const restartServer = async (id: string) => {
|
||||||
|
try {
|
||||||
|
updateLoadingState(id, "Starting server...");
|
||||||
|
await resumeMcpServer(id);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to start server, please check logs",
|
||||||
|
);
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState(id, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restart all clients
|
||||||
|
const handleRestartAll = async () => {
|
||||||
|
try {
|
||||||
|
updateLoadingState("all", "Restarting all servers...");
|
||||||
|
const newConfig = await restartAllClients();
|
||||||
|
setConfig(newConfig);
|
||||||
|
showToast("Restarting all clients");
|
||||||
|
} catch (error) {
|
||||||
|
showToast("Failed to restart clients");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState("all", null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render configuration form
|
||||||
|
const renderConfigForm = () => {
|
||||||
|
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||||
|
if (!preset?.configSchema) return null;
|
||||||
|
|
||||||
|
return Object.entries(preset.configSchema.properties).map(
|
||||||
|
([key, prop]: [string, ConfigProperty]) => {
|
||||||
|
if (prop.type === "array") {
|
||||||
|
const currentValue = userConfig[key as keyof typeof userConfig] || [];
|
||||||
|
const itemLabel = (prop as any).itemLabel || key;
|
||||||
|
const addButtonText =
|
||||||
|
(prop as any).addButtonText || `Add ${itemLabel}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={key}
|
||||||
|
title={key}
|
||||||
|
subTitle={prop.description}
|
||||||
|
vertical
|
||||||
|
>
|
||||||
|
<div className={styles["path-list"]}>
|
||||||
|
{(currentValue as string[]).map(
|
||||||
|
(value: string, index: number) => (
|
||||||
|
<div key={index} className={styles["path-item"]}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder={`${itemLabel} ${index + 1}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = [...currentValue] as string[];
|
||||||
|
newValue[index] = e.target.value;
|
||||||
|
setUserConfig({ ...userConfig, [key]: newValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
className={styles["delete-button"]}
|
||||||
|
onClick={() => {
|
||||||
|
const newValue = [...currentValue] as string[];
|
||||||
|
newValue.splice(index, 1);
|
||||||
|
setUserConfig({ ...userConfig, [key]: newValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon={<AddIcon />}
|
||||||
|
text={addButtonText}
|
||||||
|
className={styles["add-button"]}
|
||||||
|
bordered
|
||||||
|
onClick={() => {
|
||||||
|
const newValue = [...currentValue, ""] as string[];
|
||||||
|
setUserConfig({ ...userConfig, [key]: newValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
} else if (prop.type === "string") {
|
||||||
|
const currentValue = userConfig[key as keyof typeof userConfig] || "";
|
||||||
|
return (
|
||||||
|
<ListItem key={key} title={key} subTitle={prop.description}>
|
||||||
|
<input
|
||||||
|
aria-label={key}
|
||||||
|
type="text"
|
||||||
|
value={currentValue}
|
||||||
|
placeholder={`Enter ${key}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUserConfig({ ...userConfig, [key]: e.target.value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkServerStatus = (clientId: string) => {
|
||||||
|
return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServerStatusDisplay = (clientId: string) => {
|
||||||
|
const status = checkServerStatus(clientId);
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
undefined: null, // 未配置/未找到不显示
|
||||||
|
// 添加初始化状态
|
||||||
|
initializing: (
|
||||||
|
<span className={clsx(styles["server-status"], styles["initializing"])}>
|
||||||
|
Initializing
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
paused: (
|
||||||
|
<span className={clsx(styles["server-status"], styles["stopped"])}>
|
||||||
|
Stopped
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
active: <span className={styles["server-status"]}>Running</span>,
|
||||||
|
error: (
|
||||||
|
<span className={clsx(styles["server-status"], styles["error"])}>
|
||||||
|
Error
|
||||||
|
<span className={styles["error-message"]}>: {status.errorMsg}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return statusMap[status.status];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the type of operation status
|
||||||
|
const getOperationStatusType = (message: string) => {
|
||||||
|
if (message.toLowerCase().includes("stopping")) return "stopping";
|
||||||
|
if (message.toLowerCase().includes("starting")) return "starting";
|
||||||
|
if (message.toLowerCase().includes("error")) return "error";
|
||||||
|
return "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染服务器列表
|
||||||
|
const renderServerList = () => {
|
||||||
|
if (loadingPresets) {
|
||||||
|
return (
|
||||||
|
<div className={styles["loading-container"]}>
|
||||||
|
<div className={styles["loading-text"]}>
|
||||||
|
Loading preset server list...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(presetServers) || presetServers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles["empty-container"]}>
|
||||||
|
<div className={styles["empty-text"]}>No servers available</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return presetServers
|
||||||
|
.filter((server) => {
|
||||||
|
if (searchText.length === 0) return true;
|
||||||
|
const searchLower = searchText.toLowerCase();
|
||||||
|
return (
|
||||||
|
server.name.toLowerCase().includes(searchLower) ||
|
||||||
|
server.description.toLowerCase().includes(searchLower) ||
|
||||||
|
server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aStatus = checkServerStatus(a.id).status;
|
||||||
|
const bStatus = checkServerStatus(b.id).status;
|
||||||
|
const aLoading = loadingStates[a.id];
|
||||||
|
const bLoading = loadingStates[b.id];
|
||||||
|
|
||||||
|
// 定义状态优先级
|
||||||
|
const statusPriority: Record<string, number> = {
|
||||||
|
error: 0, // Highest priority for error status
|
||||||
|
active: 1, // Second for active
|
||||||
|
initializing: 2, // Initializing
|
||||||
|
starting: 3, // Starting
|
||||||
|
stopping: 4, // Stopping
|
||||||
|
paused: 5, // Paused
|
||||||
|
undefined: 6, // Lowest priority for undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get actual status (including loading status)
|
||||||
|
const getEffectiveStatus = (status: string, loading?: string) => {
|
||||||
|
if (loading) {
|
||||||
|
const operationType = getOperationStatusType(loading);
|
||||||
|
return operationType === "default" ? status : operationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "initializing" && !loading) {
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const aEffectiveStatus = getEffectiveStatus(aStatus, aLoading);
|
||||||
|
const bEffectiveStatus = getEffectiveStatus(bStatus, bLoading);
|
||||||
|
|
||||||
|
// 首先按状态排序
|
||||||
|
if (aEffectiveStatus !== bEffectiveStatus) {
|
||||||
|
return (
|
||||||
|
(statusPriority[aEffectiveStatus] ?? 6) -
|
||||||
|
(statusPriority[bEffectiveStatus] ?? 6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name when statuses are the same
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.map((server) => (
|
||||||
|
<div
|
||||||
|
className={clsx(styles["mcp-market-item"], {
|
||||||
|
[styles["loading"]]: loadingStates[server.id],
|
||||||
|
})}
|
||||||
|
key={server.id}
|
||||||
|
>
|
||||||
|
<div className={styles["mcp-market-header"]}>
|
||||||
|
<div className={styles["mcp-market-title"]}>
|
||||||
|
<div className={styles["mcp-market-name"]}>
|
||||||
|
{server.name}
|
||||||
|
{loadingStates[server.id] && (
|
||||||
|
<span
|
||||||
|
className={styles["operation-status"]}
|
||||||
|
data-status={getOperationStatusType(
|
||||||
|
loadingStates[server.id],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loadingStates[server.id]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!loadingStates[server.id] && getServerStatusDisplay(server.id)}
|
||||||
|
{server.repo && (
|
||||||
|
<a
|
||||||
|
href={server.repo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles["repo-link"]}
|
||||||
|
title="Open repository"
|
||||||
|
>
|
||||||
|
<GithubIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles["tags-container"]}>
|
||||||
|
{server.tags.map((tag, index) => (
|
||||||
|
<span key={index} className={styles["tag"]}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(styles["mcp-market-info"], "one-line")}
|
||||||
|
title={server.description}
|
||||||
|
>
|
||||||
|
{server.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles["mcp-market-actions"]}>
|
||||||
|
{isServerAdded(server.id) ? (
|
||||||
|
<>
|
||||||
|
{server.configurable && (
|
||||||
|
<IconButton
|
||||||
|
icon={<EditIcon />}
|
||||||
|
text="Configure"
|
||||||
|
onClick={() => setEditingServerId(server.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{checkServerStatus(server.id).status === "paused" ? (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
icon={<PlayIcon />}
|
||||||
|
text="Start"
|
||||||
|
onClick={() => restartServer(server.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{/* <IconButton
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
text="Remove"
|
||||||
|
onClick={() => removeServer(server.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/> */}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
icon={<EyeIcon />}
|
||||||
|
text="Tools"
|
||||||
|
onClick={async () => {
|
||||||
|
setViewingServerId(server.id);
|
||||||
|
await loadTools(server.id);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
checkServerStatus(server.id).status === "error"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<StopIcon />}
|
||||||
|
text="Stop"
|
||||||
|
onClick={() => pauseServer(server.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon={<AddIcon />}
|
||||||
|
text="Add"
|
||||||
|
onClick={() => addServer(server)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className={styles["mcp-market-page"]}>
|
||||||
|
<div className="window-header">
|
||||||
|
<div className="window-header-title">
|
||||||
|
<div className="window-header-main-title">
|
||||||
|
MCP Market
|
||||||
|
{loadingStates["all"] && (
|
||||||
|
<span className={styles["loading-indicator"]}>
|
||||||
|
{loadingStates["all"]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="window-header-sub-title">
|
||||||
|
{Object.keys(config?.mcpServers ?? {}).length} servers configured
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="window-actions">
|
||||||
|
<div className="window-action-button">
|
||||||
|
<IconButton
|
||||||
|
icon={<RestartIcon />}
|
||||||
|
bordered
|
||||||
|
onClick={handleRestartAll}
|
||||||
|
text="Restart All"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="window-action-button">
|
||||||
|
<IconButton
|
||||||
|
icon={<CloseIcon />}
|
||||||
|
bordered
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["mcp-market-page-body"]}>
|
||||||
|
<div className={styles["mcp-market-filter"]}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles["search-bar"]}
|
||||||
|
placeholder={"Search MCP Server"}
|
||||||
|
autoFocus
|
||||||
|
onInput={(e) => setSearchText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["server-list"]}>{renderServerList()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*编辑服务器配置*/}
|
||||||
|
{editingServerId && (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={`Configure Server - ${editingServerId}`}
|
||||||
|
onClose={() => !isLoading && setEditingServerId(undefined)}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="cancel"
|
||||||
|
text="Cancel"
|
||||||
|
onClick={() => setEditingServerId(undefined)}
|
||||||
|
bordered
|
||||||
|
disabled={isLoading}
|
||||||
|
/>,
|
||||||
|
<IconButton
|
||||||
|
key="confirm"
|
||||||
|
text="Save"
|
||||||
|
type="primary"
|
||||||
|
onClick={saveServerConfig}
|
||||||
|
bordered
|
||||||
|
disabled={isLoading}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List>{renderConfigForm()}</List>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewingServerId && (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={`Server Details - ${viewingServerId}`}
|
||||||
|
onClose={() => setViewingServerId(undefined)}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="close"
|
||||||
|
text="Close"
|
||||||
|
onClick={() => setViewingServerId(undefined)}
|
||||||
|
bordered
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className={styles["tools-list"]}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading...</div>
|
||||||
|
) : tools?.tools ? (
|
||||||
|
tools.tools.map(
|
||||||
|
(tool: ListToolsResponse["tools"], index: number) => (
|
||||||
|
<div key={index} className={styles["tool-item"]}>
|
||||||
|
<div className={styles["tool-name"]}>{tool.name}</div>
|
||||||
|
<div className={styles["tool-description"]}>
|
||||||
|
{tool.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div>No tools available</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import Locale from "../locales";
|
|||||||
|
|
||||||
import styles from "./message-selector.module.scss";
|
import styles from "./message-selector.module.scss";
|
||||||
import { getMessageTextContent } from "../utils";
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
function useShiftRange() {
|
function useShiftRange() {
|
||||||
const [startIndex, setStartIndex] = useState<number>();
|
const [startIndex, setStartIndex] = useState<number>();
|
||||||
@@ -71,6 +72,7 @@ export function MessageSelector(props: {
|
|||||||
defaultSelectAll?: boolean;
|
defaultSelectAll?: boolean;
|
||||||
onSelected?: (messages: ChatMessage[]) => void;
|
onSelected?: (messages: ChatMessage[]) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const LATEST_COUNT = 4;
|
||||||
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;
|
||||||
@@ -141,15 +143,13 @@ export function MessageSelector(props: {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [startIndex, endIndex]);
|
}, [startIndex, endIndex]);
|
||||||
|
|
||||||
const LATEST_COUNT = 4;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["message-selector"]}>
|
<div className={styles["message-selector"]}>
|
||||||
<div className={styles["message-filter"]}>
|
<div className={styles["message-filter"]}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={Locale.Select.Search}
|
placeholder={Locale.Select.Search}
|
||||||
className={styles["filter-item"] + " " + styles["search-bar"]}
|
className={clsx(styles["filter-item"], styles["search-bar"])}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
setSearchInput(e.currentTarget.value);
|
setSearchInput(e.currentTarget.value);
|
||||||
@@ -196,9 +196,9 @@ export function MessageSelector(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles["message"]} ${
|
className={clsx(styles["message"], {
|
||||||
props.selection.has(m.id!) && styles["message-selected"]
|
[styles["message-selected"]]: props.selection.has(m.id!),
|
||||||
}`}
|
})}
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.updateSelection((selection) => {
|
props.updateSelection((selection) => {
|
||||||
@@ -221,7 +221,7 @@ export function MessageSelector(props: {
|
|||||||
<div className={styles["date"]}>
|
<div className={styles["date"]}>
|
||||||
{new Date(m.date).toLocaleString()}
|
{new Date(m.date).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className={`${styles["content"]} one-line`}>
|
<div className={clsx(styles["content"], "one-line")}>
|
||||||
{getMessageTextContent(m)}
|
{getMessageTextContent(m)}
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,34 +5,48 @@ import Locale from "../locales";
|
|||||||
import { InputRange } from "./input-range";
|
import { InputRange } from "./input-range";
|
||||||
import { ListItem, Select } from "./ui-lib";
|
import { ListItem, Select } from "./ui-lib";
|
||||||
import { useAllModels } from "../utils/hooks";
|
import { useAllModels } from "../utils/hooks";
|
||||||
|
import { groupBy } from "lodash-es";
|
||||||
|
import styles from "./model-config.module.scss";
|
||||||
|
import { getModelProvider } from "../utils/model";
|
||||||
|
|
||||||
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 allModels = useAllModels();
|
||||||
|
const groupModels = groupBy(
|
||||||
|
allModels.filter((v) => v.available),
|
||||||
|
"provider.providerName",
|
||||||
|
);
|
||||||
const value = `${props.modelConfig.model}@${props.modelConfig?.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
|
||||||
|
aria-label={Locale.Settings.Model}
|
||||||
value={value}
|
value={value}
|
||||||
|
align="left"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const [model, providerName] = e.currentTarget.value.split("@");
|
const [model, providerName] = getModelProvider(
|
||||||
|
e.currentTarget.value,
|
||||||
|
);
|
||||||
props.updateConfig((config) => {
|
props.updateConfig((config) => {
|
||||||
config.model = ModalConfigValidator.model(model);
|
config.model = ModalConfigValidator.model(model);
|
||||||
config.providerName = providerName as ServiceProvider;
|
config.providerName = providerName as ServiceProvider;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{allModels
|
{Object.keys(groupModels).map((providerName, index) => (
|
||||||
.filter((v) => v.available)
|
<optgroup label={providerName} key={index}>
|
||||||
.map((v, i) => (
|
{groupModels[providerName].map((v, i) => (
|
||||||
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
||||||
{v.displayName}({v.provider?.providerName})
|
{v.displayName}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -40,6 +54,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
|
||||||
@@ -59,6 +74,7 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.TopP.SubTitle}
|
subTitle={Locale.Settings.TopP.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
|
aria={Locale.Settings.TopP.Title}
|
||||||
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
@@ -78,6 +94,7 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Settings.MaxTokens.Title}
|
||||||
type="number"
|
type="number"
|
||||||
min={1024}
|
min={1024}
|
||||||
max={512000}
|
max={512000}
|
||||||
@@ -100,6 +117,7 @@ export function ModelConfigList(props: {
|
|||||||
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"
|
||||||
@@ -121,6 +139,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"
|
||||||
@@ -142,6 +161,7 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Settings.InjectSystemPrompts.Title}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.modelConfig.enableInjectSystemPrompts}
|
checked={props.modelConfig.enableInjectSystemPrompts}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -159,6 +179,7 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
aria-label={Locale.Settings.InputTemplate.Title}
|
||||||
type="text"
|
type="text"
|
||||||
value={props.modelConfig.template}
|
value={props.modelConfig.template}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -175,6 +196,7 @@ export function ModelConfigList(props: {
|
|||||||
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"
|
||||||
@@ -193,6 +215,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}
|
||||||
@@ -208,6 +231,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) =>
|
||||||
@@ -217,6 +241,33 @@ 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] = getModelProvider(
|
||||||
|
e.currentTarget.value,
|
||||||
|
);
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { MaskAvatar } from "./mask";
|
|||||||
import { useCommand } from "../command";
|
import { useCommand } from "../command";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { BUILTIN_MASK_STORE } from "../masks";
|
import { BUILTIN_MASK_STORE } from "../masks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +25,9 @@ function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
|||||||
avatar={props.mask.avatar}
|
avatar={props.mask.avatar}
|
||||||
model={props.mask.modelConfig.model}
|
model={props.mask.modelConfig.model}
|
||||||
/>
|
/>
|
||||||
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
<div className={clsx(styles["mask-name"], "one-line")}>
|
||||||
|
{props.mask.name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
370
app/components/plugin.tsx
Normal file
370
app/components/plugin.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
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";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
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={clsx(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={clsx(
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
app/components/realtime-chat/index.ts
Normal file
1
app/components/realtime-chat/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./realtime-chat";
|
||||||
74
app/components/realtime-chat/realtime-chat.module.scss
Normal file
74
app/components/realtime-chat/realtime-chat.module.scss
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
.realtime-chat {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
.circle-mic {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(to bottom right, #a0d8ef, #f0f8ff);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.icon-center {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-icons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-left,
|
||||||
|
.icon-right {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
font-size: 36px;
|
||||||
|
background: var(--second);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
359
app/components/realtime-chat/realtime-chat.tsx
Normal file
359
app/components/realtime-chat/realtime-chat.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import VoiceIcon from "@/app/icons/voice.svg";
|
||||||
|
import VoiceOffIcon from "@/app/icons/voice-off.svg";
|
||||||
|
import PowerIcon from "@/app/icons/power.svg";
|
||||||
|
|
||||||
|
import styles from "./realtime-chat.module.scss";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
import { useChatStore, createMessage, useAppConfig } from "@/app/store";
|
||||||
|
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Modality,
|
||||||
|
RTClient,
|
||||||
|
RTInputAudioItem,
|
||||||
|
RTResponse,
|
||||||
|
TurnDetection,
|
||||||
|
} from "rt-client";
|
||||||
|
import { AudioHandler } from "@/app/lib/audio";
|
||||||
|
import { uploadImage } from "@/app/utils/chat";
|
||||||
|
import { VoicePrint } from "@/app/components/voice-print";
|
||||||
|
|
||||||
|
interface RealtimeChatProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
onStartVoice?: () => void;
|
||||||
|
onPausedVoice?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RealtimeChat({
|
||||||
|
onClose,
|
||||||
|
onStartVoice,
|
||||||
|
onPausedVoice,
|
||||||
|
}: RealtimeChatProps) {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [modality, setModality] = useState("audio");
|
||||||
|
const [useVAD, setUseVAD] = useState(true);
|
||||||
|
const [frequencies, setFrequencies] = useState<Uint8Array | undefined>();
|
||||||
|
|
||||||
|
const clientRef = useRef<RTClient | null>(null);
|
||||||
|
const audioHandlerRef = useRef<AudioHandler | null>(null);
|
||||||
|
const initRef = useRef(false);
|
||||||
|
|
||||||
|
const temperature = config.realtimeConfig.temperature;
|
||||||
|
const apiKey = config.realtimeConfig.apiKey;
|
||||||
|
const model = config.realtimeConfig.model;
|
||||||
|
const azure = config.realtimeConfig.provider === "Azure";
|
||||||
|
const azureEndpoint = config.realtimeConfig.azure.endpoint;
|
||||||
|
const azureDeployment = config.realtimeConfig.azure.deployment;
|
||||||
|
const voice = config.realtimeConfig.voice;
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (isConnecting) return;
|
||||||
|
if (!isConnected) {
|
||||||
|
try {
|
||||||
|
setIsConnecting(true);
|
||||||
|
clientRef.current = azure
|
||||||
|
? new RTClient(
|
||||||
|
new URL(azureEndpoint),
|
||||||
|
{ key: apiKey },
|
||||||
|
{ deployment: azureDeployment },
|
||||||
|
)
|
||||||
|
: new RTClient({ key: apiKey }, { model });
|
||||||
|
const modalities: Modality[] =
|
||||||
|
modality === "audio" ? ["text", "audio"] : ["text"];
|
||||||
|
const turnDetection: TurnDetection = useVAD
|
||||||
|
? { type: "server_vad" }
|
||||||
|
: null;
|
||||||
|
await clientRef.current.configure({
|
||||||
|
instructions: "",
|
||||||
|
voice,
|
||||||
|
input_audio_transcription: { model: "whisper-1" },
|
||||||
|
turn_detection: turnDetection,
|
||||||
|
tools: [],
|
||||||
|
temperature,
|
||||||
|
modalities,
|
||||||
|
});
|
||||||
|
startResponseListener();
|
||||||
|
|
||||||
|
setIsConnected(true);
|
||||||
|
// TODO
|
||||||
|
// try {
|
||||||
|
// const recentMessages = chatStore.getMessagesWithMemory();
|
||||||
|
// for (const message of recentMessages) {
|
||||||
|
// const { role, content } = message;
|
||||||
|
// if (typeof content === "string") {
|
||||||
|
// await clientRef.current.sendItem({
|
||||||
|
// type: "message",
|
||||||
|
// role: role as any,
|
||||||
|
// content: [
|
||||||
|
// {
|
||||||
|
// type: (role === "assistant" ? "text" : "input_text") as any,
|
||||||
|
// text: content as string,
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// // await clientRef.current.generateResponse();
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("Set message failed:", error);
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Connection failed:", error);
|
||||||
|
setStatus("Connection failed");
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
if (clientRef.current) {
|
||||||
|
try {
|
||||||
|
await clientRef.current.close();
|
||||||
|
clientRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Disconnect failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startResponseListener = async () => {
|
||||||
|
if (!clientRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const serverEvent of clientRef.current.events()) {
|
||||||
|
if (serverEvent.type === "response") {
|
||||||
|
await handleResponse(serverEvent);
|
||||||
|
} else if (serverEvent.type === "input_audio") {
|
||||||
|
await handleInputAudio(serverEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (clientRef.current) {
|
||||||
|
console.error("Response iteration error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResponse = async (response: RTResponse) => {
|
||||||
|
for await (const item of response) {
|
||||||
|
if (item.type === "message" && item.role === "assistant") {
|
||||||
|
const botMessage = createMessage({
|
||||||
|
role: item.role,
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
// add bot message first
|
||||||
|
chatStore.updateTargetSession(session, (session) => {
|
||||||
|
session.messages = session.messages.concat([botMessage]);
|
||||||
|
});
|
||||||
|
let hasAudio = false;
|
||||||
|
for await (const content of item) {
|
||||||
|
if (content.type === "text") {
|
||||||
|
for await (const text of content.textChunks()) {
|
||||||
|
botMessage.content += text;
|
||||||
|
}
|
||||||
|
} else if (content.type === "audio") {
|
||||||
|
const textTask = async () => {
|
||||||
|
for await (const text of content.transcriptChunks()) {
|
||||||
|
botMessage.content += text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const audioTask = async () => {
|
||||||
|
audioHandlerRef.current?.startStreamingPlayback();
|
||||||
|
for await (const audio of content.audioChunks()) {
|
||||||
|
hasAudio = true;
|
||||||
|
audioHandlerRef.current?.playChunk(audio);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all([textTask(), audioTask()]);
|
||||||
|
}
|
||||||
|
// update message.content
|
||||||
|
chatStore.updateTargetSession(session, (session) => {
|
||||||
|
session.messages = session.messages.concat();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hasAudio) {
|
||||||
|
// upload audio get audio_url
|
||||||
|
const blob = audioHandlerRef.current?.savePlayFile();
|
||||||
|
uploadImage(blob!).then((audio_url) => {
|
||||||
|
botMessage.audio_url = audio_url;
|
||||||
|
// update text and audio_url
|
||||||
|
chatStore.updateTargetSession(session, (session) => {
|
||||||
|
session.messages = session.messages.concat();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputAudio = async (item: RTInputAudioItem) => {
|
||||||
|
await item.waitForCompletion();
|
||||||
|
if (item.transcription) {
|
||||||
|
const userMessage = createMessage({
|
||||||
|
role: "user",
|
||||||
|
content: item.transcription,
|
||||||
|
});
|
||||||
|
chatStore.updateTargetSession(session, (session) => {
|
||||||
|
session.messages = session.messages.concat([userMessage]);
|
||||||
|
});
|
||||||
|
// save input audio_url, and update session
|
||||||
|
const { audioStartMillis, audioEndMillis } = item;
|
||||||
|
// upload audio get audio_url
|
||||||
|
const blob = audioHandlerRef.current?.saveRecordFile(
|
||||||
|
audioStartMillis,
|
||||||
|
audioEndMillis,
|
||||||
|
);
|
||||||
|
uploadImage(blob!).then((audio_url) => {
|
||||||
|
userMessage.audio_url = audio_url;
|
||||||
|
chatStore.updateTargetSession(session, (session) => {
|
||||||
|
session.messages = session.messages.concat();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// stop streaming play after get input audio.
|
||||||
|
audioHandlerRef.current?.stopStreamingPlayback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRecording = async () => {
|
||||||
|
if (!isRecording && clientRef.current) {
|
||||||
|
try {
|
||||||
|
if (!audioHandlerRef.current) {
|
||||||
|
audioHandlerRef.current = new AudioHandler();
|
||||||
|
await audioHandlerRef.current.initialize();
|
||||||
|
}
|
||||||
|
await audioHandlerRef.current.startRecording(async (chunk) => {
|
||||||
|
await clientRef.current?.sendAudio(chunk);
|
||||||
|
});
|
||||||
|
setIsRecording(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recording:", error);
|
||||||
|
}
|
||||||
|
} else if (audioHandlerRef.current) {
|
||||||
|
try {
|
||||||
|
audioHandlerRef.current.stopRecording();
|
||||||
|
if (!useVAD) {
|
||||||
|
const inputAudio = await clientRef.current?.commitAudio();
|
||||||
|
await handleInputAudio(inputAudio!);
|
||||||
|
await clientRef.current?.generateResponse();
|
||||||
|
}
|
||||||
|
setIsRecording(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to stop recording:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 防止重复初始化
|
||||||
|
if (initRef.current) return;
|
||||||
|
initRef.current = true;
|
||||||
|
|
||||||
|
const initAudioHandler = async () => {
|
||||||
|
const handler = new AudioHandler();
|
||||||
|
await handler.initialize();
|
||||||
|
audioHandlerRef.current = handler;
|
||||||
|
await handleConnect();
|
||||||
|
await toggleRecording();
|
||||||
|
};
|
||||||
|
|
||||||
|
initAudioHandler().catch((error) => {
|
||||||
|
setStatus(error);
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (isRecording) {
|
||||||
|
toggleRecording();
|
||||||
|
}
|
||||||
|
audioHandlerRef.current?.close().catch(console.error);
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
if (isConnected && isRecording) {
|
||||||
|
const animationFrame = () => {
|
||||||
|
if (audioHandlerRef.current) {
|
||||||
|
const freqData = audioHandlerRef.current.getByteFrequencyData();
|
||||||
|
setFrequencies(freqData);
|
||||||
|
}
|
||||||
|
animationFrameId = requestAnimationFrame(animationFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animationFrame);
|
||||||
|
} else {
|
||||||
|
setFrequencies(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isConnected, isRecording]);
|
||||||
|
|
||||||
|
// update session params
|
||||||
|
useEffect(() => {
|
||||||
|
clientRef.current?.configure({ voice });
|
||||||
|
}, [voice]);
|
||||||
|
useEffect(() => {
|
||||||
|
clientRef.current?.configure({ temperature });
|
||||||
|
}, [temperature]);
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
onClose?.();
|
||||||
|
if (isRecording) {
|
||||||
|
await toggleRecording();
|
||||||
|
}
|
||||||
|
disconnect().catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["realtime-chat"]}>
|
||||||
|
<div
|
||||||
|
className={clsx(styles["circle-mic"], {
|
||||||
|
[styles["pulse"]]: isRecording,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VoicePrint frequencies={frequencies} isActive={isRecording} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["bottom-icons"]}>
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
icon={isRecording ? <VoiceIcon /> : <VoiceOffIcon />}
|
||||||
|
onClick={toggleRecording}
|
||||||
|
disabled={!isConnected}
|
||||||
|
shadow
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles["icon-center"]}>{status}</div>
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
icon={<PowerIcon />}
|
||||||
|
onClick={handleClose}
|
||||||
|
shadow
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
app/components/realtime-chat/realtime-config.tsx
Normal file
173
app/components/realtime-chat/realtime-config.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { RealtimeConfig } from "@/app/store";
|
||||||
|
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { ListItem, Select, PasswordInput } from "@/app/components/ui-lib";
|
||||||
|
|
||||||
|
import { InputRange } from "@/app/components/input-range";
|
||||||
|
import { Voice } from "rt-client";
|
||||||
|
import { ServiceProvider } from "@/app/constant";
|
||||||
|
|
||||||
|
const providers = [ServiceProvider.OpenAI, ServiceProvider.Azure];
|
||||||
|
|
||||||
|
const models = ["gpt-4o-realtime-preview-2024-10-01"];
|
||||||
|
|
||||||
|
const voice = ["alloy", "shimmer", "echo"];
|
||||||
|
|
||||||
|
export function RealtimeConfigList(props: {
|
||||||
|
realtimeConfig: RealtimeConfig;
|
||||||
|
updateConfig: (updater: (config: RealtimeConfig) => void) => void;
|
||||||
|
}) {
|
||||||
|
const azureConfigComponent = props.realtimeConfig.provider ===
|
||||||
|
ServiceProvider.Azure && (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Realtime.Azure.Endpoint.Title}
|
||||||
|
subTitle={Locale.Settings.Realtime.Azure.Endpoint.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={props.realtimeConfig?.azure?.endpoint}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Settings.Realtime.Azure.Endpoint.Title}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.azure.endpoint = e.currentTarget.value),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Realtime.Azure.Deployment.Title}
|
||||||
|
subTitle={Locale.Settings.Realtime.Azure.Deployment.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={props.realtimeConfig?.azure?.deployment}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Settings.Realtime.Azure.Deployment.Title}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.azure.deployment = e.currentTarget.value),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Realtime.Enable.Title}
|
||||||
|
subTitle={Locale.Settings.Realtime.Enable.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.realtimeConfig.enable}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.enable = e.currentTarget.checked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{props.realtimeConfig.enable && (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Realtime.Provider.Title}
|
||||||
|
subTitle={Locale.Settings.Realtime.Provider.SubTitle}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
aria-label={Locale.Settings.Realtime.Provider.Title}
|
||||||
|
value={props.realtimeConfig.provider}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.provider = e.target.value as ServiceProvider),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{providers.map((v, i) => (
|
||||||
|
<option value={v} key={i}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Realtime.Model.Title}
|
||||||
|
subTitle={Locale.Settings.Realtime.Model.SubTitle}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
aria-label={Locale.Settings.Realtime.Model.Title}
|
||||||
|
value={props.realtimeConfig.model}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig((config) => (config.model = e.target.value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{models.map((v, i) => (
|
||||||
|
<option value={v} key={i}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Realtime.ApiKey.Title}
|
||||||
|
subTitle={Locale.Settings.Realtime.ApiKey.SubTitle}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
aria={Locale.Settings.ShowPassword}
|
||||||
|
aria-label={Locale.Settings.Realtime.ApiKey.Title}
|
||||||
|
value={props.realtimeConfig.apiKey}
|
||||||
|
type="text"
|
||||||
|
placeholder={Locale.Settings.Realtime.ApiKey.Placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.apiKey = e.currentTarget.value),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
{azureConfigComponent}
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.TTS.Voice.Title}
|
||||||
|
subTitle={Locale.Settings.TTS.Voice.SubTitle}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={props.realtimeConfig.voice}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.voice = e.currentTarget.value as Voice),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{voice.map((v, i) => (
|
||||||
|
<option value={v} key={i}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Realtime.Temperature.Title}
|
||||||
|
subTitle={Locale.Settings.Realtime.Temperature.SubTitle}
|
||||||
|
>
|
||||||
|
<InputRange
|
||||||
|
aria={Locale.Settings.Temperature.Title}
|
||||||
|
value={props.realtimeConfig?.temperature?.toFixed(1)}
|
||||||
|
min="0.6"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.temperature = e.currentTarget.valueAsNumber),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
321
app/components/sd/sd-panel.tsx
Normal file
321
app/components/sd/sd-panel.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
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={clsx(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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
340
app/components/sd/sd.tsx
Normal file
340
app/components/sd/sd.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
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";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
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={clsx({ [homeStyles["sidebar-show"]]: isSd })} />
|
||||||
|
<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={clsx(
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,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, useMemo } from "react";
|
import React, { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import styles from "./home.module.scss";
|
import styles from "./home.module.scss";
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@ 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 DeleteIcon from "../icons/delete.svg";
|
||||||
import MaskIcon from "../icons/mask.svg";
|
import MaskIcon from "../icons/mask.svg";
|
||||||
import PluginIcon from "../icons/plugin.svg";
|
import McpIcon from "../icons/mcp.svg";
|
||||||
import DragIcon from "../icons/drag.svg";
|
import DragIcon from "../icons/drag.svg";
|
||||||
|
import DiscoveryIcon from "../icons/discovery.svg";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
|
||||||
@@ -29,13 +29,21 @@ import {
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { isIOS, useMobileScreen } from "../utils";
|
import { isIOS, useMobileScreen } from "../utils";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { showConfirm, showToast } from "./ui-lib";
|
import { Selector, showConfirm } from "./ui-lib";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { isMcpEnabled } from "../mcp/actions";
|
||||||
|
|
||||||
|
const DISCOVERY = [
|
||||||
|
{ name: Locale.Plugin.Name, path: Path.Plugins },
|
||||||
|
{ name: "Stable Diffusion", path: Path.Sd },
|
||||||
|
{ name: Locale.SearchChat.Page.Title, path: Path.SearchChat },
|
||||||
|
];
|
||||||
|
|
||||||
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(() => {
|
||||||
@@ -54,7 +62,7 @@ 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();
|
||||||
@@ -128,68 +136,176 @@ function useDragSideBar() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SideBar(props: { className?: string }) {
|
export function SideBarContainer(props: {
|
||||||
const chatStore = useChatStore();
|
children: React.ReactNode;
|
||||||
|
onDragStart: (e: MouseEvent) => void;
|
||||||
// drag side bar
|
shouldNarrow: boolean;
|
||||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
className?: string;
|
||||||
const navigate = useNavigate();
|
}) {
|
||||||
const config = useAppConfig();
|
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
const isIOSMobile = useMemo(
|
const isIOSMobile = useMemo(
|
||||||
() => isIOS() && isMobileScreen,
|
() => isIOS() && isMobileScreen,
|
||||||
[isMobileScreen],
|
[isMobileScreen],
|
||||||
);
|
);
|
||||||
|
const { children, className, onDragStart, shouldNarrow } = props;
|
||||||
useHotKey();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.sidebar} ${props.className} ${
|
className={clsx(styles.sidebar, className, {
|
||||||
shouldNarrow && styles["narrow-sidebar"]
|
[styles["narrow-sidebar"]]: shouldNarrow,
|
||||||
}`}
|
})}
|
||||||
style={{
|
style={{
|
||||||
// #3016 disable transition on ios mobile screen
|
// #3016 disable transition on ios mobile screen
|
||||||
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles["sidebar-header"]} data-tauri-drag-region>
|
{children}
|
||||||
<div className={styles["sidebar-title"]} data-tauri-drag-region>
|
|
||||||
NextChat
|
|
||||||
</div>
|
|
||||||
<div className={styles["sidebar-sub-title"]}>
|
|
||||||
Build your own AI assistant.
|
|
||||||
</div>
|
|
||||||
<div className={styles["sidebar-logo"] + " no-dark"}>
|
|
||||||
<ChatGptIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles["sidebar-header-bar"]}>
|
|
||||||
<IconButton
|
|
||||||
icon={<MaskIcon />}
|
|
||||||
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
|
||||||
className={styles["sidebar-bar-button"]}
|
|
||||||
onClick={() => {
|
|
||||||
if (config.dontShowMaskSplashScreen !== true) {
|
|
||||||
navigate(Path.NewChat, { state: { fromHome: true } });
|
|
||||||
} else {
|
|
||||||
navigate(Path.Masks, { state: { fromHome: true } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
shadow
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={<PluginIcon />}
|
|
||||||
text={shouldNarrow ? undefined : Locale.Plugin.Name}
|
|
||||||
className={styles["sidebar-bar-button"]}
|
|
||||||
onClick={() => showToast(Locale.WIP)}
|
|
||||||
shadow
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles["sidebar-body"]}
|
className={styles["sidebar-drag"]}
|
||||||
|
onPointerDown={(e) => onDragStart(e as any)}
|
||||||
|
>
|
||||||
|
<DragIcon />
|
||||||
|
</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={clsx(styles["sidebar-header"], {
|
||||||
|
[styles["sidebar-header-narrow"]]: shouldNarrow,
|
||||||
|
})}
|
||||||
|
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={clsx(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 [showDiscoverySelector, setshowDiscoverySelector] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 检查 MCP 是否启用
|
||||||
|
const checkMcpStatus = async () => {
|
||||||
|
const enabled = await isMcpEnabled();
|
||||||
|
setMcpEnabled(enabled);
|
||||||
|
console.log("[SideBar] MCP enabled:", enabled);
|
||||||
|
};
|
||||||
|
checkMcpStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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"]}>
|
||||||
|
<IconButton
|
||||||
|
icon={<MaskIcon />}
|
||||||
|
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
||||||
|
className={styles["sidebar-bar-button"]}
|
||||||
|
onClick={() => {
|
||||||
|
if (config.dontShowMaskSplashScreen !== true) {
|
||||||
|
navigate(Path.NewChat, { state: { fromHome: true } });
|
||||||
|
} else {
|
||||||
|
navigate(Path.Masks, { state: { fromHome: true } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
shadow
|
||||||
|
/>
|
||||||
|
{mcpEnabled && (
|
||||||
|
<IconButton
|
||||||
|
icon={<McpIcon />}
|
||||||
|
text={shouldNarrow ? undefined : Locale.Mcp.Name}
|
||||||
|
className={styles["sidebar-bar-button"]}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(Path.McpMarket, { state: { fromHome: true } });
|
||||||
|
}}
|
||||||
|
shadow
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon={<DiscoveryIcon />}
|
||||||
|
text={shouldNarrow ? undefined : Locale.Discovery.Name}
|
||||||
|
className={styles["sidebar-bar-button"]}
|
||||||
|
onClick={() => setshowDiscoverySelector(true)}
|
||||||
|
shadow
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showDiscoverySelector && (
|
||||||
|
<Selector
|
||||||
|
items={[
|
||||||
|
...DISCOVERY.map((item) => {
|
||||||
|
return {
|
||||||
|
title: item.name,
|
||||||
|
value: item.path,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
onClose={() => setshowDiscoverySelector(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);
|
||||||
@@ -197,32 +313,41 @@ 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={clsx(styles["sidebar-action"], styles.mobile)}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<DeleteIcon />}
|
icon={<DeleteIcon />}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (await showConfirm(Locale.Home.DeleteChat)) {
|
if (await showConfirm(Locale.Home.DeleteChat)) {
|
||||||
chatStore.deleteSession(chatStore.currentSessionIndex);
|
chatStore.deleteSession(chatStore.currentSessionIndex);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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
|
||||||
</Link>
|
aria={Locale.Settings.Title}
|
||||||
</div>
|
icon={<SettingsIcon />}
|
||||||
<div className={styles["sidebar-action"]}>
|
shadow
|
||||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
/>
|
||||||
<IconButton icon={<GithubIcon />} shadow />
|
</Link>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
<div className={styles["sidebar-action"]}>
|
||||||
</div>
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
<div>
|
<IconButton
|
||||||
|
aria={Locale.Export.MessageFromChatGPT}
|
||||||
|
icon={<GithubIcon />}
|
||||||
|
shadow
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
secondaryAction={
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<AddIcon />}
|
icon={<AddIcon />}
|
||||||
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
||||||
@@ -236,15 +361,8 @@ export function SideBar(props: { className?: string }) {
|
|||||||
}}
|
}}
|
||||||
shadow
|
shadow
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
</SideBarContainer>
|
||||||
<div
|
|
||||||
className={styles["sidebar-drag"]}
|
|
||||||
onPointerDown={(e) => onDragStart(e as any)}
|
|
||||||
>
|
|
||||||
<DragIcon />
|
|
||||||
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,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 {
|
||||||
@@ -239,6 +252,12 @@
|
|||||||
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);
|
||||||
@@ -291,7 +310,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
|
.selector-item-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
|
min-width: 300px;
|
||||||
.list {
|
.list {
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -312,3 +336,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ import MinIcon from "../icons/min.svg";
|
|||||||
import Locale from "../locales";
|
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";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export function Popover(props: {
|
export function Popover(props: {
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
@@ -37,21 +46,28 @@ export function Popover(props: {
|
|||||||
|
|
||||||
export function Card(props: { children: JSX.Element[]; className?: string }) {
|
export function Card(props: { children: JSX.Element[]; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.card + " " + props.className}>{props.children}</div>
|
<div className={clsx(styles.card, props.className)}>{props.children}</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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?: () => void;
|
onClick?: (e: MouseEvent) => void;
|
||||||
|
vertical?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles["list-item"] + ` ${props.className || ""}`}
|
className={clsx(
|
||||||
|
styles["list-item"],
|
||||||
|
{
|
||||||
|
[styles["vertical"]]: props.vertical,
|
||||||
|
},
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<div className={styles["list-header"]}>
|
<div className={styles["list-header"]}>
|
||||||
@@ -122,9 +138,9 @@ export function Modal(props: ModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={clsx(styles["modal-container"], {
|
||||||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
[styles["modal-container-max"]]: isMax,
|
||||||
}
|
})}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
@@ -247,14 +263,15 @@ export function Input(props: InputProps) {
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
{...props}
|
{...props}
|
||||||
className={`${styles["input"]} ${props.className}`}
|
className={clsx(styles["input"], props.className)}
|
||||||
></textarea>
|
></textarea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -262,6 +279,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"}
|
||||||
@@ -277,13 +295,23 @@ 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={clsx(
|
||||||
|
styles["select-with-icon"],
|
||||||
|
{
|
||||||
|
[styles["left-align-option"]]: align === "left",
|
||||||
|
},
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</select>
|
||||||
@@ -420,17 +448,25 @@ export function showPrompt(content: any, value = "", rows = 3) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showImageModal(img: string) {
|
export function showImageModal(
|
||||||
|
img: string,
|
||||||
|
defaultMax?: boolean,
|
||||||
|
style?: CSSProperties,
|
||||||
|
boxStyle?: CSSProperties,
|
||||||
|
) {
|
||||||
showModal({
|
showModal({
|
||||||
title: Locale.Export.Image.Modal,
|
title: Locale.Export.Image.Modal,
|
||||||
|
defaultMax: defaultMax,
|
||||||
children: (
|
children: (
|
||||||
<div>
|
<div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
|
||||||
<img
|
<img
|
||||||
src={img}
|
src={img}
|
||||||
alt="preview"
|
alt="preview"
|
||||||
style={{
|
style={
|
||||||
maxWidth: "100%",
|
style ?? {
|
||||||
}}
|
maxWidth: "100%",
|
||||||
|
}
|
||||||
|
}
|
||||||
></img>
|
></img>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -442,27 +478,56 @@ export function Selector<T>(props: {
|
|||||||
title: string;
|
title: string;
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
value: T;
|
value: T;
|
||||||
|
disable?: boolean;
|
||||||
}>;
|
}>;
|
||||||
defaultSelectedValue?: T;
|
defaultSelectedValue?: T[] | T;
|
||||||
onSelection?: (selection: T[]) => void;
|
onSelection?: (selection: T[]) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
multiple?: boolean;
|
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 (
|
return (
|
||||||
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
||||||
<div className={styles["selector-content"]}>
|
<div className={styles["selector-content"]}>
|
||||||
<List>
|
<List>
|
||||||
{props.items.map((item, i) => {
|
{props.items.map((item, i) => {
|
||||||
const selected = props.defaultSelectedValue === item.value;
|
const selected = selectedValues.includes(item.value);
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
className={styles["selector-item"]}
|
className={clsx(styles["selector-item"], {
|
||||||
|
[styles["selector-item-disabled"]]: item.disable,
|
||||||
|
})}
|
||||||
key={i}
|
key={i}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
subTitle={item.subTitle}
|
subTitle={item.subTitle}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
props.onSelection?.([item.value]);
|
if (item.disable) {
|
||||||
props.onClose?.();
|
e.stopPropagation();
|
||||||
|
} else {
|
||||||
|
handleSelection(e, item.value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
@@ -485,3 +550,38 @@ export function Selector<T>(props: {
|
|||||||
</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
app/components/voice-print/index.ts
Normal file
1
app/components/voice-print/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./voice-print";
|
||||||
11
app/components/voice-print/voice-print.module.scss
Normal file
11
app/components/voice-print/voice-print.module.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.voice-print {
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
margin: 20px 0;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: brightness(1.2); // 增加整体亮度
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/components/voice-print/voice-print.tsx
Normal file
180
app/components/voice-print/voice-print.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import styles from "./voice-print.module.scss";
|
||||||
|
|
||||||
|
interface VoicePrintProps {
|
||||||
|
frequencies?: Uint8Array;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoicePrint({ frequencies, isActive }: VoicePrintProps) {
|
||||||
|
// Canvas引用,用于获取绘图上下文
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// 存储历史频率数据,用于平滑处理
|
||||||
|
const historyRef = useRef<number[][]>([]);
|
||||||
|
// 控制保留的历史数据帧数,影响平滑度
|
||||||
|
const historyLengthRef = useRef(10);
|
||||||
|
// 存储动画帧ID,用于清理
|
||||||
|
const animationFrameRef = useRef<number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新频率历史数据
|
||||||
|
* 使用FIFO队列维护固定长度的历史记录
|
||||||
|
*/
|
||||||
|
const updateHistory = useCallback((freqArray: number[]) => {
|
||||||
|
historyRef.current.push(freqArray);
|
||||||
|
if (historyRef.current.length > historyLengthRef.current) {
|
||||||
|
historyRef.current.shift();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理高DPI屏幕显示
|
||||||
|
* 根据设备像素比例调整canvas实际渲染分辨率
|
||||||
|
*/
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = canvas.offsetWidth * dpr;
|
||||||
|
canvas.height = canvas.offsetHeight * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主要绘制函数
|
||||||
|
* 使用requestAnimationFrame实现平滑动画
|
||||||
|
* 包含以下步骤:
|
||||||
|
* 1. 清空画布
|
||||||
|
* 2. 更新历史数据
|
||||||
|
* 3. 计算波形点
|
||||||
|
* 4. 绘制上下对称的声纹
|
||||||
|
*/
|
||||||
|
const draw = () => {
|
||||||
|
// 清空画布
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (!frequencies || !isActive) {
|
||||||
|
historyRef.current = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freqArray = Array.from(frequencies);
|
||||||
|
updateHistory(freqArray);
|
||||||
|
|
||||||
|
// 绘制声纹
|
||||||
|
const points: [number, number][] = [];
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
const width = canvas.width;
|
||||||
|
const sliceWidth = width / (frequencies.length - 1);
|
||||||
|
|
||||||
|
// 绘制主波形
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, centerY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 声纹绘制算法:
|
||||||
|
* 1. 使用历史数据平均值实现平滑过渡
|
||||||
|
* 2. 通过正弦函数添加自然波动
|
||||||
|
* 3. 使用贝塞尔曲线连接点,使曲线更平滑
|
||||||
|
* 4. 绘制对称部分形成完整声纹
|
||||||
|
*/
|
||||||
|
for (let i = 0; i < frequencies.length; i++) {
|
||||||
|
const x = i * sliceWidth;
|
||||||
|
let avgFrequency = frequencies[i];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 波形平滑处理:
|
||||||
|
* 1. 收集历史数据中对应位置的频率值
|
||||||
|
* 2. 计算当前值与历史值的加权平均
|
||||||
|
* 3. 根据平均值计算实际显示高度
|
||||||
|
*/
|
||||||
|
if (historyRef.current.length > 0) {
|
||||||
|
const historicalValues = historyRef.current.map((h) => h[i] || 0);
|
||||||
|
avgFrequency =
|
||||||
|
(avgFrequency + historicalValues.reduce((a, b) => a + b, 0)) /
|
||||||
|
(historyRef.current.length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 波形变换:
|
||||||
|
* 1. 归一化频率值到0-1范围
|
||||||
|
* 2. 添加时间相关的正弦变换
|
||||||
|
* 3. 使用贝塞尔曲线平滑连接点
|
||||||
|
*/
|
||||||
|
const normalized = avgFrequency / 255.0;
|
||||||
|
const height = normalized * (canvas.height / 2);
|
||||||
|
const y = centerY + height * Math.sin(i * 0.2 + Date.now() * 0.002);
|
||||||
|
|
||||||
|
points.push([x, y]);
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
// 使用贝塞尔曲线使波形更平滑
|
||||||
|
const prevPoint = points[i - 1];
|
||||||
|
const midX = (prevPoint[0] + x) / 2;
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
prevPoint[0],
|
||||||
|
prevPoint[1],
|
||||||
|
midX,
|
||||||
|
(prevPoint[1] + y) / 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制对称的下半部分
|
||||||
|
for (let i = points.length - 1; i >= 0; i--) {
|
||||||
|
const [x, y] = points[i];
|
||||||
|
const symmetricY = centerY - (y - centerY);
|
||||||
|
if (i === points.length - 1) {
|
||||||
|
ctx.lineTo(x, symmetricY);
|
||||||
|
} else {
|
||||||
|
const nextPoint = points[i + 1];
|
||||||
|
const midX = (nextPoint[0] + x) / 2;
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
nextPoint[0],
|
||||||
|
centerY - (nextPoint[1] - centerY),
|
||||||
|
midX,
|
||||||
|
centerY - ((nextPoint[1] + y) / 2 - centerY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.closePath();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渐变效果:
|
||||||
|
* 从左到右应用三色渐变,带透明度
|
||||||
|
* 使用蓝色系配色提升视觉效果
|
||||||
|
*/
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||||
|
gradient.addColorStop(0, "rgba(100, 180, 255, 0.95)");
|
||||||
|
gradient.addColorStop(0.5, "rgba(140, 200, 255, 0.9)");
|
||||||
|
gradient.addColorStop(1, "rgba(180, 220, 255, 0.95)");
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动动画循环
|
||||||
|
draw();
|
||||||
|
|
||||||
|
// 清理函数:在组件卸载时取消动画
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [frequencies, isActive, updateHistory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["voice-print"]}>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,5 +1,6 @@
|
|||||||
import md5 from "spark-md5";
|
import md5 from "spark-md5";
|
||||||
import { DEFAULT_MODELS } from "../constant";
|
import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant";
|
||||||
|
import { isGPT4Model } from "../utils/model";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
@@ -22,6 +23,11 @@ declare global {
|
|||||||
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
||||||
CUSTOM_MODELS?: string; // to control custom models
|
CUSTOM_MODELS?: string; // to control custom models
|
||||||
DEFAULT_MODEL?: string; // to control default model in every new chat window
|
DEFAULT_MODEL?: string; // to control default model in every new chat window
|
||||||
|
VISION_MODELS?: string; // to control vision models
|
||||||
|
|
||||||
|
// stability only
|
||||||
|
STABILITY_URL?: string;
|
||||||
|
STABILITY_API_KEY?: string;
|
||||||
|
|
||||||
// azure only
|
// azure only
|
||||||
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
||||||
@@ -53,8 +59,35 @@ declare global {
|
|||||||
ALIBABA_URL?: string;
|
ALIBABA_URL?: string;
|
||||||
ALIBABA_API_KEY?: 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;
|
||||||
|
|
||||||
|
DEEPSEEK_URL?: string;
|
||||||
|
DEEPSEEK_API_KEY?: string;
|
||||||
|
|
||||||
|
// xai only
|
||||||
|
XAI_URL?: string;
|
||||||
|
XAI_API_KEY?: string;
|
||||||
|
|
||||||
|
// chatglm only
|
||||||
|
CHATGLM_URL?: string;
|
||||||
|
CHATGLM_API_KEY?: string;
|
||||||
|
|
||||||
// custom template for preprocessing user input
|
// custom template for preprocessing user input
|
||||||
DEFAULT_INPUT_TEMPLATE?: string;
|
DEFAULT_INPUT_TEMPLATE?: string;
|
||||||
|
|
||||||
|
ENABLE_MCP?: string; // enable mcp functionality
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,22 +131,33 @@ export const getServerSideConfig = () => {
|
|||||||
const disableGPT4 = !!process.env.DISABLE_GPT4;
|
const disableGPT4 = !!process.env.DISABLE_GPT4;
|
||||||
let customModels = process.env.CUSTOM_MODELS ?? "";
|
let customModels = process.env.CUSTOM_MODELS ?? "";
|
||||||
let defaultModel = process.env.DEFAULT_MODEL ?? "";
|
let defaultModel = process.env.DEFAULT_MODEL ?? "";
|
||||||
|
let visionModels = process.env.VISION_MODELS ?? "";
|
||||||
|
|
||||||
if (disableGPT4) {
|
if (disableGPT4) {
|
||||||
if (customModels) customModels += ",";
|
if (customModels) customModels += ",";
|
||||||
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
|
customModels += DEFAULT_MODELS.filter((m) => isGPT4Model(m.name))
|
||||||
.map((m) => "-" + m.name)
|
.map((m) => "-" + m.name)
|
||||||
.join(",");
|
.join(",");
|
||||||
if (defaultModel.startsWith("gpt-4")) defaultModel = "";
|
if (defaultModel && isGPT4Model(defaultModel)) {
|
||||||
|
defaultModel = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isStability = !!process.env.STABILITY_API_KEY;
|
||||||
|
|
||||||
const isAzure = !!process.env.AZURE_URL;
|
const isAzure = !!process.env.AZURE_URL;
|
||||||
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
||||||
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
const isTencent = !!process.env.TENCENT_API_KEY;
|
||||||
|
|
||||||
const isBaidu = !!process.env.BAIDU_API_KEY;
|
const isBaidu = !!process.env.BAIDU_API_KEY;
|
||||||
const isBytedance = !!process.env.BYTEDANCE_API_KEY;
|
const isBytedance = !!process.env.BYTEDANCE_API_KEY;
|
||||||
const isAlibaba = !!process.env.ALIBABA_API_KEY;
|
const isAlibaba = !!process.env.ALIBABA_API_KEY;
|
||||||
|
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
|
||||||
|
const isIflytek = !!process.env.IFLYTEK_API_KEY;
|
||||||
|
const isDeepSeek = !!process.env.DEEPSEEK_API_KEY;
|
||||||
|
const isXAI = !!process.env.XAI_API_KEY;
|
||||||
|
const isChatGLM = !!process.env.CHATGLM_API_KEY;
|
||||||
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
||||||
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||||
// const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
// const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||||
@@ -122,8 +166,8 @@ export const getServerSideConfig = () => {
|
|||||||
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
||||||
// );
|
// );
|
||||||
|
|
||||||
const allowedWebDevEndpoints = (
|
const allowedWebDavEndpoints = (
|
||||||
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
|
process.env.WHITE_WEBDAV_ENDPOINTS ?? ""
|
||||||
).split(",");
|
).split(",");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -131,6 +175,10 @@ export const getServerSideConfig = () => {
|
|||||||
apiKey: getApiKey(process.env.OPENAI_API_KEY),
|
apiKey: getApiKey(process.env.OPENAI_API_KEY),
|
||||||
openaiOrgId: process.env.OPENAI_ORG_ID,
|
openaiOrgId: process.env.OPENAI_ORG_ID,
|
||||||
|
|
||||||
|
isStability,
|
||||||
|
stabilityUrl: process.env.STABILITY_URL,
|
||||||
|
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
|
||||||
|
|
||||||
isAzure,
|
isAzure,
|
||||||
azureUrl: process.env.AZURE_URL,
|
azureUrl: process.env.AZURE_URL,
|
||||||
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
|
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
|
||||||
@@ -158,7 +206,39 @@ export const getServerSideConfig = () => {
|
|||||||
alibabaUrl: process.env.ALIBABA_URL,
|
alibabaUrl: process.env.ALIBABA_URL,
|
||||||
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
|
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,
|
||||||
|
|
||||||
|
isDeepSeek,
|
||||||
|
deepseekUrl: process.env.DEEPSEEK_URL,
|
||||||
|
deepseekApiKey: getApiKey(process.env.DEEPSEEK_API_KEY),
|
||||||
|
|
||||||
|
isXAI,
|
||||||
|
xaiUrl: process.env.XAI_URL,
|
||||||
|
xaiApiKey: getApiKey(process.env.XAI_API_KEY),
|
||||||
|
|
||||||
|
isChatGLM,
|
||||||
|
chatglmUrl: process.env.CHATGLM_URL,
|
||||||
|
chatglmApiKey: getApiKey(process.env.CHATGLM_API_KEY),
|
||||||
|
|
||||||
|
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,
|
gtmId: process.env.GTM_ID,
|
||||||
|
gaId: process.env.GA_ID || DEFAULT_GA_ID,
|
||||||
|
|
||||||
needCode: ACCESS_CODES.size > 0,
|
needCode: ACCESS_CODES.size > 0,
|
||||||
code: process.env.CODE,
|
code: process.env.CODE,
|
||||||
@@ -173,6 +253,8 @@ export const getServerSideConfig = () => {
|
|||||||
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||||
customModels,
|
customModels,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
allowedWebDevEndpoints,
|
visionModels,
|
||||||
|
allowedWebDavEndpoints,
|
||||||
|
enableMcp: !!process.env.ENABLE_MCP,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
425
app/constant.ts
425
app/constant.ts
@@ -1,6 +1,7 @@
|
|||||||
export const OWNER = "Yidadaa";
|
export const OWNER = "ChatGPTNextWeb";
|
||||||
export const REPO = "ChatGPT-Next-Web";
|
export const REPO = "ChatGPT-Next-Web";
|
||||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
||||||
|
export const PLUGINS_REPO_URL = `https://github.com/${OWNER}/NextChat-Awesome-Plugins`;
|
||||||
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
||||||
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
|
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
|
||||||
export const RELEASE_URL = `${REPO_URL}/releases`;
|
export const RELEASE_URL = `${REPO_URL}/releases`;
|
||||||
@@ -8,7 +9,8 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c
|
|||||||
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
|
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
|
||||||
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
|
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
|
||||||
|
|
||||||
export const DEFAULT_API_HOST = "https://api.nextchat.dev";
|
export const STABILITY_BASE_URL = "https://api.stability.ai";
|
||||||
|
|
||||||
export const OPENAI_BASE_URL = "https://api.openai.com";
|
export const OPENAI_BASE_URL = "https://api.openai.com";
|
||||||
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||||
|
|
||||||
@@ -21,6 +23,17 @@ export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com";
|
|||||||
|
|
||||||
export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/";
|
export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/";
|
||||||
|
|
||||||
|
export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
|
||||||
|
|
||||||
|
export const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
|
||||||
|
export const IFLYTEK_BASE_URL = "https://spark-api-open.xf-yun.com";
|
||||||
|
|
||||||
|
export const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
||||||
|
|
||||||
|
export const XAI_BASE_URL = "https://api.x.ai";
|
||||||
|
|
||||||
|
export const CHATGLM_BASE_URL = "https://open.bigmodel.cn";
|
||||||
|
|
||||||
export const CACHE_URL_PREFIX = "/api/cache";
|
export const CACHE_URL_PREFIX = "/api/cache";
|
||||||
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
|
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
|
||||||
|
|
||||||
@@ -30,7 +43,13 @@ export enum Path {
|
|||||||
Settings = "/settings",
|
Settings = "/settings",
|
||||||
NewChat = "/new-chat",
|
NewChat = "/new-chat",
|
||||||
Masks = "/masks",
|
Masks = "/masks",
|
||||||
|
Plugins = "/plugins",
|
||||||
Auth = "/auth",
|
Auth = "/auth",
|
||||||
|
Sd = "/sd",
|
||||||
|
SdNew = "/sd-new",
|
||||||
|
Artifacts = "/artifacts",
|
||||||
|
SearchChat = "/search-chat",
|
||||||
|
McpMarket = "/mcp-market",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ApiPath {
|
export enum ApiPath {
|
||||||
@@ -42,6 +61,14 @@ export enum ApiPath {
|
|||||||
Baidu = "/api/baidu",
|
Baidu = "/api/baidu",
|
||||||
ByteDance = "/api/bytedance",
|
ByteDance = "/api/bytedance",
|
||||||
Alibaba = "/api/alibaba",
|
Alibaba = "/api/alibaba",
|
||||||
|
Tencent = "/api/tencent",
|
||||||
|
Moonshot = "/api/moonshot",
|
||||||
|
Iflytek = "/api/iflytek",
|
||||||
|
Stability = "/api/stability",
|
||||||
|
Artifacts = "/api/artifacts",
|
||||||
|
XAI = "/api/xai",
|
||||||
|
ChatGLM = "/api/chatglm",
|
||||||
|
DeepSeek = "/api/deepseek",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SlotID {
|
export enum SlotID {
|
||||||
@@ -56,12 +83,15 @@ export enum FileName {
|
|||||||
|
|
||||||
export enum StoreKey {
|
export enum StoreKey {
|
||||||
Chat = "chat-next-web-store",
|
Chat = "chat-next-web-store",
|
||||||
|
Plugin = "chat-next-web-plugin",
|
||||||
Access = "access-control",
|
Access = "access-control",
|
||||||
Config = "app-config",
|
Config = "app-config",
|
||||||
Mask = "mask-store",
|
Mask = "mask-store",
|
||||||
Prompt = "prompt-store",
|
Prompt = "prompt-store",
|
||||||
Update = "chat-update",
|
Update = "chat-update",
|
||||||
Sync = "sync",
|
Sync = "sync",
|
||||||
|
SdList = "sd-list",
|
||||||
|
Mcp = "mcp-store",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||||
@@ -88,17 +118,45 @@ export enum ServiceProvider {
|
|||||||
Baidu = "Baidu",
|
Baidu = "Baidu",
|
||||||
ByteDance = "ByteDance",
|
ByteDance = "ByteDance",
|
||||||
Alibaba = "Alibaba",
|
Alibaba = "Alibaba",
|
||||||
|
Tencent = "Tencent",
|
||||||
|
Moonshot = "Moonshot",
|
||||||
|
Stability = "Stability",
|
||||||
|
Iflytek = "Iflytek",
|
||||||
|
XAI = "XAI",
|
||||||
|
ChatGLM = "ChatGLM",
|
||||||
|
DeepSeek = "DeepSeek",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
|
||||||
|
// BLOCK_NONE will not block any content, and BLOCK_ONLY_HIGH will block only high-risk content.
|
||||||
|
export enum GoogleSafetySettingsThreshold {
|
||||||
|
BLOCK_NONE = "BLOCK_NONE",
|
||||||
|
BLOCK_ONLY_HIGH = "BLOCK_ONLY_HIGH",
|
||||||
|
BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE",
|
||||||
|
BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ModelProvider {
|
export enum ModelProvider {
|
||||||
|
Stability = "Stability",
|
||||||
GPT = "GPT",
|
GPT = "GPT",
|
||||||
GeminiPro = "GeminiPro",
|
GeminiPro = "GeminiPro",
|
||||||
Claude = "Claude",
|
Claude = "Claude",
|
||||||
Ernie = "Ernie",
|
Ernie = "Ernie",
|
||||||
Doubao = "Doubao",
|
Doubao = "Doubao",
|
||||||
Qwen = "Qwen",
|
Qwen = "Qwen",
|
||||||
|
Hunyuan = "Hunyuan",
|
||||||
|
Moonshot = "Moonshot",
|
||||||
|
Iflytek = "Iflytek",
|
||||||
|
XAI = "XAI",
|
||||||
|
ChatGLM = "ChatGLM",
|
||||||
|
DeepSeek = "DeepSeek",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Stability = {
|
||||||
|
GeneratePath: "v2beta/stable-image/generate",
|
||||||
|
ExampleEndpoint: "https://api.stability.ai",
|
||||||
|
};
|
||||||
|
|
||||||
export const Anthropic = {
|
export const Anthropic = {
|
||||||
ChatPath: "v1/messages",
|
ChatPath: "v1/messages",
|
||||||
ChatPath1: "v1/complete",
|
ChatPath1: "v1/complete",
|
||||||
@@ -108,6 +166,8 @@ export const Anthropic = {
|
|||||||
|
|
||||||
export const OpenaiPath = {
|
export const OpenaiPath = {
|
||||||
ChatPath: "v1/chat/completions",
|
ChatPath: "v1/chat/completions",
|
||||||
|
SpeechPath: "v1/audio/speech",
|
||||||
|
ImagePath: "v1/images/generations",
|
||||||
UsagePath: "dashboard/billing/usage",
|
UsagePath: "dashboard/billing/usage",
|
||||||
SubsPath: "dashboard/billing/subscription",
|
SubsPath: "dashboard/billing/subscription",
|
||||||
ListModelPath: "v1/models",
|
ListModelPath: "v1/models",
|
||||||
@@ -116,7 +176,10 @@ export const OpenaiPath = {
|
|||||||
export const Azure = {
|
export const Azure = {
|
||||||
ChatPath: (deployName: string, apiVersion: string) =>
|
ChatPath: (deployName: string, apiVersion: string) =>
|
||||||
`deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
|
`deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
|
||||||
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
|
// https://<your_resource_name>.openai.azure.com/openai/deployments/<your_deployment_name>/images/generations?api-version=<api_version>
|
||||||
|
ImagePath: (deployName: string, apiVersion: string) =>
|
||||||
|
`deployments/${deployName}/images/generations?api-version=${apiVersion}`,
|
||||||
|
ExampleEndpoint: "https://{resource-url}/openai",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Google = {
|
export const Google = {
|
||||||
@@ -138,9 +201,6 @@ export const Baidu = {
|
|||||||
if (modelName === "ernie-3.5-8k") {
|
if (modelName === "ernie-3.5-8k") {
|
||||||
endpoint = "completions";
|
endpoint = "completions";
|
||||||
}
|
}
|
||||||
if (modelName === "ernie-speed-128k") {
|
|
||||||
endpoint = "ernie-speed-128k";
|
|
||||||
}
|
|
||||||
if (modelName === "ernie-speed-8k") {
|
if (modelName === "ernie-speed-8k") {
|
||||||
endpoint = "ernie_speed";
|
endpoint = "ernie_speed";
|
||||||
}
|
}
|
||||||
@@ -158,6 +218,37 @@ export const Alibaba = {
|
|||||||
ChatPath: "v1/services/aigc/text-generation/generation",
|
ChatPath: "v1/services/aigc/text-generation/generation",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Tencent = {
|
||||||
|
ExampleEndpoint: TENCENT_BASE_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Moonshot = {
|
||||||
|
ExampleEndpoint: MOONSHOT_BASE_URL,
|
||||||
|
ChatPath: "v1/chat/completions",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Iflytek = {
|
||||||
|
ExampleEndpoint: IFLYTEK_BASE_URL,
|
||||||
|
ChatPath: "v1/chat/completions",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeepSeek = {
|
||||||
|
ExampleEndpoint: DEEPSEEK_BASE_URL,
|
||||||
|
ChatPath: "chat/completions",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const XAI = {
|
||||||
|
ExampleEndpoint: XAI_BASE_URL,
|
||||||
|
ChatPath: "v1/chat/completions",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatGLM = {
|
||||||
|
ExampleEndpoint: CHATGLM_BASE_URL,
|
||||||
|
ChatPath: "api/paas/v4/chat/completions",
|
||||||
|
ImagePath: "api/paas/v4/images/generations",
|
||||||
|
VideoPath: "api/paas/v4/videos/generations",
|
||||||
|
};
|
||||||
|
|
||||||
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
||||||
// export const DEFAULT_SYSTEM_TEMPLATE = `
|
// export const DEFAULT_SYSTEM_TEMPLATE = `
|
||||||
// You are ChatGPT, a large language model trained by {{ServiceProvider}}.
|
// You are ChatGPT, a large language model trained by {{ServiceProvider}}.
|
||||||
@@ -176,7 +267,131 @@ Latex inline: \\(x^2\\)
|
|||||||
Latex block: $$e=mc^2$$
|
Latex block: $$e=mc^2$$
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
|
export const MCP_TOOLS_TEMPLATE = `
|
||||||
|
[clientId]
|
||||||
|
{{ clientId }}
|
||||||
|
[tools]
|
||||||
|
{{ tools }}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MCP_SYSTEM_TEMPLATE = `
|
||||||
|
You are an AI assistant with access to system tools. Your role is to help users by combining natural language understanding with tool operations when needed.
|
||||||
|
|
||||||
|
1. AVAILABLE TOOLS:
|
||||||
|
{{ MCP_TOOLS }}
|
||||||
|
|
||||||
|
2. WHEN TO USE TOOLS:
|
||||||
|
- ALWAYS USE TOOLS when they can help answer user questions
|
||||||
|
- DO NOT just describe what you could do - TAKE ACTION immediately
|
||||||
|
- If you're not sure whether to use a tool, USE IT
|
||||||
|
- Common triggers for tool use:
|
||||||
|
* Questions about files or directories
|
||||||
|
* Requests to check, list, or manipulate system resources
|
||||||
|
* Any query that can be answered with available tools
|
||||||
|
|
||||||
|
3. HOW TO USE TOOLS:
|
||||||
|
A. Tool Call Format:
|
||||||
|
- Use markdown code blocks with format: \`\`\`json:mcp:{clientId}\`\`\`
|
||||||
|
- Always include:
|
||||||
|
* method: "tools/call"(Only this method is supported)
|
||||||
|
* params:
|
||||||
|
- name: must match an available primitive name
|
||||||
|
- arguments: required parameters for the primitive
|
||||||
|
|
||||||
|
B. Response Format:
|
||||||
|
- Tool responses will come as user messages
|
||||||
|
- Format: \`\`\`json:mcp-response:{clientId}\`\`\`
|
||||||
|
- Wait for response before making another tool call
|
||||||
|
|
||||||
|
C. Important Rules:
|
||||||
|
- Only use tools/call method
|
||||||
|
- Only ONE tool call per message
|
||||||
|
- ALWAYS TAKE ACTION instead of just describing what you could do
|
||||||
|
- Include the correct clientId in code block language tag
|
||||||
|
- Verify arguments match the primitive's requirements
|
||||||
|
|
||||||
|
4. INTERACTION FLOW:
|
||||||
|
A. When user makes a request:
|
||||||
|
- IMMEDIATELY use appropriate tool if available
|
||||||
|
- DO NOT ask if user wants you to use the tool
|
||||||
|
- DO NOT just describe what you could do
|
||||||
|
B. After receiving tool response:
|
||||||
|
- Explain results clearly
|
||||||
|
- Take next appropriate action if needed
|
||||||
|
C. If tools fail:
|
||||||
|
- Explain the error
|
||||||
|
- Try alternative approach immediately
|
||||||
|
|
||||||
|
5. EXAMPLE INTERACTION:
|
||||||
|
|
||||||
|
good example:
|
||||||
|
|
||||||
|
\`\`\`json:mcp:filesystem
|
||||||
|
{
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "list_allowed_directories",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`"
|
||||||
|
|
||||||
|
|
||||||
|
\`\`\`json:mcp-response:filesystem
|
||||||
|
{
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "write_file",
|
||||||
|
"arguments": {
|
||||||
|
"path": "/Users/river/dev/nextchat/test/joke.txt",
|
||||||
|
"content": "为什么数学书总是感到忧伤?因为它有太多的问题。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
follwing is the wrong! mcp json example:
|
||||||
|
|
||||||
|
\`\`\`json:mcp:filesystem
|
||||||
|
{
|
||||||
|
"method": "write_file",
|
||||||
|
"params": {
|
||||||
|
"path": "NextChat_Information.txt",
|
||||||
|
"content": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
This is wrong because the method is not tools/call.
|
||||||
|
|
||||||
|
\`\`\`{
|
||||||
|
"method": "search_repositories",
|
||||||
|
"params": {
|
||||||
|
"query": "2oeee"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
This is wrong because the method is not tools/call.!!!!!!!!!!!
|
||||||
|
|
||||||
|
the right format is:
|
||||||
|
\`\`\`json:mcp:filesystem
|
||||||
|
{
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "search_repositories",
|
||||||
|
"arguments": {
|
||||||
|
"query": "2oeee"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
please follow the format strictly ONLY use tools/call method!!!!!!!!!!!
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SUMMARIZE_MODEL = "gpt-4o-mini";
|
||||||
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
||||||
|
|
||||||
export const KnowledgeCutOffDate: Record<string, string> = {
|
export const KnowledgeCutOffDate: Record<string, string> = {
|
||||||
@@ -186,15 +401,53 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
|||||||
"gpt-4-turbo-preview": "2023-12",
|
"gpt-4-turbo-preview": "2023-12",
|
||||||
"gpt-4o": "2023-10",
|
"gpt-4o": "2023-10",
|
||||||
"gpt-4o-2024-05-13": "2023-10",
|
"gpt-4o-2024-05-13": "2023-10",
|
||||||
|
"gpt-4o-2024-08-06": "2023-10",
|
||||||
|
"gpt-4o-2024-11-20": "2023-10",
|
||||||
|
"chatgpt-4o-latest": "2023-10",
|
||||||
"gpt-4o-mini": "2023-10",
|
"gpt-4o-mini": "2023-10",
|
||||||
"gpt-4o-mini-2024-07-18": "2023-10",
|
"gpt-4o-mini-2024-07-18": "2023-10",
|
||||||
"gpt-4-vision-preview": "2023-04",
|
"gpt-4-vision-preview": "2023-04",
|
||||||
|
"o1-mini": "2023-10",
|
||||||
|
"o1-preview": "2023-10",
|
||||||
// After improvements,
|
// After improvements,
|
||||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||||
"gemini-pro": "2023-12",
|
"gemini-pro": "2023-12",
|
||||||
"gemini-pro-vision": "2023-12",
|
"gemini-pro-vision": "2023-12",
|
||||||
|
"deepseek-chat": "2024-07",
|
||||||
|
"deepseek-coder": "2024-07",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TTS_ENGINE = "OpenAI-TTS";
|
||||||
|
export const DEFAULT_TTS_ENGINES = ["OpenAI-TTS", "Edge-TTS"];
|
||||||
|
export const DEFAULT_TTS_MODEL = "tts-1";
|
||||||
|
export const DEFAULT_TTS_VOICE = "alloy";
|
||||||
|
export const DEFAULT_TTS_MODELS = ["tts-1", "tts-1-hd"];
|
||||||
|
export const DEFAULT_TTS_VOICES = [
|
||||||
|
"alloy",
|
||||||
|
"echo",
|
||||||
|
"fable",
|
||||||
|
"onyx",
|
||||||
|
"nova",
|
||||||
|
"shimmer",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VISION_MODEL_REGEXES = [
|
||||||
|
/vision/,
|
||||||
|
/gpt-4o/,
|
||||||
|
/claude-3/,
|
||||||
|
/gemini-1\.5/,
|
||||||
|
/gemini-exp/,
|
||||||
|
/gemini-2\.0/,
|
||||||
|
/learnlm/,
|
||||||
|
/qwen-vl/,
|
||||||
|
/qwen2-vl/,
|
||||||
|
/gpt-4-turbo(?!.*preview)/, // Matches "gpt-4-turbo" but not "gpt-4-turbo-preview"
|
||||||
|
/^dall-e-3$/, // Matches exactly "dall-e-3"
|
||||||
|
/glm-4v/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EXCLUDE_VISION_MODEL_REGEXES = [/claude-3-5-haiku-20241022/];
|
||||||
|
|
||||||
const openaiModels = [
|
const openaiModels = [
|
||||||
"gpt-3.5-turbo",
|
"gpt-3.5-turbo",
|
||||||
"gpt-3.5-turbo-1106",
|
"gpt-3.5-turbo-1106",
|
||||||
@@ -207,18 +460,37 @@ const openaiModels = [
|
|||||||
"gpt-4-turbo-preview",
|
"gpt-4-turbo-preview",
|
||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
"gpt-4o-2024-05-13",
|
"gpt-4o-2024-05-13",
|
||||||
|
"gpt-4o-2024-08-06",
|
||||||
|
"gpt-4o-2024-11-20",
|
||||||
|
"chatgpt-4o-latest",
|
||||||
"gpt-4o-mini",
|
"gpt-4o-mini",
|
||||||
"gpt-4o-mini-2024-07-18",
|
"gpt-4o-mini-2024-07-18",
|
||||||
"gpt-4-vision-preview",
|
"gpt-4-vision-preview",
|
||||||
"gpt-4-turbo-2024-04-09",
|
"gpt-4-turbo-2024-04-09",
|
||||||
"gpt-4-1106-preview",
|
"gpt-4-1106-preview",
|
||||||
|
"dall-e-3",
|
||||||
|
"o1-mini",
|
||||||
|
"o1-preview",
|
||||||
];
|
];
|
||||||
|
|
||||||
const googleModels = [
|
const googleModels = [
|
||||||
"gemini-1.0-pro",
|
"gemini-1.0-pro", // Deprecated on 2/15/2025
|
||||||
"gemini-1.5-pro-latest",
|
"gemini-1.5-pro-latest",
|
||||||
|
"gemini-1.5-pro",
|
||||||
|
"gemini-1.5-pro-002",
|
||||||
|
"gemini-1.5-pro-exp-0827",
|
||||||
"gemini-1.5-flash-latest",
|
"gemini-1.5-flash-latest",
|
||||||
"gemini-pro-vision",
|
"gemini-1.5-flash-8b-latest",
|
||||||
|
"gemini-1.5-flash",
|
||||||
|
"gemini-1.5-flash-8b",
|
||||||
|
"gemini-1.5-flash-002",
|
||||||
|
"gemini-1.5-flash-exp-0827",
|
||||||
|
"learnlm-1.5-pro-experimental",
|
||||||
|
"gemini-exp-1114",
|
||||||
|
"gemini-exp-1121",
|
||||||
|
"gemini-exp-1206",
|
||||||
|
"gemini-2.0-flash-exp",
|
||||||
|
"gemini-2.0-flash-thinking-exp-1219",
|
||||||
];
|
];
|
||||||
|
|
||||||
const anthropicModels = [
|
const anthropicModels = [
|
||||||
@@ -227,8 +499,13 @@ const anthropicModels = [
|
|||||||
"claude-2.1",
|
"claude-2.1",
|
||||||
"claude-3-sonnet-20240229",
|
"claude-3-sonnet-20240229",
|
||||||
"claude-3-opus-20240229",
|
"claude-3-opus-20240229",
|
||||||
|
"claude-3-opus-latest",
|
||||||
"claude-3-haiku-20240307",
|
"claude-3-haiku-20240307",
|
||||||
|
"claude-3-5-haiku-20241022",
|
||||||
|
"claude-3-5-haiku-latest",
|
||||||
"claude-3-5-sonnet-20240620",
|
"claude-3-5-sonnet-20240620",
|
||||||
|
"claude-3-5-sonnet-20241022",
|
||||||
|
"claude-3-5-sonnet-latest",
|
||||||
];
|
];
|
||||||
|
|
||||||
const baiduModels = [
|
const baiduModels = [
|
||||||
@@ -264,68 +541,193 @@ const alibabaModes = [
|
|||||||
"qwen-max-longcontext",
|
"qwen-max-longcontext",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const tencentModels = [
|
||||||
|
"hunyuan-pro",
|
||||||
|
"hunyuan-standard",
|
||||||
|
"hunyuan-lite",
|
||||||
|
"hunyuan-role",
|
||||||
|
"hunyuan-functioncall",
|
||||||
|
"hunyuan-code",
|
||||||
|
"hunyuan-vision",
|
||||||
|
];
|
||||||
|
|
||||||
|
const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"];
|
||||||
|
|
||||||
|
const iflytekModels = [
|
||||||
|
"general",
|
||||||
|
"generalv3",
|
||||||
|
"pro-128k",
|
||||||
|
"generalv3.5",
|
||||||
|
"4.0Ultra",
|
||||||
|
];
|
||||||
|
|
||||||
|
const deepseekModels = ["deepseek-chat", "deepseek-coder"];
|
||||||
|
|
||||||
|
const xAIModes = ["grok-beta"];
|
||||||
|
|
||||||
|
const chatglmModels = [
|
||||||
|
"glm-4-plus",
|
||||||
|
"glm-4-0520",
|
||||||
|
"glm-4",
|
||||||
|
"glm-4-air",
|
||||||
|
"glm-4-airx",
|
||||||
|
"glm-4-long",
|
||||||
|
"glm-4-flashx",
|
||||||
|
"glm-4-flash",
|
||||||
|
"glm-4v-plus",
|
||||||
|
"glm-4v",
|
||||||
|
"glm-4v-flash", // free
|
||||||
|
"cogview-3-plus",
|
||||||
|
"cogview-3",
|
||||||
|
"cogview-3-flash", // free
|
||||||
|
// 目前无法适配轮询任务
|
||||||
|
// "cogvideox",
|
||||||
|
// "cogvideox-flash", // free
|
||||||
|
];
|
||||||
|
|
||||||
|
let seq = 1000; // 内置的模型序号生成器从1000开始
|
||||||
export const DEFAULT_MODELS = [
|
export const DEFAULT_MODELS = [
|
||||||
...openaiModels.map((name) => ({
|
...openaiModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++, // Global sequence sort(index)
|
||||||
provider: {
|
provider: {
|
||||||
id: "openai",
|
id: "openai",
|
||||||
providerName: "OpenAI",
|
providerName: "OpenAI",
|
||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
|
sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...openaiModels.map((name) => ({
|
...openaiModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
provider: {
|
provider: {
|
||||||
id: "azure",
|
id: "azure",
|
||||||
providerName: "Azure",
|
providerName: "Azure",
|
||||||
providerType: "azure",
|
providerType: "azure",
|
||||||
|
sorted: 2,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...googleModels.map((name) => ({
|
...googleModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
provider: {
|
provider: {
|
||||||
id: "google",
|
id: "google",
|
||||||
providerName: "Google",
|
providerName: "Google",
|
||||||
providerType: "google",
|
providerType: "google",
|
||||||
|
sorted: 3,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...anthropicModels.map((name) => ({
|
...anthropicModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
provider: {
|
provider: {
|
||||||
id: "anthropic",
|
id: "anthropic",
|
||||||
providerName: "Anthropic",
|
providerName: "Anthropic",
|
||||||
providerType: "anthropic",
|
providerType: "anthropic",
|
||||||
|
sorted: 4,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...baiduModels.map((name) => ({
|
...baiduModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
provider: {
|
provider: {
|
||||||
id: "baidu",
|
id: "baidu",
|
||||||
providerName: "Baidu",
|
providerName: "Baidu",
|
||||||
providerType: "baidu",
|
providerType: "baidu",
|
||||||
|
sorted: 5,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...bytedanceModels.map((name) => ({
|
...bytedanceModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
provider: {
|
provider: {
|
||||||
id: "bytedance",
|
id: "bytedance",
|
||||||
providerName: "ByteDance",
|
providerName: "ByteDance",
|
||||||
providerType: "bytedance",
|
providerType: "bytedance",
|
||||||
|
sorted: 6,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...alibabaModes.map((name) => ({
|
...alibabaModes.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
provider: {
|
provider: {
|
||||||
id: "alibaba",
|
id: "alibaba",
|
||||||
providerName: "Alibaba",
|
providerName: "Alibaba",
|
||||||
providerType: "alibaba",
|
providerType: "alibaba",
|
||||||
|
sorted: 7,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...tencentModels.map((name) => ({
|
||||||
|
name,
|
||||||
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
|
provider: {
|
||||||
|
id: "tencent",
|
||||||
|
providerName: "Tencent",
|
||||||
|
providerType: "tencent",
|
||||||
|
sorted: 8,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...moonshotModes.map((name) => ({
|
||||||
|
name,
|
||||||
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
|
provider: {
|
||||||
|
id: "moonshot",
|
||||||
|
providerName: "Moonshot",
|
||||||
|
providerType: "moonshot",
|
||||||
|
sorted: 9,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...iflytekModels.map((name) => ({
|
||||||
|
name,
|
||||||
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
|
provider: {
|
||||||
|
id: "iflytek",
|
||||||
|
providerName: "Iflytek",
|
||||||
|
providerType: "iflytek",
|
||||||
|
sorted: 10,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...xAIModes.map((name) => ({
|
||||||
|
name,
|
||||||
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
|
provider: {
|
||||||
|
id: "xai",
|
||||||
|
providerName: "XAI",
|
||||||
|
providerType: "xai",
|
||||||
|
sorted: 11,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...chatglmModels.map((name) => ({
|
||||||
|
name,
|
||||||
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
|
provider: {
|
||||||
|
id: "chatglm",
|
||||||
|
providerName: "ChatGLM",
|
||||||
|
providerType: "chatglm",
|
||||||
|
sorted: 12,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...deepseekModels.map((name) => ({
|
||||||
|
name,
|
||||||
|
available: true,
|
||||||
|
sorted: seq++,
|
||||||
|
provider: {
|
||||||
|
id: "deepseek",
|
||||||
|
providerName: "DeepSeek",
|
||||||
|
providerType: "deepseek",
|
||||||
|
sorted: 13,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
] as const;
|
] as const;
|
||||||
@@ -345,3 +747,8 @@ export const internalAllowedWebDavEndpoints = [
|
|||||||
"https://webdav.yandex.com",
|
"https://webdav.yandex.com",
|
||||||
"https://app.koofr.net/dav/Koofr",
|
"https://app.koofr.net/dav/Koofr",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_GA_ID = "G-89WN60ZK2E";
|
||||||
|
|
||||||
|
export const SAAS_CHAT_URL = "https://nextchat.dev/chat";
|
||||||
|
export const SAAS_CHAT_UTM_URL = "https://nextchat.dev/chat?utm=github";
|
||||||
|
|||||||
15
app/global.d.ts
vendored
15
app/global.d.ts
vendored
@@ -21,10 +21,23 @@ declare interface Window {
|
|||||||
writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
|
writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
|
||||||
writeTextFile(path: string, data: string): Promise<void>;
|
writeTextFile(path: string, data: string): Promise<void>;
|
||||||
};
|
};
|
||||||
notification:{
|
notification: {
|
||||||
requestPermission(): Promise<Permission>;
|
requestPermission(): Promise<Permission>;
|
||||||
isPermissionGranted(): Promise<boolean>;
|
isPermissionGranted(): Promise<boolean>;
|
||||||
sendNotification(options: string | Options): void;
|
sendNotification(options: string | Options): void;
|
||||||
};
|
};
|
||||||
|
updater: {
|
||||||
|
checkUpdate(): Promise<UpdateResult>;
|
||||||
|
installUpdate(): Promise<void>;
|
||||||
|
onUpdaterEvent(
|
||||||
|
handler: (status: UpdateStatusResult) => void,
|
||||||
|
): Promise<UnlistenFn>;
|
||||||
|
};
|
||||||
|
http: {
|
||||||
|
fetch<T>(
|
||||||
|
url: string,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
): Promise<Response<T>>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
1
app/icons/arrow.svg
Normal file
1
app/icons/arrow.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon--SJP_d" width="16" height="16" fill="none" viewBox="0 0 16 16" style="min-width: 16px; min-height: 16px;"><g><path data-follow-fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M5.248 14.444a.625.625 0 0 1-.005-.884l5.068-5.12a.625.625 0 0 0 0-.88L5.243 2.44a.625.625 0 1 1 .889-.88l5.067 5.121c.723.73.723 1.907 0 2.638l-5.067 5.12a.625.625 0 0 1-.884.005Z" fill="currentColor"></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 426 B |
7
app/icons/discovery.svg
Normal file
7
app/icons/discovery.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" height="1.2rem" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path
|
||||||
|
d="M11.307 9.739L15 9l-.739 3.693a2 2 0 0 1-1.568 1.569L9 15l.739-3.693a2 2 0 0 1 1.568-1.568" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 371 B |
1
app/icons/fire.svg
Normal file
1
app/icons/fire.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12.832 21.801c3.126-.626 7.168-2.875 7.168-8.69c0-5.291-3.873-8.815-6.658-10.434c-.619-.36-1.342.113-1.342.828v1.828c0 1.442-.606 4.074-2.29 5.169c-.86.559-1.79-.278-1.894-1.298l-.086-.838c-.1-.974-1.092-1.565-1.87-.971C4.461 8.46 3 10.33 3 13.11C3 20.221 8.289 22 10.933 22q.232 0 .484-.015C10.111 21.874 8 21.064 8 18.444c0-2.05 1.495-3.435 2.631-4.11c.306-.18.663.055.663.41v.59c0 .45.175 1.155.59 1.637c.47.546 1.159-.026 1.214-.744c.018-.226.246-.37.442-.256c.641.375 1.46 1.175 1.46 2.473c0 2.048-1.129 2.99-2.168 3.357"/></svg>
|
||||||
|
After Width: | Height: | Size: 648 B |
4
app/icons/hd.svg
Normal file
4
app/icons/hd.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-badge-hd" viewBox="0 0 16 16">
|
||||||
|
<path d="M7.396 11V5.001H6.209v2.44H3.687V5H2.5v6h1.187V8.43h2.522V11zM8.5 5.001V11h2.188c1.811 0 2.685-1.107 2.685-3.015 0-1.894-.86-2.984-2.684-2.984zm1.187.967h.843c1.112 0 1.622.686 1.622 2.04 0 1.353-.505 2.02-1.622 2.02h-.843z"/>
|
||||||
|
<path d="M14 3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 514 B |
11
app/icons/headphone.svg
Normal file
11
app/icons/headphone.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 28C4 26.8954 4.89543 26 6 26H10V38H6C4.89543 38 4 37.1046 4 36V28Z" fill="none" />
|
||||||
|
<path d="M38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||||
|
fill="none" />
|
||||||
|
<path
|
||||||
|
d="M10 36V24C10 16.268 16.268 10 24 10C31.732 10 38 16.268 38 24V36M10 26H6C4.89543 26 4 26.8954 4 28V36C4 37.1046 4.89543 38 6 38H10V26ZM38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||||
|
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M16 32H20L22 26L26 38L28 32H32" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 808 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user