Compare commits
	
		
			591 Commits
		
	
	
		
			v2.10.2
			...
			87325fad74
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 87325fad74 | ||
|  | 122aa94c9f | ||
|  | e2e8a45104 | ||
|  | fb5fc13f72 | ||
|  | edb92f7bfb | ||
|  | ca865a80dc | ||
|  | cf1c8e8f2a | ||
|  | d948be2372 | ||
|  | cc28aef625 | ||
|  | 036358de7c | ||
|  | 0958b9ee12 | ||
|  | aff1d7ecd6 | ||
|  | 42fdbd9bb8 | ||
|  | 034c82e514 | ||
|  | 14ff46b5cd | ||
|  | c9099ca0a5 | ||
|  | 58b144b345 | ||
|  | af21c57e77 | ||
|  | 624e4dbaaf | ||
|  | 9bbb4f396b | ||
|  | 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 | ||
|  | 3935c725c9 | ||
|  | 908ee0060f | ||
|  | 82e6fd7bb5 | ||
|  | 6b98b14179 | ||
|  | 1ecefd88f7 | ||
|  | 2e9e20ce7c | ||
|  | fb60fbb217 | ||
|  | 4199e17da0 | ||
|  | dfd089132d | ||
|  | 3a10f58b28 | ||
|  | 9d55adbaf2 | ||
|  | 00be2be24f | ||
|  | 5b126c7e52 | ||
|  | 1943f3b53f | ||
|  | 4a0bef9afb | ||
|  | dfd2a53129 | ||
|  | aa4e855012 | ||
|  | d6089e6309 | ||
|  | 038e6df8f0 | ||
|  | 2fd68bcac3 | ||
|  | e468fecf12 | ||
|  | fc31d8e5d1 | ||
|  | 115f357a07 | ||
|  | ac04a1cac8 | ||
|  | 87a286ef07 | ||
|  | 622d8a4edb | ||
|  | b44086f0dc | ||
|  | 0236e13187 | ||
|  | a3d4a7253f | ||
|  | e079f1b31a | ||
|  | 9a78a72eb3 | ||
|  | 862c2e8810 | ||
|  | 12cad4c418 | ||
|  | 89b9d3a7f7 | ||
|  | 57831d4880 | ||
|  | 052004d70e | ||
|  | a765237441 | ||
|  | ac470a6d07 | ||
|  | 7237d33be3 | ||
|  | 1610b480af | ||
|  | 287fa0a39c | ||
|  | afa1a4303b | ||
|  | 28cedb1493 | ||
|  | a280e25ee7 | ||
|  | 8464ca8931 | ||
|  | 44340f277d | ||
|  | 74bf99f9ea | ||
|  | 9caf820758 | ||
|  | 26c2598f56 | ||
|  | 6d9abf261c | ||
|  | b16d0185dd | ||
|  | ca51c2e93d | ||
|  | da254975cd | ||
|  | 86bae6be3a | ||
|  | fc8c7ef18d | ||
|  | f654629c6a | ||
|  | d4a87c561a | ||
|  | d8872d48b3 | ||
|  | ed16c2c18d | ||
|  | 0478a6ce3b | ||
|  | 68b60e82ba | ||
|  | 8edc0989e2 | ||
|  | fbf3551bbe | ||
|  | 238d3122c4 | ||
|  | ee22fba448 | ||
|  | f8a2a28bff | ||
|  | b3cfaf1420 | ||
|  | 2e9f701bb7 | ||
|  | 9aabc4ad6a | ||
|  | 5dc731bc77 | ||
|  | 32d05c9855 | ||
|  | 5a0d0c0b75 | ||
|  | 17d4a8fb26 | ||
|  | 348c1a7d5f | ||
|  | 49151dabf5 | ||
|  | eb7c7cdcb6 | ||
|  | ec95292209 | ||
|  | 4b84fb328c | ||
|  | 47d27c1f41 | ||
|  | 5267ad46da | ||
|  | 94bc880b7f | ||
|  | bab3e0bc9b | ||
|  | b3a324b6f5 | ||
|  | a1117cd4ee | ||
|  | 6ece818d69 | ||
|  | 5df09d5e2a | ||
|  | 33450ce429 | ||
|  | e2f0206d88 | ||
|  | 3767b2c7f9 | ||
|  | 1779f1f3da | ||
|  | b9d1dca65d | ||
|  | 8e4d26163a | ||
|  | 53c1176cbf | ||
|  | 46d3e7884b | ||
|  | a0290b0c1b | ||
|  | b4ae706914 | ||
|  | 476bdac717 | ||
|  | 831627268d | ||
|  | 9b97dca601 | ||
|  | 4ea8c0802a | ||
|  | 9203870df5 | ||
|  | e8088d6e38 | ||
|  | 59d9bcdd27 | ||
|  | 9d1b13ba73 | ||
|  | dd1030139b | ||
|  | 30ca2117bb | ||
|  | 89024a8dc8 | ||
|  | 728c38396a | ||
|  | d61cb98ac7 | ||
|  | a7ceb61e27 | ||
|  | 74b915a790 | ||
|  | 01ea690421 | ||
|  | 17cc9284a0 | ||
|  | 498d0f0b8b | ||
|  | 89049e1a22 | ||
|  | 5e7254e8dc | ||
|  | f8c2732fdc | ||
|  | fec36eb298 | ||
|  | 2299a4156d | ||
|  | 32b82b9cb3 | ||
|  | ba6039fc8b | ||
|  | 6885812d21 | ||
|  | 844025ec14 | ||
|  | 94bc91c554 | ||
|  | 044c16da4c | ||
|  | cd4784c54a | ||
|  | 814aaa4a69 | ||
|  | e3b3a4fefa | ||
|  | 3fcbb3010d | ||
|  | 7573a19dc9 | ||
|  | 3628d68d9a | ||
|  | 23872086fa | ||
|  | bb349a03da | ||
|  | 82be426f78 | ||
|  | 9d2a633f5e | ||
|  | 1149d45589 | ||
|  | 9d7e19cebf | ||
|  | b3023543d6 | ||
|  | c229d2c3ce | ||
|  | 47ea383ddd | ||
|  | f2a35f1114 | ||
|  | 147fc9a35a | ||
|  | 93a03f8fe4 | ||
|  | 230e3823a9 | ||
|  | b14a0f24ae | ||
|  | 5295802720 | ||
|  | fadd7f6eb4 | ||
|  | 011b76e4e7 | ||
|  | f68cd2c5c0 | ||
|  | 6ac9789a1c | ||
|  | 2b0153807c | ||
|  | 34ab37f31e | ||
|  | 71af2628eb | ||
|  | 15f028abfb | ||
|  | 9bdd37bb63 | ||
|  | d726c71141 | ||
|  | 1caa61f4c0 | ||
|  | f3e3f08377 | ||
|  | 2ec8b7a804 | ||
|  | 9f7d137b05 | ||
|  | 7218f13783 | ||
|  | fa31e7802c | ||
|  | 9b3b4494ba | ||
|  | 785d3748e1 | ||
|  | 5e0657ce55 | ||
|  | 700b06f9c5 | ||
|  | b58bbf8eb4 | ||
|  | 2d1f522aaf | ||
|  | 0b2863dfab | ||
|  | 70907ead8a | ||
|  | 6dc4844c12 | ||
|  | 14bc1b6aac | ||
|  | 183ad2a34b | ||
|  | d9758be3ae | ||
|  | 6b1b530443 | ||
|  | 1c20137b0e | ||
|  | c4a6c933f8 | ||
|  | 31d9444264 | ||
|  | 8cb204e22e | ||
|  | 97aa72ec5b | ||
|  | a68341eae6 | ||
|  | aa08183439 | ||
|  | 7a5596b909 | ||
|  | b9ffd50992 | ||
|  | 14f2a8f370 | ||
|  | e7b16bfbc0 | ||
|  | a16725ac17 | ||
|  | 2803a91673 | ||
|  | cf2fce7666 | ||
|  | 1609abd166 | ||
|  | 88c74ae18d | ||
|  | 78e2b41e0c | ||
|  | 501f8b028b | ||
|  | 54401162bd | ||
|  | 7fde9327a2 | ||
|  | bbbf59c74a | ||
|  | c4ad66f745 | ||
|  | 69974d5651 | ||
|  | ce3b6a04c2 | ||
|  | 37e2517dac | ||
|  | d65ddead11 | ||
|  | 34034be0e3 | ||
|  | d21481173e | ||
|  | fa6ebadc7b | ||
|  | a51fb24f36 | ||
|  | c359b30763 | ||
|  | 95e3b156c0 | ||
|  | b972a0d081 | ||
|  | 20749355da | ||
|  | dad122199a | ||
|  | 9fb8fbcc65 | ||
|  | 78e7ea72dc | ||
|  | 4640060891 | ||
|  | 6efe4fb734 | ||
|  | 74986803db | ||
|  | 9b0a705055 | ||
|  | 163fc9e3a3 | ||
|  | 24bf7950d8 | ||
|  | b6735bffe4 | ||
|  | 1d8fd480ca | ||
|  | da2e2372aa | ||
|  | f3b972e573 | ||
|  | bf3bc3c7e9 | ||
|  | 38664487a0 | ||
|  | de1111286c | ||
|  | d89a12aa05 | ||
|  | 754acd7c26 | ||
|  | c3e2f3b714 | ||
|  | 22ef3d3a46 | ||
|  | 7f3516f44f | ||
|  | bfdb47a7ed | ||
|  | f55f04ab4f | ||
|  | 01c9dbc1fd | ||
|  | 0aa807df19 | ||
|  | 48d44ece58 | ||
|  | e58cb2b0db | ||
|  | bffd9d9173 | ||
|  | 8688842984 | ||
|  | cf29a8f2c8 | ||
|  | 1e00c89988 | ||
|  | 0eccb547b5 | ||
|  | 4789a7f6a9 | ||
|  | 0bf758afd4 | ||
|  | 6612550c06 | ||
|  | d411159124 | ||
|  | cf635a5e6f | ||
|  | 3a007e4f3d | ||
|  | 9faab960f6 | ||
|  | 5df8b1d183 | ||
|  | ef5f910f19 | ||
|  | fffbee80e8 | ||
|  | 6b30e167e1 | ||
|  | 8ec721259a | ||
|  | 9d7ce207b6 | ||
|  | 2d1f0c9f57 | ||
|  | d3131d2f55 | ||
|  | c10447df79 | ||
|  | 212ae76d76 | ||
|  | cd48f7eff4 | ||
|  | 3513c6801e | ||
|  | 864529cbf6 | ||
|  | 58c0d3e12d | ||
|  | a1493bfb4e | ||
|  | b3e856df1d | ||
|  | 8ef2617eec | ||
|  | 1da7d81122 | ||
|  | a103582346 | ||
|  | 7b61d05e88 | ||
|  | 6fc7c50f19 | ||
|  | 9d728ec3c5 | ||
|  | 9cd3358e4e | ||
|  | 4cd94370e8 | ||
|  | 52312dbd23 | ||
|  | b2e8a1eaa2 | ||
|  | 506c17a093 | ||
|  | 69642fba52 | ||
|  | 7d647c981f | ||
|  | 9aec3b714e | ||
|  | dd4648ed9a | ||
|  | 1cd0beb231 | ||
|  | c96e4b7966 | ||
|  | b7aab3c102 | ||
|  | fcb1a657e3 | ||
|  | 9b2cb1e1c3 | ||
|  | fb8b8d28da | ||
|  | ad80153bbb | ||
|  | 9564b261d5 | ||
|  | 1e2a662fa6 | ||
|  | 51f7daaeaf | ||
|  | f742a7ec4e | ||
|  | e2c0d2a07b | ||
|  | d112dc41b2 | ||
|  | 2322851ac4 | ||
|  | aa084ea09a | ||
|  | 6520f9b7eb | ||
|  | fd8d0a1746 | ||
|  | af3ebacee6 | ||
|  | 55d7014301 | ||
|  | b72d7fbeda | ||
|  | ee15c14049 | ||
|  | 1756bdd033 | ||
|  | 0cffaf8dc5 | ||
|  | 55a93e7b47 | ||
|  | 5dc5bfb797 | ||
|  | f101ee3c4f | ||
|  | 6319f41b2c | ||
|  | 6c718ada1b | ||
|  | 67acc38a1f | ||
|  | dd1d8509f0 | ||
|  | 79f342439a | ||
|  | 13db64f0ec | ||
|  | 908ce3bbd9 | ||
|  | df3313971d | ||
|  | b175132854 | ||
|  | 4cb0655192 | ||
|  | 8b191bd2f7 | ||
|  | f3106e3bbb | ||
|  | 7fcfbc3729 | ||
|  | 598468c2b7 | ||
|  | 84681d3878 | ||
|  | c7b14cba4d | ||
|  | d508127452 | ||
|  | 984c79e2d2 | ||
|  | 6cb296f952 | ||
|  | db533fc166 | ||
|  | 02b0e79ba3 | ||
|  | 1b83dd0a8a | ||
|  | 9b982b408d | ||
|  | 9b03ab830d | ||
|  | 264da6798c | ||
|  | f68b8afa8d | ||
|  | 63f9063255 | ||
|  | 6dad353e1c | ||
|  | 5446d8d4a2 | ||
|  | ef7617d545 | ||
|  | 0fbb560e90 | ||
|  | 86b5c55855 | ||
|  | 768decde93 | ||
|  | 3cb4315193 | ||
|  | 69b079c86e | ||
|  | 9f3fc5eb9f | ||
|  | 15e595837b | ||
|  | 17e57bb28e | ||
|  | 4d0c77b973 | ||
|  | f8b180ac44 | ||
|  | cd30368da9 | ||
|  | 27ed57a648 | ||
|  | e38b527ac2 | ||
|  | 113d9612db | ||
|  | 6b3daec23f | ||
|  | e056a1d46d | ||
|  | 57026f6262 | ||
|  | 8ef77f50c3 | ||
|  | 93e21515e5 | ||
|  | 24caa3b97b | ||
|  | c93b36fe79 | ||
|  | 0de9242a26 | ||
|  | 53fb52c6c0 | ||
|  | afaa529ba6 | ||
|  | 43824bd621 | ||
|  | 3c97a4f5a1 | ||
|  | 711bf190d4 | ||
|  | 1049006cf9 | ||
|  | 76603d108d | ||
|  | 5bc3930230 | ||
|  | e5edd851b3 | ||
|  | dcad400758 | ||
|  | a1aaea9c55 | ||
|  | a4e4286e04 | ||
|  | 6dd7a6a171 | ||
|  | 8e554a87b0 | ||
|  | f1b4c083a4 | ||
|  | 90af4e3b77 | ||
|  | e8d76a513d | ||
|  | 29e03b88c7 | ||
|  | ebbd870150 | ||
|  | 5bf402710f | ||
|  | c0c54e5709 | ||
|  | 3ba984d09e | ||
|  | f274683d46 | ||
|  | 2053db4cfc | ||
|  | e20ce8e335 | ||
|  | 9fd750511c | ||
|  | 028957fcdc | ||
|  | a4c54cae60 | ||
|  | 754303e7c7 | ||
|  | cc0eae7153 | ||
|  | 066ca9e552 | ||
|  | 7c04a90d77 | ||
|  | a8a65ac769 | ||
|  | aec3c5d6cc | ||
|  | a22141c2eb | ||
|  | 99aa064319 | ||
|  | 6aaf83f3c2 | ||
|  | 133ce39a13 | ||
|  | 8645214654 | ||
|  | eebc334e02 | ||
|  | 038fa3b301 | ||
|  | 9a8497299d | ||
|  | 61ce3868b5 | ||
|  | 844c2a26bc | ||
|  | a15c4d9c20 | ||
|  | ff9f0e60ac | ||
|  | 2bf6111bf5 | ||
|  | ad10a11903 | ||
|  | c22153a4eb | ||
|  | 5348d57057 | ||
|  | 052524dabd | ||
|  | e33d05cfe5 | ||
|  | 5529ece220 | ||
|  | e71094d4a8 | ||
|  | 98aa023d70 | ||
|  | e1066434d0 | ||
|  | 86ae4b2a75 | ||
|  | ed8099bf1e | ||
|  | 524c9beee4 | ||
|  | 99fb9dcf11 | ||
|  | 1294817103 | ||
|  | 9775660da7 | ||
|  | e7051353eb | ||
|  | bd19e97cf8 | ||
|  | 8b821ac0c9 | ||
|  | 43e5dc2292 | ||
|  | 08fa22749a | ||
|  | c197962851 | ||
|  | 44a51273be | ||
|  | e3b3ae97bc | ||
|  | 410a22dc63 | ||
|  | 069766d581 | ||
|  | f22e36e52f | ||
|  | bc1794fb4a | ||
|  | aacd26c7db | ||
|  | ff166f7b4c | ||
|  | bf1b5c3951 | ||
|  | 22baebaf8c | ||
|  | e756506c18 | ||
|  | fd67f980a5 | ||
|  | e2da3406d2 | ||
|  | 05b6d989b6 | ||
|  | 1d6ee64e1d | ||
|  | bfefb99192 | ||
|  | 47ae874e4d | ||
|  | d74f636558 | ||
|  | b8f0822214 | ||
|  | 0869455612 | ||
|  | bca74241e6 | ||
|  | 9d5801fb5f | ||
|  | 462a88ae82 | ||
|  | bf711f2ad7 | ||
|  | 3554872d9a | ||
|  | 86f42d56f2 | ||
|  | f05bf0a6f6 | ||
|  | 943a2707d2 | ||
|  | 1442337e3c | ||
|  | 8dc8682078 | ||
|  | 36e9c6ac4d | ||
|  | 10ea9bf1e3 | ||
|  | fe0f078353 | ||
|  | 39f3afd52c | ||
|  | 544bab0fe2 | ||
|  | cdf0311d27 | ||
|  | 5610f423d0 | 
| @@ -1,8 +1,97 @@ | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
|  | ||||
| # Runtime data | ||||
| pids | ||||
| *.pid | ||||
| *.seed | ||||
| *.pid.lock | ||||
|  | ||||
| # Directory for instrumented libs generated by jscoverage/JSCover | ||||
| lib-cov | ||||
|  | ||||
| # Coverage directory used by tools like istanbul | ||||
| coverage | ||||
| *.lcov | ||||
|  | ||||
| # nyc test coverage | ||||
| .nyc_output | ||||
|  | ||||
| # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) | ||||
| .grunt | ||||
|  | ||||
| # Node.js dependencies | ||||
| /node_modules | ||||
| /jspm_packages | ||||
|  | ||||
| # TypeScript v1 declaration files | ||||
| typings | ||||
|  | ||||
| # Optional npm cache directory | ||||
| .npm | ||||
|  | ||||
| # Optional eslint cache | ||||
| .eslintcache | ||||
|  | ||||
| # Optional REPL history | ||||
| .node_repl_history | ||||
|  | ||||
| # Output of 'npm pack' | ||||
| *.tgz | ||||
|  | ||||
| # Yarn Integrity file | ||||
| .yarn-integrity | ||||
|  | ||||
| # dotenv environment variable files | ||||
| .env | ||||
| .env.test | ||||
|  | ||||
| # local env files | ||||
| .env*.local | ||||
|  | ||||
| # docker-compose env files | ||||
| .env | ||||
| # Next.js build output | ||||
| .next | ||||
| out | ||||
|  | ||||
| # Nuxt.js build output | ||||
| .nuxt | ||||
| dist | ||||
|  | ||||
| # Gatsby files | ||||
| .cache/ | ||||
|  | ||||
|  | ||||
| # Vuepress build output | ||||
| .vuepress/dist | ||||
|  | ||||
| # Serverless directories | ||||
| .serverless/ | ||||
|  | ||||
| # FuseBox cache | ||||
| .fusebox/ | ||||
|  | ||||
| # DynamoDB Local files | ||||
| .dynamodb/ | ||||
|  | ||||
| # Temporary folders | ||||
| tmp | ||||
| temp | ||||
|  | ||||
| # IDE and editor directories | ||||
| .idea | ||||
| .vscode | ||||
| *.swp | ||||
| *.swo | ||||
| *~ | ||||
|  | ||||
| # OS generated files | ||||
| .DS_Store | ||||
| Thumbs.db | ||||
|  | ||||
| # secret key | ||||
| *.key | ||||
| *.key.pub | ||||
| *.key.pub | ||||
|   | ||||
| @@ -1,21 +1,20 @@ | ||||
|  | ||||
| # Your openai api key. (required) | ||||
| OPENAI_API_KEY=sk-xxxx | ||||
|  | ||||
| # Access passsword, separated by comma. (optional) | ||||
| # Access password, separated by comma. (optional) | ||||
| CODE=your-password | ||||
|  | ||||
| # You can start service behind a proxy | ||||
| # You can start service behind a proxy. (optional) | ||||
| PROXY_URL=http://localhost:7890 | ||||
|  | ||||
| # (optional) | ||||
| # Default: Empty | ||||
| # Googel 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. | ||||
| GOOGLE_API_KEY= | ||||
|  | ||||
| # (optional) | ||||
| # Default: https://generativelanguage.googleapis.com/ | ||||
| # Googel Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url. | ||||
| # Google Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url. | ||||
| GOOGLE_URL= | ||||
|  | ||||
| # Override openai api request base url. (optional) | ||||
| @@ -47,3 +46,24 @@ ENABLE_BALANCE_QUERY= | ||||
| # If you want to disable parse settings from url, set this value to 1. | ||||
| DISABLE_FAST_LINK= | ||||
|  | ||||
| # (optional) | ||||
| # Default: Empty | ||||
| # To control custom models, use + to add a custom model, use - to hide a model, use name=displayName to customize model name, separated by comma. | ||||
| CUSTOM_MODELS= | ||||
|  | ||||
| # (optional) | ||||
| # Default: Empty | ||||
| # Change default model | ||||
| DEFAULT_MODEL= | ||||
|  | ||||
| # anthropic claude Api Key.(optional) | ||||
| ANTHROPIC_API_KEY= | ||||
|  | ||||
| ### anthropic claude Api version. (optional) | ||||
| ANTHROPIC_API_VERSION= | ||||
|  | ||||
| ### anthropic claude Api url (optional) | ||||
| ANTHROPIC_URL= | ||||
|  | ||||
| ### (optional) | ||||
| WHITE_WEBDEV_ENDPOINTS= | ||||
							
								
								
									
										80
									
								
								.github/ISSUE_TEMPLATE/1_bug_report.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| name: '🐛 Bug Report' | ||||
| description: 'Report an bug' | ||||
| title: '[Bug] ' | ||||
| labels: ['bug'] | ||||
| body: | ||||
|   - type: dropdown | ||||
|     attributes: | ||||
|       label: '📦 Deployment Method' | ||||
|       multiple: true | ||||
|       options: | ||||
|         - 'Official installation package' | ||||
|         - 'Vercel' | ||||
|         - 'Zeabur' | ||||
|         - 'Sealos' | ||||
|         - 'Netlify' | ||||
|         - 'Docker' | ||||
|         - 'Other' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     attributes: | ||||
|       label: '📌 Version' | ||||
|     validations: | ||||
|       required: true | ||||
|    | ||||
|   - type: dropdown | ||||
|     attributes: | ||||
|       label: '💻 Operating System' | ||||
|       multiple: true | ||||
|       options: | ||||
|         - 'Windows' | ||||
|         - 'macOS' | ||||
|         - 'Ubuntu' | ||||
|         - 'Other Linux' | ||||
|         - 'iOS' | ||||
|         - 'iPad OS' | ||||
|         - 'Android' | ||||
|         - 'Other' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     attributes: | ||||
|       label: '📌 System Version' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: dropdown | ||||
|     attributes: | ||||
|       label: '🌐 Browser' | ||||
|       multiple: true | ||||
|       options: | ||||
|         - 'Chrome' | ||||
|         - 'Edge' | ||||
|         - 'Safari' | ||||
|         - 'Firefox' | ||||
|         - 'Other' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     attributes: | ||||
|       label: '📌 Browser Version' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🐛 Bug Description' | ||||
|       description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail. | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '📷 Recurrence Steps' | ||||
|       description: A clear and concise description of how to recurrence. | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🚦 Expected Behavior' | ||||
|       description: A clear and concise description of what you expected to happen. | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '📝 Additional Information' | ||||
|       description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here. | ||||
							
								
								
									
										80
									
								
								.github/ISSUE_TEMPLATE/1_bug_report_cn.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| name: '🐛 反馈缺陷' | ||||
| description: '反馈一个问题/缺陷' | ||||
| title: '[Bug] ' | ||||
| labels: ['bug'] | ||||
| body: | ||||
|   - type: dropdown | ||||
|     attributes: | ||||
|       label: '📦 部署方式' | ||||
|       multiple: true | ||||
|       options: | ||||
|         - '官方安装包' | ||||
|         - 'Vercel' | ||||
|         - 'Zeabur' | ||||
|         - 'Sealos' | ||||
|         - 'Netlify' | ||||
|         - 'Docker' | ||||
|         - 'Other' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     attributes: | ||||
|       label: '📌 软件版本' | ||||
|     validations: | ||||
|       required: true | ||||
|  | ||||
|   - type: dropdown | ||||
|     attributes: | ||||
|       label: '💻 系统环境' | ||||
|       multiple: true | ||||
|       options: | ||||
|         - 'Windows' | ||||
|         - 'macOS' | ||||
|         - 'Ubuntu' | ||||
|         - 'Other Linux' | ||||
|         - 'iOS' | ||||
|         - 'iPad OS' | ||||
|         - 'Android' | ||||
|         - 'Other' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     attributes: | ||||
|       label: '📌 系统版本' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: dropdown | ||||
|     attributes: | ||||
|       label: '🌐 浏览器' | ||||
|       multiple: true | ||||
|       options: | ||||
|         - 'Chrome' | ||||
|         - 'Edge' | ||||
|         - 'Safari' | ||||
|         - 'Firefox' | ||||
|         - 'Other' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     attributes: | ||||
|       label: '📌 浏览器版本' | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🐛 问题描述' | ||||
|       description: 请提供一个清晰且简洁的问题描述,若上述选项为`Other`,也请详细说明。 | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '📷 复现步骤' | ||||
|       description: 请提供一个清晰且简洁的描述,说明如何复现问题。 | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🚦 期望结果' | ||||
|       description: 请提供一个清晰且简洁的描述,说明您期望发生什么。 | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '📝 补充信息' | ||||
|       description: 如果您的问题需要进一步说明,或者您遇到的问题无法在一个简单的示例中复现,请在这里添加更多信息。 | ||||
							
								
								
									
										21
									
								
								.github/ISSUE_TEMPLATE/2_feature_request.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| name: '🌠 Feature Request' | ||||
| description: 'Suggest an idea' | ||||
| title: '[Feature Request] ' | ||||
| labels: ['enhancement'] | ||||
| body: | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🥰 Feature Description' | ||||
|       description: Please add a clear and concise description of the problem you are seeking to solve with this feature request. | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🧐 Proposed Solution' | ||||
|       description: Describe the solution you'd like in a clear and concise manner. | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '📝 Additional Information' | ||||
|       description: Add any other context about the problem here. | ||||
							
								
								
									
										21
									
								
								.github/ISSUE_TEMPLATE/2_feature_request_cn.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| name: '🌠 功能需求' | ||||
| description: '提出需求或建议' | ||||
| title: '[Feature Request] ' | ||||
| labels: ['enhancement'] | ||||
| body: | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🥰 需求描述' | ||||
|       description: 请添加一个清晰且简洁的问题描述,阐述您希望通过这个功能需求解决的问题。 | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '🧐 解决方案' | ||||
|       description: 请清晰且简洁地描述您想要的解决方案。 | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: '📝 补充信息' | ||||
|       description: 在这里添加关于问题的任何其他背景信息。 | ||||
							
								
								
									
										43
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,43 +0,0 @@ | ||||
| --- | ||||
| name: Bug report | ||||
| about: Create a report to help us improve | ||||
| title: "[Bug] " | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **Describe the bug** | ||||
| A clear and concise description of what the bug is. | ||||
|  | ||||
| **To Reproduce** | ||||
| Steps to reproduce the behavior: | ||||
| 1. Go to '...' | ||||
| 2. Click on '....' | ||||
| 3. Scroll down to '....' | ||||
| 4. See error | ||||
|  | ||||
| **Expected behavior** | ||||
| A clear and concise description of what you expected to happen. | ||||
|  | ||||
| **Screenshots** | ||||
| If applicable, add screenshots to help explain your problem. | ||||
|  | ||||
| **Deployment** | ||||
| - [ ] Docker | ||||
| - [ ] Vercel | ||||
| - [ ] Server | ||||
|  | ||||
| **Desktop (please complete the following information):** | ||||
|  - OS: [e.g. iOS] | ||||
|  - Browser [e.g. chrome, safari] | ||||
|  - Version [e.g. 22] | ||||
|  | ||||
| **Smartphone (please complete the following information):** | ||||
|  - Device: [e.g. iPhone6] | ||||
|  - OS: [e.g. iOS8.1] | ||||
|  - Browser [e.g. stock browser, safari] | ||||
|  - Version [e.g. 22] | ||||
|  | ||||
| **Additional Logs** | ||||
| Add any logs about the problem here. | ||||
							
								
								
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,20 +0,0 @@ | ||||
| --- | ||||
| name: Feature request | ||||
| about: Suggest an idea for this project | ||||
| title: "[Feature] " | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **Is your feature request related to a problem? Please describe.** | ||||
| A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] | ||||
|  | ||||
| **Describe the solution you'd like** | ||||
| A clear and concise description of what you want to happen. | ||||
|  | ||||
| **Describe alternatives you've considered** | ||||
| A clear and concise description of any alternative solutions or features you've considered. | ||||
|  | ||||
| **Additional context** | ||||
| Add any other context or screenshots about the feature request here. | ||||
							
								
								
									
										24
									
								
								.github/ISSUE_TEMPLATE/功能建议.md
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,24 +0,0 @@ | ||||
| --- | ||||
| name: 功能建议 | ||||
| about: 请告诉我们你的灵光一闪 | ||||
| title: "[Feature] " | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| > 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。 | ||||
|  | ||||
| > [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) | ||||
|  | ||||
| **这个功能与现有的问题有关吗?** | ||||
| 如果有关,请在此列出链接或者描述问题。 | ||||
|  | ||||
| **你想要什么功能或者有什么建议?** | ||||
| 尽管告诉我们。 | ||||
|  | ||||
| **有没有可以参考的同类竞品?** | ||||
| 可以给出参考产品的链接或者截图。 | ||||
|  | ||||
| **其他信息** | ||||
| 可以说说你的其他考虑。 | ||||
							
								
								
									
										36
									
								
								.github/ISSUE_TEMPLATE/反馈问题.md
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,36 +0,0 @@ | ||||
| --- | ||||
| name: 反馈问题 | ||||
| about: 请告诉我们你遇到的问题 | ||||
| title: "[Bug] " | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| > 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。 | ||||
|  | ||||
| > [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) | ||||
|  | ||||
| **反馈须知** | ||||
|  | ||||
| ⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭,如果没有提供下方的信息,我们无法定位你的问题。 | ||||
|  | ||||
| 请在下方中括号内输入 x 来表示你已经知晓相关内容。 | ||||
| - [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答; | ||||
| - [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。 | ||||
| - [ ] 我确认已经在 [Vercel 使用教程](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/vercel-cn.md) 中搜索了此次反馈的问题,没有找到解答。 | ||||
|  | ||||
| **描述问题** | ||||
| 请在此描述你遇到了什么问题。 | ||||
|  | ||||
| **如何复现** | ||||
| 请告诉我们你是通过什么操作触发的该问题。 | ||||
|  | ||||
| **截图** | ||||
| 请在此提供控制台截图、屏幕截图或者服务端的 log 截图。 | ||||
|  | ||||
| **一些必要的信息** | ||||
|  - 系统:[比如 windows 10/ macos 12/ linux / android 11 / ios 16] | ||||
|  - 浏览器: [比如 chrome, safari] | ||||
|  - 版本: [填写设置页面的版本号] | ||||
|  - 部署方式:[比如 vercel、docker 或者服务器部署] | ||||
							
								
								
									
										28
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,28 @@ | ||||
| #### 💻 变更类型 | Change Type | ||||
|  | ||||
| <!-- For change type, change [ ] to [x]. --> | ||||
|  | ||||
| - [ ] feat    <!-- 引入新功能 | Introduce new features --> | ||||
| - [ ] fix    <!-- 修复 Bug | Fix a bug --> | ||||
| - [ ] refactor    <!-- 重构代码(既不修复 Bug 也不添加新功能) | Refactor code that neither fixes a bug nor adds a feature --> | ||||
| - [ ] perf    <!-- 提升性能的代码变更 | A code change that improves performance --> | ||||
| - [ ] style    <!-- 添加或更新不影响代码含义的样式文件 | Add or update style files that do not affect the meaning of the code --> | ||||
| - [ ] test    <!-- 添加缺失的测试或纠正现有的测试 | Adding missing tests or correcting existing tests --> | ||||
| - [ ] docs    <!-- 仅文档更新 | Documentation only changes --> | ||||
| - [ ] ci    <!-- 修改持续集成配置文件和脚本 | Changes to our CI configuration files and scripts --> | ||||
| - [ ] chore    <!-- 其他不修改 src 或 test 文件的变更 | Other changes that don’t modify src or test files --> | ||||
| - [ ] build    <!-- 进行架构变更 | Make architectural changes --> | ||||
|  | ||||
| #### 🔀 变更说明 | Description of Change | ||||
|  | ||||
| <!--  | ||||
| 感谢您的 Pull Request ,请提供此 Pull Request 的变更说明 | ||||
| Thank you for your Pull Request. Please provide a description above. | ||||
| --> | ||||
|  | ||||
| #### 📝 补充信息 | Additional Information | ||||
|  | ||||
| <!--  | ||||
| 请添加与此 Pull Request 相关的补充信息 | ||||
| Add any other context about the Pull Request here. | ||||
| --> | ||||
							
								
								
									
										1
									
								
								.github/workflows/deploy_preview.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -5,6 +5,7 @@ on: | ||||
|     types: | ||||
|       - opened | ||||
|       - synchronize | ||||
|       - reopened | ||||
|  | ||||
| env: | ||||
|   VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }} | ||||
|   | ||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -43,4 +43,6 @@ dev | ||||
| .env | ||||
|  | ||||
| *.key | ||||
| *.key.pub | ||||
| *.key.pub | ||||
|  | ||||
| masks.json | ||||
|   | ||||
| @@ -43,7 +43,7 @@ COPY --from=builder /app/.next/server ./.next/server | ||||
| EXPOSE 3000 | ||||
|  | ||||
| CMD if [ -n "$PROXY_URL" ]; then \ | ||||
|     export HOSTNAME="127.0.0.1"; \ | ||||
|     export HOSTNAME="0.0.0.0"; \ | ||||
|     protocol=$(echo $PROXY_URL | cut -d: -f1); \ | ||||
|     host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \ | ||||
|     port=$(echo $PROXY_URL | cut -d: -f3); \ | ||||
|   | ||||
							
								
								
									
										2
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,6 @@ | ||||
| MIT License | ||||
|  | ||||
| Copyright (c) 2023 Zhang Yifei | ||||
| Copyright (c) 2023-2024 Zhang Yifei | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | ||||
|   | ||||
							
								
								
									
										160
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,5 +1,8 @@ | ||||
| <div align="center"> | ||||
| <img src="./docs/images/head-cover.png" alt="icon"/> | ||||
|  | ||||
| <a href='#企业版'> | ||||
|   <img src="./docs/images/ent.svg" alt="icon"/> | ||||
| </a> | ||||
|  | ||||
| <h1 align="center">NextChat (ChatGPT Next Web)</h1> | ||||
|  | ||||
| @@ -14,27 +17,49 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 | ||||
| [![MacOS][MacOS-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) / [Twitter](https://twitter.com/NextChatDev) | ||||
| [Web App](https://app.nextchat.dev/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Enterprise Edition](#enterprise-edition) / [Twitter](https://twitter.com/NextChatDev) | ||||
|  | ||||
| [网页版](https://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) | ||||
| [网页版](https://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) | ||||
|  | ||||
| [web-url]: https://chatgpt.nextweb.fun | ||||
| [web-url]: https://app.nextchat.dev/ | ||||
| [download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases | ||||
| [Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge | ||||
| [Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows | ||||
| [MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple | ||||
| [Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu | ||||
|  | ||||
| [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) | ||||
|  | ||||
| [](https://zeabur.com/templates/ZBUEFA) | ||||
|  | ||||
| [](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
|  | ||||
| [<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA)  [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## Enterprise Edition | ||||
|  | ||||
| Meeting Your Company's Privatization and Customization Deployment Requirements: | ||||
| - **Brand Customization**: Tailored VI/UI to seamlessly align with your corporate brand image. | ||||
| - **Resource Integration**: Unified configuration and management of dozens of AI resources by company administrators, ready for use by team members. | ||||
| - **Permission Control**: Clearly defined member permissions, resource permissions, and knowledge base permissions, all controlled via a corporate-grade Admin Panel. | ||||
| - **Knowledge Integration**: Combining your internal knowledge base with AI capabilities, making it more relevant to your company's specific business needs compared to general AI. | ||||
| - **Security Auditing**: Automatically intercept sensitive inquiries and trace all historical conversation records, ensuring AI adherence to corporate information security standards. | ||||
| - **Private Deployment**: Enterprise-level private deployment supporting various mainstream private cloud solutions, ensuring data security and privacy protection. | ||||
| - **Continuous Updates**: Ongoing updates and upgrades in cutting-edge capabilities like multimodal AI, ensuring consistent innovation and advancement. | ||||
|  | ||||
| For enterprise inquiries, please contact: **business@nextchat.dev** | ||||
|  | ||||
| ## 企业版 | ||||
|  | ||||
| 满足企业用户私有化部署和个性化定制需求: | ||||
| - **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合 | ||||
| - **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用 | ||||
| - **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制 | ||||
| - **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求 | ||||
| - **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范 | ||||
| - **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护 | ||||
| - **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进 | ||||
|  | ||||
| 企业版咨询: **business@nextchat.dev** | ||||
|  | ||||
| <img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601"> | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - **Deploy for free with one-click** on Vercel in under 1 minute | ||||
| @@ -49,6 +74,12 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 | ||||
| - Automatically compresses chat history to support long conversations while also saving your tokens | ||||
| - I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia | ||||
|  | ||||
| <div align="center"> | ||||
|     | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## Roadmap | ||||
|  | ||||
| - [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) | ||||
| @@ -57,10 +88,15 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 | ||||
| - [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) | ||||
| - [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. | ||||
| - [ ] 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 artifacts, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) | ||||
|   - [x] artifacts | ||||
|   - [ ] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) | ||||
| - [ ] local knowledge base | ||||
|  | ||||
| ## What's New | ||||
|  | ||||
| - 🚀 v2.14.0 Now supports  Artifacts & SD  | ||||
| - 🚀 v2.10.1 support Google Gemini Pro model. | ||||
| - 🚀 v2.9.11 you can use azure endpoint now. | ||||
| - 🚀 v2.8 now we have a client that runs across all platforms! | ||||
| @@ -89,15 +125,21 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 | ||||
| - [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) | ||||
| - [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) | ||||
| - [x] 插件机制,支持 artifacts,联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) | ||||
|    - [x] artifacts | ||||
|    - [ ] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) | ||||
|  - [ ] 本地知识库 | ||||
|  | ||||
| ## 最新动态 | ||||
|  | ||||
| - 🚀 v2.14.0 现在支持 Artifacts & SD 了。 | ||||
| - 🚀 v2.10.1 现在支持 Gemini Pro 模型。 | ||||
| - 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 | ||||
| - 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。 | ||||
| - 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。 | ||||
| - 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。 | ||||
| - 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com | ||||
| - 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。 | ||||
| - 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。 | ||||
| - 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 | ||||
|  | ||||
| ## Get Started | ||||
|  | ||||
| @@ -180,7 +222,7 @@ Specify OpenAI organization ID. | ||||
|  | ||||
| ### `AZURE_URL` (optional) | ||||
|  | ||||
| > Example: https://{azure-resource-url}/openai/deployments/{deploy-name} | ||||
| > Example: https://{azure-resource-url}/openai | ||||
|  | ||||
| Azure deploy url. | ||||
|  | ||||
| @@ -200,6 +242,58 @@ Google Gemini Pro Api Key. | ||||
|  | ||||
| Google Gemini Pro Api Url. | ||||
|  | ||||
| ### `ANTHROPIC_API_KEY` (optional) | ||||
|  | ||||
| anthropic claude Api Key. | ||||
|  | ||||
| ### `ANTHROPIC_API_VERSION` (optional) | ||||
|  | ||||
| anthropic claude Api version. | ||||
|  | ||||
| ### `ANTHROPIC_URL` (optional) | ||||
|  | ||||
| anthropic claude Api Url. | ||||
|  | ||||
| ### `BAIDU_API_KEY` (optional) | ||||
|  | ||||
| Baidu Api Key. | ||||
|  | ||||
| ### `BAIDU_SECRET_KEY` (optional) | ||||
|  | ||||
| Baidu Secret Key. | ||||
|  | ||||
| ### `BAIDU_URL` (optional) | ||||
|  | ||||
| Baidu Api Url. | ||||
|  | ||||
| ### `BYTEDANCE_API_KEY` (optional) | ||||
|  | ||||
| ByteDance Api Key. | ||||
|  | ||||
| ### `BYTEDANCE_URL` (optional) | ||||
|  | ||||
| ByteDance Api Url. | ||||
|  | ||||
| ### `ALIBABA_API_KEY` (optional) | ||||
|  | ||||
| Alibaba Cloud Api Key. | ||||
|  | ||||
| ### `ALIBABA_URL` (optional) | ||||
|  | ||||
| Alibaba Cloud Api Url. | ||||
|  | ||||
| ### `IFLYTEK_URL` (Optional) | ||||
|  | ||||
| iflytek Api Url. | ||||
|  | ||||
| ### `IFLYTEK_API_KEY` (Optional) | ||||
|  | ||||
| iflytek Api Key. | ||||
|  | ||||
| ### `IFLYTEK_API_SECRET` (Optional) | ||||
|  | ||||
| iflytek Api Secret. | ||||
|  | ||||
| ### `HIDE_USER_API_KEY` (optional) | ||||
|  | ||||
| > Default: Empty | ||||
| @@ -216,7 +310,7 @@ If you do not want users to use GPT-4, set this value to 1. | ||||
|  | ||||
| > Default: Empty | ||||
|  | ||||
| If you do want users to query balance, set this value to 1, or you should set it to 0. | ||||
| If you do want users to query balance, set this value to 1. | ||||
|  | ||||
| ### `DISABLE_FAST_LINK` (optional) | ||||
|  | ||||
| @@ -233,6 +327,36 @@ 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. | ||||
|  | ||||
| For Azure: use `modelName@azure=deploymentName` to customize model name and deployment name. | ||||
| > Example: `+gpt-3.5-turbo@azure=gpt35` will show option `gpt35(Azure)` in model list. | ||||
| > If you only can use Azure model, `-all,+gpt-3.5-turbo@azure=gpt35` will `gpt35(Azure)` the only option in model list. | ||||
|  | ||||
| For ByteDance: use `modelName@bytedance=deploymentName` to customize model name and deployment name. | ||||
| > Example: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` will show option `Doubao-lite-4k(ByteDance)` in model list. | ||||
|  | ||||
| ### `DEFAULT_MODEL` (optional) | ||||
|  | ||||
| Change default model | ||||
|  | ||||
| ### `WHITE_WEBDEV_ENDPOINTS` (optional) | ||||
|  | ||||
| You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format: | ||||
| - Each address must be a complete endpoint  | ||||
| > `https://xxxx/yyy` | ||||
| - Multiple addresses are connected by ', ' | ||||
|  | ||||
| ### `DEFAULT_INPUT_TEMPLATE` (optional) | ||||
|  | ||||
| Customize the default template used to initialize the User Input Preprocessing configuration item in Settings. | ||||
|  | ||||
| ### `STABILITY_API_KEY` (optional) | ||||
|  | ||||
| Stability API key. | ||||
|  | ||||
| ### `STABILITY_URL` (optional) | ||||
|  | ||||
| Customize Stability API url. | ||||
|  | ||||
| ## Requirements | ||||
|  | ||||
| NodeJS >= 18, Docker >= 20 | ||||
|   | ||||
							
								
								
									
										128
									
								
								README_CN.md
									
									
									
									
									
								
							
							
						
						| @@ -1,22 +1,34 @@ | ||||
| <div align="center"> | ||||
| <img src="./docs/images/icon.svg" alt="预览"/> | ||||
|  | ||||
| <a href='#企业版'> | ||||
|   <img src="./docs/images/ent.svg" alt="icon"/> | ||||
| </a> | ||||
|  | ||||
| <h1 align="center">NextChat</h1> | ||||
|  | ||||
| 一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。 | ||||
|  | ||||
| [演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) | ||||
| [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) /[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) | ||||
|  | ||||
| [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) | ||||
|  | ||||
| [](https://zeabur.com/templates/ZBUEFA) | ||||
|  | ||||
| [](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
|  | ||||
| [<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA)  [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## 企业版 | ||||
|  | ||||
| 满足您公司私有化部署和定制需求 | ||||
| - **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合 | ||||
| - **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用 | ||||
| - **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制 | ||||
| - **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求 | ||||
| - **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范 | ||||
| - **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护 | ||||
| - **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进 | ||||
|  | ||||
| 企业版咨询: **business@nextchat.dev** | ||||
|  | ||||
| <img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601"> | ||||
|  | ||||
| ## 开始使用 | ||||
|  | ||||
| 1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys); | ||||
| @@ -25,6 +37,12 @@ | ||||
| 3. 部署完毕后,即可开始使用; | ||||
| 4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 | ||||
|  | ||||
| <div align="center"> | ||||
|     | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## 保持更新 | ||||
|  | ||||
| 如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。 | ||||
| @@ -94,7 +112,7 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填 | ||||
|  | ||||
| ### `AZURE_URL` (可选) | ||||
|  | ||||
| > 形如:https://{azure-resource-url}/openai/deployments/{deploy-name} | ||||
| > 形如:https://{azure-resource-url}/openai | ||||
|  | ||||
| Azure 部署地址。 | ||||
|  | ||||
| @@ -106,14 +124,68 @@ Azure 密钥。 | ||||
|  | ||||
| Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。 | ||||
|  | ||||
| ### `GOOGLE_API_KEY` (optional) | ||||
| ### `GOOGLE_API_KEY` (可选) | ||||
|  | ||||
| Google Gemini Pro 密钥. | ||||
|  | ||||
| ### `GOOGLE_URL` (optional) | ||||
| ### `GOOGLE_URL` (可选) | ||||
|  | ||||
| Google Gemini Pro Api Url. | ||||
|  | ||||
| ### `ANTHROPIC_API_KEY` (可选) | ||||
|  | ||||
| anthropic claude Api Key. | ||||
|  | ||||
| ### `ANTHROPIC_API_VERSION` (可选) | ||||
|  | ||||
| anthropic claude Api version. | ||||
|  | ||||
| ### `ANTHROPIC_URL` (可选) | ||||
|  | ||||
| anthropic claude Api Url. | ||||
|  | ||||
| ### `BAIDU_API_KEY` (可选) | ||||
|  | ||||
| Baidu Api Key. | ||||
|  | ||||
| ### `BAIDU_SECRET_KEY` (可选) | ||||
|  | ||||
| Baidu Secret Key. | ||||
|  | ||||
| ### `BAIDU_URL` (可选) | ||||
|  | ||||
| Baidu Api Url. | ||||
|  | ||||
| ### `BYTEDANCE_API_KEY` (可选) | ||||
|  | ||||
| ByteDance Api Key. | ||||
|  | ||||
| ### `BYTEDANCE_URL` (可选) | ||||
|  | ||||
| ByteDance Api Url. | ||||
|  | ||||
| ### `ALIBABA_API_KEY` (可选) | ||||
|  | ||||
| 阿里云(千问)Api Key. | ||||
|  | ||||
| ### `ALIBABA_URL` (可选) | ||||
|  | ||||
| 阿里云(千问)Api Url. | ||||
|  | ||||
| ### `IFLYTEK_URL` (可选) | ||||
|  | ||||
| 讯飞星火Api Url. | ||||
|  | ||||
| ### `IFLYTEK_API_KEY` (可选) | ||||
|  | ||||
| 讯飞星火Api Key. | ||||
|  | ||||
| ### `IFLYTEK_API_SECRET` (可选) | ||||
|  | ||||
| 讯飞星火Api Secret. | ||||
|  | ||||
|  | ||||
|  | ||||
| ### `HIDE_USER_API_KEY` (可选) | ||||
|  | ||||
| 如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。 | ||||
| @@ -130,6 +202,13 @@ Google Gemini Pro Api Url. | ||||
|  | ||||
| 如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。 | ||||
|  | ||||
| ### `WHITE_WEBDEV_ENDPOINTS` (可选) | ||||
|  | ||||
| 如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求: | ||||
| - 每一个地址必须是一个完整的 endpoint | ||||
| > `https://xxxx/xxx` | ||||
| - 多个地址以`,`相连 | ||||
|  | ||||
| ### `CUSTOM_MODELS` (可选) | ||||
|  | ||||
| > 示例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` 表示增加 `qwen-7b-chat` 和 `glm-6b` 到模型列表,而从列表中删除 `gpt-3.5-turbo`,并将 `gpt-4-1106-preview` 模型名字展示为 `gpt-4-turbo`。 | ||||
| @@ -137,6 +216,31 @@ Google Gemini Pro Api Url. | ||||
|  | ||||
| 用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。 | ||||
|  | ||||
| 在Azure的模式下,支持使用`modelName@azure=deploymentName`的方式配置模型名称和部署名称(deploy-name) | ||||
| > 示例:`+gpt-3.5-turbo@azure=gpt35`这个配置会在模型列表显示一个`gpt35(Azure)`的选项。 | ||||
| > 如果你只能使用Azure模式,那么设置 `-all,+gpt-3.5-turbo@azure=gpt35` 则可以让对话的默认使用 `gpt35(Azure)` | ||||
|  | ||||
| 在ByteDance的模式下,支持使用`modelName@bytedance=deploymentName`的方式配置模型名称和部署名称(deploy-name) | ||||
| > 示例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx`这个配置会在模型列表显示一个`Doubao-lite-4k(ByteDance)`的选项 | ||||
|  | ||||
|  | ||||
| ### `DEFAULT_MODEL` (可选) | ||||
|  | ||||
| 更改默认模型 | ||||
|  | ||||
| ### `DEFAULT_INPUT_TEMPLATE` (可选) | ||||
|  | ||||
| 自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项 | ||||
|  | ||||
| ### `STABILITY_API_KEY` (optional) | ||||
|  | ||||
| Stability API密钥 | ||||
|  | ||||
| ### `STABILITY_URL` (optional) | ||||
|  | ||||
| 自定义的Stability API请求地址 | ||||
|  | ||||
|  | ||||
| ## 开发 | ||||
|  | ||||
| 点击下方按钮,开始二次开发: | ||||
|   | ||||
							
								
								
									
										310
									
								
								README_JA.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,310 @@ | ||||
| <div align="center"> | ||||
| <img src="./docs/images/ent.svg" alt="プレビュー"/> | ||||
|  | ||||
| <h1 align="center">NextChat</h1> | ||||
|  | ||||
| ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。 | ||||
|  | ||||
| [企業版](#企業版) / [デモ](https://chat-gpt-next-web.vercel.app/) / [フィードバック](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discordに参加](https://discord.gg/zrhvHCr79N) | ||||
|  | ||||
| [<img src="https://vercel.com/button" alt="Zeaburでデプロイ" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Zeaburでデプロイ" height="30">](https://zeabur.com/templates/ZBUEFA)  [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Gitpodで開く" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## 企業版 | ||||
|  | ||||
| あなたの会社のプライベートデプロイとカスタマイズのニーズに応える | ||||
| - **ブランドカスタマイズ**:企業向けに特別に設計された VI/UI、企業ブランドイメージとシームレスにマッチ | ||||
| - **リソース統合**:企業管理者が数十種類のAIリソースを統一管理、チームメンバーはすぐに使用可能 | ||||
| - **権限管理**:メンバーの権限、リソースの権限、ナレッジベースの権限を明確にし、企業レベルのAdmin Panelで統一管理 | ||||
| - **知識の統合**:企業内部のナレッジベースとAI機能を結びつけ、汎用AIよりも企業自身の業務ニーズに近づける | ||||
| - **セキュリティ監査**:機密質問を自動的にブロックし、すべての履歴対話を追跡可能にし、AIも企業の情報セキュリティ基準に従わせる | ||||
| - **プライベートデプロイ**:企業レベルのプライベートデプロイ、主要なプライベートクラウドデプロイをサポートし、データのセキュリティとプライバシーを保護 | ||||
| - **継続的な更新**:マルチモーダル、エージェントなどの最先端機能を継続的に更新し、常に最新であり続ける | ||||
|  | ||||
| 企業版のお問い合わせ: **business@nextchat.dev** | ||||
|  | ||||
|  | ||||
| ## 始めに | ||||
|  | ||||
| 1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する; | ||||
| 2. 右側のボタンをクリックしてデプロイを開始: | ||||
|    [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください; | ||||
| 3. デプロイが完了したら、すぐに使用を開始できます; | ||||
| 4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。 | ||||
|  | ||||
| <div align="center"> | ||||
|     | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
|  | ||||
| ## 更新を維持する | ||||
|  | ||||
| もし上記の手順に従ってワンクリックでプロジェクトをデプロイした場合、「更新があります」というメッセージが常に表示されることがあります。これは、Vercel がデフォルトで新しいプロジェクトを作成するためで、本プロジェクトを fork していないことが原因です。そのため、正しく更新を検出できません。 | ||||
|  | ||||
| 以下の手順に従って再デプロイすることをお勧めします: | ||||
|  | ||||
| - 元のリポジトリを削除する | ||||
| - ページ右上の fork ボタンを使って、本プロジェクトを fork する | ||||
| - Vercel で再度選択してデプロイする、[詳細な手順はこちらを参照してください](./docs/vercel-ja.md)。 | ||||
|  | ||||
|  | ||||
| ### 自動更新を開く | ||||
|  | ||||
| > Upstream Sync の実行エラーが発生した場合は、手動で Sync Fork してください! | ||||
|  | ||||
| プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります: | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ### 手動でコードを更新する | ||||
|  | ||||
| 手動で即座に更新したい場合は、[GitHub のドキュメント](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)を参照して、fork したプロジェクトを上流のコードと同期する方法を確認してください。 | ||||
|  | ||||
| このプロジェクトをスターまたはウォッチしたり、作者をフォローすることで、新機能の更新通知をすぐに受け取ることができます。 | ||||
|  | ||||
|  | ||||
|  | ||||
| ## ページアクセスパスワードを設定する | ||||
|  | ||||
| > パスワードを設定すると、ユーザーは設定ページでアクセスコードを手動で入力しない限り、通常のチャットができず、未承認の状態であることを示すメッセージが表示されます。 | ||||
|  | ||||
| > **警告**:パスワードの桁数は十分に長く設定してください。7桁以上が望ましいです。さもないと、[ブルートフォース攻撃を受ける可能性があります](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。 | ||||
|  | ||||
| このプロジェクトは限られた権限管理機能を提供しています。Vercel プロジェクトのコントロールパネルで、環境変数ページに `CODE` という名前の環境変数を追加し、値をカンマで区切ったカスタムパスワードに設定してください: | ||||
|  | ||||
| ``` | ||||
| code1,code2,code3 | ||||
| ``` | ||||
|  | ||||
| この環境変数を追加または変更した後、**プロジェクトを再デプロイ**して変更を有効にしてください。 | ||||
|  | ||||
|  | ||||
| ## 環境変数 | ||||
|  | ||||
| > 本プロジェクトのほとんどの設定は環境変数で行います。チュートリアル:[Vercel の環境変数を変更する方法](./docs/vercel-ja.md)。 | ||||
|  | ||||
| ### `OPENAI_API_KEY` (必須) | ||||
|  | ||||
| OpenAI の API キー。OpenAI アカウントページで申請したキーをカンマで区切って複数設定できます。これにより、ランダムにキーが選択されます。 | ||||
|  | ||||
| ### `CODE` (オプション) | ||||
|  | ||||
| アクセスパスワード。カンマで区切って複数設定可能。 | ||||
|  | ||||
| **警告**:この項目を設定しないと、誰でもデプロイしたウェブサイトを利用でき、トークンが急速に消耗する可能性があるため、設定をお勧めします。 | ||||
|  | ||||
| ### `BASE_URL` (オプション) | ||||
|  | ||||
| > デフォルト: `https://api.openai.com` | ||||
|  | ||||
| > 例: `http://your-openai-proxy.com` | ||||
|  | ||||
| OpenAI API のプロキシ URL。手動で OpenAI API のプロキシを設定している場合はこのオプションを設定してください。 | ||||
|  | ||||
| > SSL 証明書の問題がある場合は、`BASE_URL` のプロトコルを http に設定してください。 | ||||
|  | ||||
| ### `OPENAI_ORG_ID` (オプション) | ||||
|  | ||||
| OpenAI の組織 ID を指定します。 | ||||
|  | ||||
| ### `AZURE_URL` (オプション) | ||||
|  | ||||
| > 形式: https://{azure-resource-url}/openai/deployments/{deploy-name} | ||||
| > `CUSTOM_MODELS` で `displayName` 形式で {deploy-name} を設定した場合、`AZURE_URL` から {deploy-name} を省略できます。 | ||||
|  | ||||
| Azure のデプロイ URL。 | ||||
|  | ||||
| ### `AZURE_API_KEY` (オプション) | ||||
|  | ||||
| Azure の API キー。 | ||||
|  | ||||
| ### `AZURE_API_VERSION` (オプション) | ||||
|  | ||||
| Azure API バージョン。[Azure ドキュメント](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)で確認できます。 | ||||
|  | ||||
| ### `GOOGLE_API_KEY` (オプション) | ||||
|  | ||||
| Google Gemini Pro API キー。 | ||||
|  | ||||
| ### `GOOGLE_URL` (オプション) | ||||
|  | ||||
| Google Gemini Pro API の URL。 | ||||
|  | ||||
| ### `ANTHROPIC_API_KEY` (オプション) | ||||
|  | ||||
| Anthropic Claude API キー。 | ||||
|  | ||||
| ### `ANTHROPIC_API_VERSION` (オプション) | ||||
|  | ||||
| Anthropic Claude API バージョン。 | ||||
|  | ||||
| ### `ANTHROPIC_URL` (オプション) | ||||
|  | ||||
| Anthropic Claude API の URL。 | ||||
|  | ||||
| ### `BAIDU_API_KEY` (オプション) | ||||
|  | ||||
| Baidu API キー。 | ||||
|  | ||||
| ### `BAIDU_SECRET_KEY` (オプション) | ||||
|  | ||||
| Baidu シークレットキー。 | ||||
|  | ||||
| ### `BAIDU_URL` (オプション) | ||||
|  | ||||
| Baidu API の URL。 | ||||
|  | ||||
| ### `BYTEDANCE_API_KEY` (オプション) | ||||
|  | ||||
| ByteDance API キー。 | ||||
|  | ||||
| ### `BYTEDANCE_URL` (オプション) | ||||
|  | ||||
| ByteDance API の URL。 | ||||
|  | ||||
| ### `ALIBABA_API_KEY` (オプション) | ||||
|  | ||||
| アリババ(千问)API キー。 | ||||
|  | ||||
| ### `ALIBABA_URL` (オプション) | ||||
|  | ||||
| アリババ(千问)API の URL。 | ||||
|  | ||||
| ### `HIDE_USER_API_KEY` (オプション) | ||||
|  | ||||
| ユーザーが API キーを入力できないようにしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `DISABLE_GPT4` (オプション) | ||||
|  | ||||
| ユーザーが GPT-4 を使用できないようにしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `ENABLE_BALANCE_QUERY` (オプション) | ||||
|  | ||||
| バランスクエリ機能を有効にしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `DISABLE_FAST_LINK` (オプション) | ||||
|  | ||||
| リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `WHITE_WEBDEV_ENDPOINTS` (オプション) | ||||
|  | ||||
| アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件: | ||||
| - 各アドレスは完全なエンドポイントでなければなりません。 | ||||
| > `https://xxxx/xxx` | ||||
| - 複数のアドレスは `,` で接続します。 | ||||
|  | ||||
| ### `CUSTOM_MODELS` (オプション) | ||||
|  | ||||
| > 例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` は `qwen-7b-chat` と `glm-6b` をモデルリストに追加し、`gpt-3.5-turbo` を削除し、`gpt-4-1106-preview` のモデル名を `gpt-4-turbo` として表示します。 | ||||
| > すべてのモデルを無効にし、特定のモデルを有効にしたい場合は、`-all,+gpt-3.5-turbo` を使用します。これは `gpt-3.5-turbo` のみを有効にすることを意味します。 | ||||
|  | ||||
| モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。 | ||||
|  | ||||
| Azure モードでは、`modelName@azure=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。 | ||||
| > 例:`+gpt-3.5-turbo@azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。 | ||||
|  | ||||
| ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。 | ||||
| > 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。 | ||||
|  | ||||
| ### `DEFAULT_MODEL` (オプション) | ||||
|  | ||||
| デフォルトのモデルを変更します。 | ||||
|  | ||||
| ### `DEFAULT_INPUT_TEMPLATE` (オプション) | ||||
|  | ||||
| 『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。 | ||||
|  | ||||
|  | ||||
| ## 開発 | ||||
|  | ||||
| 下のボタンをクリックして二次開発を開始してください: | ||||
|  | ||||
| [](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
| コードを書く前に、プロジェクトのルートディレクトリに `.env.local` ファイルを新規作成し、環境変数を記入します: | ||||
|  | ||||
| ``` | ||||
| OPENAI_API_KEY=<your api key here> | ||||
| ``` | ||||
|  | ||||
|  | ||||
| ### ローカル開発 | ||||
|  | ||||
| 1. Node.js 18 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。 | ||||
| 2. `yarn install && yarn dev` を実行します。⚠️ 注意:このコマンドはローカル開発用であり、デプロイには使用しないでください。 | ||||
| 3. ローカルでデプロイしたい場合は、`yarn install && yarn build && yarn start` コマンドを使用してください。プロセスを守るために pm2 を使用することもできます。詳細は ChatGPT にお尋ねください。 | ||||
|  | ||||
|  | ||||
| ## デプロイ | ||||
|  | ||||
| ### コンテナデプロイ(推奨) | ||||
|  | ||||
| > Docker バージョンは 20 以上が必要です。それ以下だとイメージが見つからないというエラーが出ます。 | ||||
|  | ||||
| > ⚠️ 注意:Docker バージョンは最新バージョンより 1~2 日遅れることが多いため、デプロイ後に「更新があります」の通知が出続けることがありますが、正常です。 | ||||
|  | ||||
| ```shell | ||||
| docker pull yidadaa/chatgpt-next-web | ||||
|  | ||||
| docker run -d -p 3000:3000 \ | ||||
|    -e OPENAI_API_KEY=sk-xxxx \ | ||||
|    -e CODE=ページアクセスパスワード \ | ||||
|    yidadaa/chatgpt-next-web | ||||
| ``` | ||||
|  | ||||
| プロキシを指定することもできます: | ||||
|  | ||||
| ```shell | ||||
| docker run -d -p 3000:3000 \ | ||||
|    -e OPENAI_API_KEY=sk-xxxx \ | ||||
|    -e CODE=ページアクセスパスワード \ | ||||
|    --net=host \ | ||||
|    -e PROXY_URL=http://127.0.0.1:7890 \ | ||||
|    yidadaa/chatgpt-next-web | ||||
| ``` | ||||
|  | ||||
| ローカルプロキシがアカウントとパスワードを必要とする場合は、以下を使用できます: | ||||
|  | ||||
| ```shell | ||||
| -e PROXY_URL="http://127.0.0.1:7890 user password" | ||||
| ``` | ||||
|  | ||||
| 他の環境変数を指定する必要がある場合は、上記のコマンドに `-e 環境変数=環境変数値` を追加して指定してください。 | ||||
|  | ||||
|  | ||||
| ### ローカルデプロイ | ||||
|  | ||||
| コンソールで以下のコマンドを実行します: | ||||
|  | ||||
| ```shell | ||||
| bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) | ||||
| ``` | ||||
|  | ||||
| ⚠️ 注意:インストール中に問題が発生した場合は、Docker を使用してデプロイしてください。 | ||||
|  | ||||
|  | ||||
| ## 謝辞 | ||||
|  | ||||
| ### 寄付者 | ||||
|  | ||||
| > 英語版をご覧ください。 | ||||
|  | ||||
| ### 貢献者 | ||||
|  | ||||
| [プロジェクトの貢献者リストはこちら](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors) | ||||
|  | ||||
| ### 関連プロジェクト | ||||
|  | ||||
| - [one-api](https://github.com/songquanpeng/one-api): 一つのプラットフォームで大規模モデルのクォータ管理を提供し、市場に出回っているすべての主要な大規模言語モデルをサポートします。 | ||||
|  | ||||
|  | ||||
| ## オープンソースライセンス | ||||
|  | ||||
| [MIT](https://opensource.org/license/mit/) | ||||
							
								
								
									
										66
									
								
								app/api/[provider]/[...path]/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,66 @@ | ||||
| import { ApiPath } from "@/app/constant"; | ||||
| import { NextRequest, NextResponse } 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"; | ||||
| 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 }); | ||||
|     default: | ||||
|       return openaiHandler(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", | ||||
| ]; | ||||
							
								
								
									
										131
									
								
								app/api/alibaba.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,131 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   Alibaba, | ||||
|   ALIBABA_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import type { RequestPayload } from "@/app/client/platforms/openai"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Alibaba Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Qwen); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Alibaba] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   // alibaba use base url or just remove the path | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Alibaba, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.alibabaUrl || ALIBABA_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|       "X-DashScope-SSE": req.headers.get("X-DashScope-SSE") ?? "disable", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Alibaba as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Alibaba] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										170
									
								
								app/api/anthropic.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,170 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   ANTHROPIC_BASE_URL, | ||||
|   Anthropic, | ||||
|   ApiPath, | ||||
|   DEFAULT_MODELS, | ||||
|   ServiceProvider, | ||||
|   ModelProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "./auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; | ||||
|  | ||||
| const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Anthropic Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const subpath = params.path.join("/"); | ||||
|  | ||||
|   if (!ALLOWD_PATH.has(subpath)) { | ||||
|     console.log("[Anthropic Route] forbidden path ", subpath); | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + subpath, | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Claude); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Anthropic] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let authHeaderName = "x-api-key"; | ||||
|   let authValue = | ||||
|     req.headers.get(authHeaderName) || | ||||
|     req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() || | ||||
|     serverConfig.anthropicApiKey || | ||||
|     ""; | ||||
|  | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, ""); | ||||
|  | ||||
|   let baseUrl = | ||||
|     serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   // try rebuild url, when using cloudflare ai gateway in server | ||||
|   const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}${path}`); | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       "Cache-Control": "no-store", | ||||
|       [authHeaderName]: authValue, | ||||
|       "anthropic-version": | ||||
|         req.headers.get("anthropic-version") || | ||||
|         serverConfig.anthropicApiVersion || | ||||
|         Anthropic.Vision, | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Anthropic as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Anthropic] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   // console.log("[Anthropic request]", fetchOptions.headers, req.method); | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // console.log( | ||||
|     //   "[Anthropic response]", | ||||
|     //   res.status, | ||||
|     //   "   ", | ||||
|     //   res.headers, | ||||
|     //   res.url, | ||||
|     // ); | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										73
									
								
								app/api/artifacts/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -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"; | ||||
| @@ -57,12 +57,50 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { | ||||
|   if (!apiKey) { | ||||
|     const serverConfig = getServerSideConfig(); | ||||
|  | ||||
|     const systemApiKey = | ||||
|       modelProvider === ModelProvider.GeminiPro | ||||
|         ? serverConfig.googleApiKey | ||||
|         : serverConfig.isAzure | ||||
|         ? serverConfig.azureApiKey | ||||
|         : serverConfig.apiKey; | ||||
|     // const systemApiKey = | ||||
|     //   modelProvider === ModelProvider.GeminiPro | ||||
|     //     ? serverConfig.googleApiKey | ||||
|     //     : serverConfig.isAzure | ||||
|     //     ? serverConfig.azureApiKey | ||||
|     //     : serverConfig.apiKey; | ||||
|  | ||||
|     let systemApiKey: string | undefined; | ||||
|  | ||||
|     switch (modelProvider) { | ||||
|       case ModelProvider.Stability: | ||||
|         systemApiKey = serverConfig.stabilityApiKey; | ||||
|         break; | ||||
|       case ModelProvider.GeminiPro: | ||||
|         systemApiKey = serverConfig.googleApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Claude: | ||||
|         systemApiKey = serverConfig.anthropicApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Doubao: | ||||
|         systemApiKey = serverConfig.bytedanceApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Ernie: | ||||
|         systemApiKey = serverConfig.baiduApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Qwen: | ||||
|         systemApiKey = serverConfig.alibabaApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Moonshot: | ||||
|         systemApiKey = serverConfig.moonshotApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Iflytek: | ||||
|         systemApiKey = | ||||
|           serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret; | ||||
|         break; | ||||
|       case ModelProvider.GPT: | ||||
|       default: | ||||
|         if (req.nextUrl.pathname.includes("azure/deployments")) { | ||||
|           systemApiKey = serverConfig.azureApiKey; | ||||
|         } else { | ||||
|           systemApiKey = serverConfig.apiKey; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (systemApiKey) { | ||||
|       console.log("[Auth] use system api key"); | ||||
|       req.headers.set("Authorization", `Bearer ${systemApiKey}`); | ||||
|   | ||||
							
								
								
									
										33
									
								
								app/api/azure.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,33 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { ModelProvider } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "./auth"; | ||||
| import { requestOpenai } from "./common"; | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Azure Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const subpath = params.path.join("/"); | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.GPT); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     return await requestOpenai(req); | ||||
|   } catch (e) { | ||||
|     console.error("[Azure] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										145
									
								
								app/api/baidu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,145 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   BAIDU_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   BAIDU_OATUH_URL, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import { getAccessToken } from "@/app/utils/baidu"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Baidu Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Ernie); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   if (!serverConfig.baiduApiKey || !serverConfig.baiduSecretKey) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         message: `missing BAIDU_API_KEY or BAIDU_SECRET_KEY in server env vars`, | ||||
|       }, | ||||
|       { | ||||
|         status: 401, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Baidu] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Baidu, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.baiduUrl || BAIDU_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const { access_token } = await getAccessToken( | ||||
|     serverConfig.baiduApiKey as string, | ||||
|     serverConfig.baiduSecretKey as string, | ||||
|   ); | ||||
|   const fetchUrl = `${baseUrl}${path}?access_token=${access_token}`; | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Baidu as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Baidu] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										129
									
								
								app/api/bytedance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,129 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   BYTEDANCE_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[ByteDance Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Doubao); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[ByteDance] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.ByteDance, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.bytedanceUrl || BYTEDANCE_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.ByteDance as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[ByteDance] filter`, e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -1,17 +1,24 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { getServerSideConfig } from "../config/server"; | ||||
| import { DEFAULT_MODELS, OPENAI_BASE_URL, GEMINI_BASE_URL } from "../constant"; | ||||
| import { collectModelTable } from "../utils/model"; | ||||
| import { makeAzurePath } from "../azure"; | ||||
| import { | ||||
|   DEFAULT_MODELS, | ||||
|   OPENAI_BASE_URL, | ||||
|   GEMINI_BASE_URL, | ||||
|   ServiceProvider, | ||||
| } from "../constant"; | ||||
| import { isModelAvailableInServer } from "../utils/model"; | ||||
| import { cloudflareAIGatewayUrl } from "../utils/cloudflare"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function requestOpenai(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   const isAzure = req.nextUrl.pathname.includes("azure/deployments"); | ||||
|  | ||||
|   var authValue, | ||||
|     authHeaderName = ""; | ||||
|   if (serverConfig.isAzure) { | ||||
|   if (isAzure) { | ||||
|     authValue = | ||||
|       req.headers | ||||
|         .get("Authorization") | ||||
| @@ -31,7 +38,7 @@ export async function requestOpenai(req: NextRequest) { | ||||
|   ); | ||||
|  | ||||
|   let baseUrl = | ||||
|     serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL; | ||||
|     (isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
| @@ -43,10 +50,6 @@ export async function requestOpenai(req: NextRequest) { | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|   // this fix [Org ID] undefined in server side if not using custom point | ||||
|   if (serverConfig.openaiOrgId !== undefined) { | ||||
|     console.log("[Org ID]", serverConfig.openaiOrgId); | ||||
|   } | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
| @@ -55,17 +58,46 @@ export async function requestOpenai(req: NextRequest) { | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   if (serverConfig.isAzure) { | ||||
|     if (!serverConfig.azureApiVersion) { | ||||
|       return NextResponse.json({ | ||||
|         error: true, | ||||
|         message: `missing AZURE_API_VERSION in server env vars`, | ||||
|       }); | ||||
|   if (isAzure) { | ||||
|     const azureApiVersion = | ||||
|       req?.nextUrl?.searchParams?.get("api-version") || | ||||
|       serverConfig.azureApiVersion; | ||||
|     baseUrl = baseUrl.split("/deployments").shift() as string; | ||||
|     path = `${req.nextUrl.pathname.replaceAll( | ||||
|       "/api/azure/", | ||||
|       "", | ||||
|     )}?api-version=${azureApiVersion}`; | ||||
|  | ||||
|     // Forward compatibility: | ||||
|     // if display_name(deployment_name) not set, and '{deploy-id}' in AZURE_URL | ||||
|     // then using default '{deploy-id}' | ||||
|     if (serverConfig.customModels && serverConfig.azureUrl) { | ||||
|       const modelName = path.split("/")[1]; | ||||
|       let realDeployName = ""; | ||||
|       serverConfig.customModels | ||||
|         .split(",") | ||||
|         .filter((v) => !!v && !v.startsWith("-") && v.includes(modelName)) | ||||
|         .forEach((m) => { | ||||
|           const [fullName, displayName] = m.split("="); | ||||
|           const [_, providerName] = fullName.split("@"); | ||||
|           if (providerName === "azure" && !displayName) { | ||||
|             const [_, deployId] = (serverConfig?.azureUrl ?? "").split( | ||||
|               "deployments/", | ||||
|             ); | ||||
|             if (deployId) { | ||||
|               realDeployName = deployId; | ||||
|             } | ||||
|           } | ||||
|         }); | ||||
|       if (realDeployName) { | ||||
|         console.log("[Replace with DeployId", realDeployName); | ||||
|         path = path.replaceAll(modelName, realDeployName); | ||||
|       } | ||||
|     } | ||||
|     path = makeAzurePath(path, serverConfig.azureApiVersion); | ||||
|   } | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}/${path}`; | ||||
|   const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}/${path}`); | ||||
|   console.log("fetchUrl", fetchUrl); | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
| @@ -87,17 +119,24 @@ export async function requestOpenai(req: NextRequest) { | ||||
|   // #1815 try to refuse gpt4 request | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const modelTable = collectModelTable( | ||||
|         DEFAULT_MODELS, | ||||
|         serverConfig.customModels, | ||||
|       ); | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if (modelTable[jsonBody?.model ?? ""].available === false) { | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.OpenAI as string, | ||||
|         ) || | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Azure as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
| @@ -116,12 +155,29 @@ export async function requestOpenai(req: NextRequest) { | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // Extract the OpenAI-Organization header from the response | ||||
|     const openaiOrganizationHeader = res.headers.get("OpenAI-Organization"); | ||||
|  | ||||
|     // Check if serverConfig.openaiOrgId is defined and not an empty string | ||||
|     if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") { | ||||
|       // If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present | ||||
|       console.log("[Org ID]", openaiOrganizationHeader); | ||||
|     } else { | ||||
|       console.log("[Org ID] is not set up."); | ||||
|     } | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     // Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV) | ||||
|     // Also, this is to prevent the header from being sent to the client | ||||
|     if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") { | ||||
|       newHeaders.delete("OpenAI-Organization"); | ||||
|     } | ||||
|  | ||||
|     // The latest version of the OpenAI API forced the content-encoding to be "br" in json response | ||||
|     // So if the streaming is disabled, we need to remove the content-encoding header | ||||
|     // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header | ||||
|   | ||||
| @@ -13,6 +13,7 @@ const DANGER_CONFIG = { | ||||
|   hideBalanceQuery: serverConfig.hideBalanceQuery, | ||||
|   disableFastLink: serverConfig.disableFastLink, | ||||
|   customModels: serverConfig.customModels, | ||||
|   defaultModel: serverConfig.defaultModel, | ||||
| }; | ||||
|  | ||||
| declare global { | ||||
|   | ||||
| @@ -1,43 +0,0 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
|  | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const [protocol, ...subpath] = params.path; | ||||
|   const targetUrl = `${protocol}://${subpath.join("/")}`; | ||||
|  | ||||
|   const method = req.headers.get("method") ?? undefined; | ||||
|   const shouldNotHaveBody = ["get", "head"].includes( | ||||
|     method?.toLowerCase() ?? "", | ||||
|   ); | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       authorization: req.headers.get("authorization") ?? "", | ||||
|     }, | ||||
|     body: shouldNotHaveBody ? null : req.body, | ||||
|     method, | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|   }; | ||||
|  | ||||
|   const fetchResult = await fetch(targetUrl, fetchOptions); | ||||
|  | ||||
|   console.log("[Any Proxy]", targetUrl, { | ||||
|     status: fetchResult.status, | ||||
|     statusText: fetchResult.statusText, | ||||
|   }); | ||||
|  | ||||
|   return fetchResult; | ||||
| } | ||||
|  | ||||
| export const POST = handle; | ||||
| export const GET = handle; | ||||
| export const OPTIONS = handle; | ||||
|  | ||||
| export const runtime = "edge"; | ||||
| @@ -1,11 +1,19 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "../../auth"; | ||||
| import { auth } from "./auth"; | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   GEMINI_BASE_URL, | ||||
|   Google, | ||||
|   ModelProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| 
 | ||||
| async function handle( | ||||
| const serverConfig = getServerSideConfig(); | ||||
| 
 | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
|   { params }: { params: { provider: string; path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Google Route] params ", params); | ||||
| 
 | ||||
| @@ -13,32 +21,6 @@ async function handle( | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
| 
 | ||||
|   const controller = new AbortController(); | ||||
| 
 | ||||
|   const serverConfig = getServerSideConfig(); | ||||
| 
 | ||||
|   let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL; | ||||
| 
 | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
| 
 | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
| 
 | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll("/api/google/", ""); | ||||
| 
 | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
| 
 | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
| 
 | ||||
|   const authResult = auth(req, ModelProvider.GeminiPro); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
| @@ -49,9 +31,9 @@ async function handle( | ||||
|   const bearToken = req.headers.get("Authorization") ?? ""; | ||||
|   const token = bearToken.trim().replaceAll("Bearer ", "").trim(); | ||||
| 
 | ||||
|   const key = token ? token : serverConfig.googleApiKey; | ||||
|   const apiKey = token ? token : serverConfig.googleApiKey; | ||||
| 
 | ||||
|   if (!key) { | ||||
|   if (!apiKey) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
| @@ -62,8 +44,63 @@ async function handle( | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|   try { | ||||
|     const response = await request(req, apiKey); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Google] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|   const fetchUrl = `${baseUrl}/${path}?key=${key}`; | ||||
| export const GET = handle; | ||||
| export const POST = handle; | ||||
| 
 | ||||
| export const runtime = "edge"; | ||||
| export const preferredRegion = [ | ||||
|   "bom1", | ||||
|   "cle1", | ||||
|   "cpt1", | ||||
|   "gru1", | ||||
|   "hnd1", | ||||
|   "iad1", | ||||
|   "icn1", | ||||
|   "kix1", | ||||
|   "pdx1", | ||||
|   "sfo1", | ||||
|   "sin1", | ||||
|   "syd1", | ||||
| ]; | ||||
| 
 | ||||
| async function request(req: NextRequest, apiKey: string) { | ||||
|   const controller = new AbortController(); | ||||
| 
 | ||||
|   let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL; | ||||
| 
 | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Google, ""); | ||||
| 
 | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
| 
 | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
| 
 | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
| 
 | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|   const fetchUrl = `${baseUrl}${path}?key=${apiKey}${ | ||||
|     req?.nextUrl?.searchParams?.get("alt") === "sse" ? "&alt=sse" : "" | ||||
|   }`;
 | ||||
| 
 | ||||
|   console.log("[Fetch Url] ", fetchUrl); | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
| @@ -95,22 +132,3 @@ async function handle( | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const GET = handle; | ||||
| export const POST = handle; | ||||
| 
 | ||||
| export const runtime = "edge"; | ||||
| export const preferredRegion = [ | ||||
|   "bom1", | ||||
|   "cle1", | ||||
|   "cpt1", | ||||
|   "gru1", | ||||
|   "hnd1", | ||||
|   "iad1", | ||||
|   "icn1", | ||||
|   "kix1", | ||||
|   "pdx1", | ||||
|   "sfo1", | ||||
|   "sin1", | ||||
|   "syd1", | ||||
| ]; | ||||
							
								
								
									
										131
									
								
								app/api/iflytek.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,131 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   Iflytek, | ||||
|   IFLYTEK_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import type { RequestPayload } from "@/app/client/platforms/openai"; | ||||
| // iflytek | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Iflytek Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Iflytek); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Iflytek] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   // iflytek use base url or just remove the path | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Iflytek, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.iflytekUrl || IFLYTEK_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Iflytek as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Iflytek] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										130
									
								
								app/api/moonshot.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,130 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   Moonshot, | ||||
|   MOONSHOT_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import type { RequestPayload } from "@/app/client/platforms/openai"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Moonshot Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Moonshot); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Moonshot] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   // alibaba use base url or just remove the path | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.moonshotUrl || MOONSHOT_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Moonshot as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Moonshot] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -3,8 +3,8 @@ import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { ModelProvider, OpenaiPath } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "../../auth"; | ||||
| import { requestOpenai } from "../../common"; | ||||
| import { auth } from "./auth"; | ||||
| import { requestOpenai } from "./common"; | ||||
| 
 | ||||
| const ALLOWD_PATH = new Set(Object.values(OpenaiPath)); | ||||
| 
 | ||||
| @@ -13,14 +13,14 @@ function getModels(remoteModelRes: OpenAIListModelResponse) { | ||||
| 
 | ||||
|   if (config.disableGPT4) { | ||||
|     remoteModelRes.data = remoteModelRes.data.filter( | ||||
|       (m) => !m.id.startsWith("gpt-4"), | ||||
|       (m) => !m.id.startsWith("gpt-4") || m.id.startsWith("gpt-4o-mini"), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return remoteModelRes; | ||||
| } | ||||
| 
 | ||||
| async function handle( | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
| @@ -70,27 +70,3 @@ async function handle( | ||||
|     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", | ||||
| ]; | ||||
							
								
								
									
										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); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										124
									
								
								app/api/tencent/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,124 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   TENCENT_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
|   Tencent, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import { getHeader } from "@/app/utils/tencent"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Tencent Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Hunyuan); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Tencent] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const GET = handle; | ||||
| export const POST = handle; | ||||
|  | ||||
| export const runtime = "edge"; | ||||
| export const preferredRegion = [ | ||||
|   "arn1", | ||||
|   "bom1", | ||||
|   "cdg1", | ||||
|   "cle1", | ||||
|   "cpt1", | ||||
|   "dub1", | ||||
|   "fra1", | ||||
|   "gru1", | ||||
|   "hnd1", | ||||
|   "iad1", | ||||
|   "icn1", | ||||
|   "kix1", | ||||
|   "lhr1", | ||||
|   "pdx1", | ||||
|   "sfo1", | ||||
|   "sin1", | ||||
|   "syd1", | ||||
| ]; | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = baseUrl; | ||||
|  | ||||
|   const body = await req.text(); | ||||
|   const headers = await getHeader( | ||||
|     body, | ||||
|     serverConfig.tencentSecretId as string, | ||||
|     serverConfig.tencentSecretKey as string, | ||||
|   ); | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers, | ||||
|     method: req.method, | ||||
|     body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										73
									
								
								app/api/upstash/[action]/[...key]/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,73 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
|  | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { action: string; key: string[] } }, | ||||
| ) { | ||||
|   const requestUrl = new URL(req.url); | ||||
|   const endpoint = requestUrl.searchParams.get("endpoint"); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|   const [...key] = params.key; | ||||
|   // only allow to request to *.upstash.io | ||||
|   if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + params.key.join("/"), | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // only allow upstash get and set method | ||||
|   if (params.action !== "get" && params.action !== "set") { | ||||
|     console.log("[Upstash Route] forbidden action ", params.action); | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + params.action, | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`; | ||||
|  | ||||
|   const method = req.method; | ||||
|   const shouldNotHaveBody = ["get", "head"].includes( | ||||
|     method?.toLowerCase() ?? "", | ||||
|   ); | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       authorization: req.headers.get("authorization") ?? "", | ||||
|     }, | ||||
|     body: shouldNotHaveBody ? null : req.body, | ||||
|     method, | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|   }; | ||||
|  | ||||
|   console.log("[Upstash Proxy]", targetUrl, fetchOptions); | ||||
|   const fetchResult = await fetch(targetUrl, fetchOptions); | ||||
|  | ||||
|   console.log("[Any Proxy]", targetUrl, { | ||||
|     status: fetchResult.status, | ||||
|     statusText: fetchResult.statusText, | ||||
|   }); | ||||
|  | ||||
|   return fetchResult; | ||||
| } | ||||
|  | ||||
| export const POST = handle; | ||||
| export const GET = handle; | ||||
| export const OPTIONS = handle; | ||||
|  | ||||
| export const runtime = "edge"; | ||||
							
								
								
									
										167
									
								
								app/api/webdav/[...path]/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,167 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant"; | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
|  | ||||
| const config = getServerSideConfig(); | ||||
|  | ||||
| const mergedAllowedWebDavEndpoints = [ | ||||
|   ...internalAllowedWebDavEndpoints, | ||||
|   ...config.allowedWebDevEndpoints, | ||||
| ].filter((domain) => Boolean(domain.trim())); | ||||
|  | ||||
| const normalizeUrl = (url: string) => { | ||||
|   try { | ||||
|     return new URL(url); | ||||
|   } catch (err) { | ||||
|     return null; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|   const folder = STORAGE_KEY; | ||||
|   const fileName = `${folder}/backup.json`; | ||||
|  | ||||
|   const requestUrl = new URL(req.url); | ||||
|   let endpoint = requestUrl.searchParams.get("endpoint"); | ||||
|   let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method; | ||||
|  | ||||
|   // Validate the endpoint to prevent potential SSRF attacks | ||||
|   if ( | ||||
|     !endpoint || | ||||
|     !mergedAllowedWebDavEndpoints.some((allowedEndpoint) => { | ||||
|       const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint); | ||||
|       const normalizedEndpoint = normalizeUrl(endpoint as string); | ||||
|  | ||||
|       return ( | ||||
|         normalizedEndpoint && | ||||
|         normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname && | ||||
|         normalizedEndpoint.pathname.startsWith( | ||||
|           normalizedAllowedEndpoint.pathname, | ||||
|         ) | ||||
|       ); | ||||
|     }) | ||||
|   ) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "Invalid endpoint", | ||||
|       }, | ||||
|       { | ||||
|         status: 400, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (!endpoint?.endsWith("/")) { | ||||
|     endpoint += "/"; | ||||
|   } | ||||
|  | ||||
|   const endpointPath = params.path.join("/"); | ||||
|   const targetPath = `${endpoint}${endpointPath}`; | ||||
|  | ||||
|   // only allow MKCOL, GET, PUT | ||||
|   if ( | ||||
|     proxy_method !== "MKCOL" && | ||||
|     proxy_method !== "GET" && | ||||
|     proxy_method !== "PUT" | ||||
|   ) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // for MKCOL request, only allow request ${folder} | ||||
|   if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // for GET request, only allow request ending with fileName | ||||
|   if (proxy_method === "GET" && !targetPath.endsWith(fileName)) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   //   for PUT request, only allow request ending with fileName | ||||
|   if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const targetUrl = targetPath; | ||||
|  | ||||
|   const method = proxy_method || req.method; | ||||
|   const shouldNotHaveBody = ["get", "head"].includes( | ||||
|     method?.toLowerCase() ?? "", | ||||
|   ); | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       authorization: req.headers.get("authorization") ?? "", | ||||
|     }, | ||||
|     body: shouldNotHaveBody ? null : req.body, | ||||
|     redirect: "manual", | ||||
|     method, | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|   }; | ||||
|  | ||||
|   let fetchResult; | ||||
|  | ||||
|   try { | ||||
|     fetchResult = await fetch(targetUrl, fetchOptions); | ||||
|   } finally { | ||||
|     console.log( | ||||
|       "[Any Proxy]", | ||||
|       targetUrl, | ||||
|       { | ||||
|         method: method, | ||||
|       }, | ||||
|       { | ||||
|         status: fetchResult?.status, | ||||
|         statusText: fetchResult?.statusText, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return fetchResult; | ||||
| } | ||||
|  | ||||
| export const PUT = handle; | ||||
| export const GET = handle; | ||||
| export const OPTIONS = handle; | ||||
|  | ||||
| export const runtime = "edge"; | ||||
| @@ -1,9 +0,0 @@ | ||||
| export function makeAzurePath(path: string, apiVersion: string) { | ||||
|   // should omit /v1 prefix | ||||
|   path = path.replaceAll("v1/", ""); | ||||
|  | ||||
|   // should add api-key to query string | ||||
|   path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`; | ||||
|  | ||||
|   return path; | ||||
| } | ||||
| @@ -6,26 +6,46 @@ import { | ||||
|   ServiceProvider, | ||||
| } from "../constant"; | ||||
| import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; | ||||
| import { ChatGPTApi } from "./platforms/openai"; | ||||
| import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai"; | ||||
| import { GeminiProApi } from "./platforms/google"; | ||||
| import { ClaudeApi } from "./platforms/anthropic"; | ||||
| import { ErnieApi } from "./platforms/baidu"; | ||||
| import { DoubaoApi } from "./platforms/bytedance"; | ||||
| import { QwenApi } from "./platforms/alibaba"; | ||||
| import { HunyuanApi } from "./platforms/tencent"; | ||||
| import { MoonshotApi } from "./platforms/moonshot"; | ||||
| import { SparkApi } from "./platforms/iflytek"; | ||||
|  | ||||
| export const ROLES = ["system", "user", "assistant"] as const; | ||||
| export type MessageRole = (typeof ROLES)[number]; | ||||
|  | ||||
| export const Models = ["gpt-3.5-turbo", "gpt-4"] as const; | ||||
| export type ChatModel = ModelType; | ||||
|  | ||||
| export interface MultimodalContent { | ||||
|   type: "text" | "image_url"; | ||||
|   text?: string; | ||||
|   image_url?: { | ||||
|     url: string; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export interface RequestMessage { | ||||
|   role: MessageRole; | ||||
|   content: string; | ||||
|   content: string | MultimodalContent[]; | ||||
| } | ||||
|  | ||||
| export interface LLMConfig { | ||||
|   model: string; | ||||
|   providerName?: string; | ||||
|   temperature?: number; | ||||
|   top_p?: number; | ||||
|   stream?: boolean; | ||||
|   presence_penalty?: number; | ||||
|   frequency_penalty?: number; | ||||
|   size?: DalleRequestPayload["size"]; | ||||
|   quality?: DalleRequestPayload["quality"]; | ||||
|   style?: DalleRequestPayload["style"]; | ||||
| } | ||||
|  | ||||
| export interface ChatOptions { | ||||
| @@ -45,14 +65,17 @@ export interface LLMUsage { | ||||
|  | ||||
| export interface LLMModel { | ||||
|   name: string; | ||||
|   displayName?: string; | ||||
|   available: boolean; | ||||
|   provider: LLMModelProvider; | ||||
|   sorted: number; | ||||
| } | ||||
|  | ||||
| export interface LLMModelProvider { | ||||
|   id: string; | ||||
|   providerName: string; | ||||
|   providerType: string; | ||||
|   sorted: number; | ||||
| } | ||||
|  | ||||
| export abstract class LLMApi { | ||||
| @@ -86,11 +109,34 @@ export class ClientApi { | ||||
|   public llm: LLMApi; | ||||
|  | ||||
|   constructor(provider: ModelProvider = ModelProvider.GPT) { | ||||
|     if (provider === ModelProvider.GeminiPro) { | ||||
|       this.llm = new GeminiProApi(); | ||||
|       return; | ||||
|     switch (provider) { | ||||
|       case ModelProvider.GeminiPro: | ||||
|         this.llm = new GeminiProApi(); | ||||
|         break; | ||||
|       case ModelProvider.Claude: | ||||
|         this.llm = new ClaudeApi(); | ||||
|         break; | ||||
|       case ModelProvider.Ernie: | ||||
|         this.llm = new ErnieApi(); | ||||
|         break; | ||||
|       case ModelProvider.Doubao: | ||||
|         this.llm = new DoubaoApi(); | ||||
|         break; | ||||
|       case ModelProvider.Qwen: | ||||
|         this.llm = new QwenApi(); | ||||
|         break; | ||||
|       case ModelProvider.Hunyuan: | ||||
|         this.llm = new HunyuanApi(); | ||||
|         break; | ||||
|       case ModelProvider.Moonshot: | ||||
|         this.llm = new MoonshotApi(); | ||||
|         break; | ||||
|       case ModelProvider.Iflytek: | ||||
|         this.llm = new SparkApi(); | ||||
|         break; | ||||
|       default: | ||||
|         this.llm = new ChatGPTApi(); | ||||
|     } | ||||
|     this.llm = new ChatGPTApi(); | ||||
|   } | ||||
|  | ||||
|   config() {} | ||||
| @@ -139,37 +185,122 @@ export class ClientApi { | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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() { | ||||
|   const accessStore = useAccessStore.getState(); | ||||
|   const chatStore = useChatStore.getState(); | ||||
|   const headers: Record<string, string> = { | ||||
|     "Content-Type": "application/json", | ||||
|     "x-requested-with": "XMLHttpRequest", | ||||
|     "Accept": "application/json", | ||||
|     Accept: "application/json", | ||||
|   }; | ||||
|   const modelConfig = useChatStore.getState().currentSession().mask.modelConfig; | ||||
|   const isGoogle = modelConfig.model === "gemini-pro"; | ||||
|   const isAzure = accessStore.provider === ServiceProvider.Azure; | ||||
|   const authHeader = isAzure ? "api-key" : "Authorization"; | ||||
|   const apiKey = isGoogle | ||||
|     ? accessStore.googleApiKey | ||||
|     : isAzure | ||||
|     ? accessStore.azureApiKey | ||||
|     : accessStore.openaiApiKey; | ||||
|  | ||||
|   const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`; | ||||
|   const validString = (x: string) => x && x.length > 0; | ||||
|   const clientConfig = getClientConfig(); | ||||
|  | ||||
|   // use user's api key first | ||||
|   if (validString(apiKey)) { | ||||
|     headers[authHeader] = makeBearer(apiKey); | ||||
|   } else if ( | ||||
|     accessStore.enabledAccessControl() && | ||||
|     validString(accessStore.accessCode) | ||||
|   ) { | ||||
|     headers[authHeader] = makeBearer( | ||||
|   function getConfig() { | ||||
|     const modelConfig = chatStore.currentSession().mask.modelConfig; | ||||
|     const isGoogle = modelConfig.providerName == ServiceProvider.Google; | ||||
|     const isAzure = modelConfig.providerName === ServiceProvider.Azure; | ||||
|     const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic; | ||||
|     const isBaidu = modelConfig.providerName == ServiceProvider.Baidu; | ||||
|     const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance; | ||||
|     const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba; | ||||
|     const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot; | ||||
|     const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek; | ||||
|     const isEnabledAccessControl = accessStore.enabledAccessControl(); | ||||
|     const apiKey = isGoogle | ||||
|       ? accessStore.googleApiKey | ||||
|       : isAzure | ||||
|       ? accessStore.azureApiKey | ||||
|       : isAnthropic | ||||
|       ? accessStore.anthropicApiKey | ||||
|       : isByteDance | ||||
|       ? accessStore.bytedanceApiKey | ||||
|       : isAlibaba | ||||
|       ? accessStore.alibabaApiKey | ||||
|       : isMoonshot | ||||
|       ? accessStore.moonshotApiKey | ||||
|       : isIflytek | ||||
|       ? accessStore.iflytekApiKey && accessStore.iflytekApiSecret | ||||
|         ? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret | ||||
|         : "" | ||||
|       : accessStore.openaiApiKey; | ||||
|     return { | ||||
|       isGoogle, | ||||
|       isAzure, | ||||
|       isAnthropic, | ||||
|       isBaidu, | ||||
|       isByteDance, | ||||
|       isAlibaba, | ||||
|       isMoonshot, | ||||
|       isIflytek, | ||||
|       apiKey, | ||||
|       isEnabledAccessControl, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   function getAuthHeader(): string { | ||||
|     return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization"; | ||||
|   } | ||||
|  | ||||
|   const { | ||||
|     isGoogle, | ||||
|     isAzure, | ||||
|     isAnthropic, | ||||
|     isBaidu, | ||||
|     apiKey, | ||||
|     isEnabledAccessControl, | ||||
|   } = 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 | ||||
|   if (isBaidu && clientConfig?.isApp) return headers; | ||||
|  | ||||
|   const authHeader = getAuthHeader(); | ||||
|  | ||||
|   const bearerToken = getBearerToken(apiKey, isAzure || isAnthropic); | ||||
|  | ||||
|   if (bearerToken) { | ||||
|     headers[authHeader] = bearerToken; | ||||
|   } else if (isEnabledAccessControl && validString(accessStore.accessCode)) { | ||||
|     headers["Authorization"] = getBearerToken( | ||||
|       ACCESS_CODE_PREFIX + accessStore.accessCode, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return headers; | ||||
| } | ||||
|  | ||||
| export function getClientApi(provider: ServiceProvider): ClientApi { | ||||
|   switch (provider) { | ||||
|     case ServiceProvider.Google: | ||||
|       return new ClientApi(ModelProvider.GeminiPro); | ||||
|     case ServiceProvider.Anthropic: | ||||
|       return new ClientApi(ModelProvider.Claude); | ||||
|     case ServiceProvider.Baidu: | ||||
|       return new ClientApi(ModelProvider.Ernie); | ||||
|     case ServiceProvider.ByteDance: | ||||
|       return new ClientApi(ModelProvider.Doubao); | ||||
|     case ServiceProvider.Alibaba: | ||||
|       return new ClientApi(ModelProvider.Qwen); | ||||
|     case ServiceProvider.Tencent: | ||||
|       return new ClientApi(ModelProvider.Hunyuan); | ||||
|     case ServiceProvider.Moonshot: | ||||
|       return new ClientApi(ModelProvider.Moonshot); | ||||
|     case ServiceProvider.Iflytek: | ||||
|       return new ClientApi(ModelProvider.Iflytek); | ||||
|     default: | ||||
|       return new ClientApi(ModelProvider.GPT); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										268
									
								
								app/client/platforms/alibaba.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,268 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   Alibaba, | ||||
|   ALIBABA_BASE_URL, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   MultimodalContent, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent } from "@/app/utils"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
|   data: Array<{ | ||||
|     id: string; | ||||
|     object: string; | ||||
|     root: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| interface RequestInput { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
| } | ||||
| interface RequestParam { | ||||
|   result_format: string; | ||||
|   incremental_output?: boolean; | ||||
|   temperature: number; | ||||
|   repetition_penalty?: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
| interface RequestPayload { | ||||
|   model: string; | ||||
|   input: RequestInput; | ||||
|   parameters: RequestParam; | ||||
| } | ||||
|  | ||||
| export class QwenApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.alibabaUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       baseUrl = isApp ? ALIBABA_BASE_URL : ApiPath.Alibaba; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Alibaba)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res?.output?.choices?.at(0)?.message?.content ?? ""; | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       role: v.role, | ||||
|       content: getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const requestPayload: RequestPayload = { | ||||
|       model: modelConfig.model, | ||||
|       input: { | ||||
|         messages, | ||||
|       }, | ||||
|       parameters: { | ||||
|         result_format: "message", | ||||
|         incremental_output: shouldStream, | ||||
|         temperature: modelConfig.temperature, | ||||
|         // max_tokens: modelConfig.max_tokens, | ||||
|         top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1 | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(Alibaba.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: { | ||||
|           ...getHeaders(), | ||||
|           "X-DashScope-SSE": shouldStream ? "enable" : "disable", | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[Alibaba] request response content type: ", | ||||
|               contentType, | ||||
|             ); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.output.choices as Array<{ | ||||
|                 message: { content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.message?.content; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| export { Alibaba }; | ||||
							
								
								
									
										397
									
								
								app/client/platforms/anthropic.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,397 @@ | ||||
| import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant"; | ||||
| import { ChatOptions, getHeaders, LLMApi, MultimodalContent } from "../api"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { DEFAULT_API_HOST } from "@/app/constant"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
|  | ||||
| import Locale from "../../locales"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getMessageTextContent, isVisionModel } from "@/app/utils"; | ||||
| import { preProcessImageContent } from "@/app/utils/chat"; | ||||
| import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; | ||||
|  | ||||
| export type MultiBlockContent = { | ||||
|   type: "image" | "text"; | ||||
|   source?: { | ||||
|     type: string; | ||||
|     media_type: string; | ||||
|     data: string; | ||||
|   }; | ||||
|   text?: string; | ||||
| }; | ||||
|  | ||||
| export type AnthropicMessage = { | ||||
|   role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper]; | ||||
|   content: string | MultiBlockContent[]; | ||||
| }; | ||||
|  | ||||
| export interface AnthropicChatRequest { | ||||
|   model: string; // The model that will complete your prompt. | ||||
|   messages: AnthropicMessage[]; // The prompt that you want Claude to complete. | ||||
|   max_tokens: number; // The maximum number of tokens to generate before stopping. | ||||
|   stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. | ||||
|   temperature?: number; // Amount of randomness injected into the response. | ||||
|   top_p?: number; // Use nucleus sampling. | ||||
|   top_k?: number; // Only sample from the top K options for each subsequent token. | ||||
|   metadata?: object; // An object describing metadata about the request. | ||||
|   stream?: boolean; // Whether to incrementally stream the response using server-sent events. | ||||
| } | ||||
|  | ||||
| export interface ChatRequest { | ||||
|   model: string; // The model that will complete your prompt. | ||||
|   prompt: string; // The prompt that you want Claude to complete. | ||||
|   max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping. | ||||
|   stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. | ||||
|   temperature?: number; // Amount of randomness injected into the response. | ||||
|   top_p?: number; // Use nucleus sampling. | ||||
|   top_k?: number; // Only sample from the top K options for each subsequent token. | ||||
|   metadata?: object; // An object describing metadata about the request. | ||||
|   stream?: boolean; // Whether to incrementally stream the response using server-sent events. | ||||
| } | ||||
|  | ||||
| export interface ChatResponse { | ||||
|   completion: string; | ||||
|   stop_reason: "stop_sequence" | "max_tokens"; | ||||
|   model: string; | ||||
| } | ||||
|  | ||||
| export type ChatStreamResponse = ChatResponse & { | ||||
|   stop?: string; | ||||
|   log_id: string; | ||||
| }; | ||||
|  | ||||
| const ClaudeMapper = { | ||||
|   assistant: "assistant", | ||||
|   user: "user", | ||||
|   system: "user", | ||||
| } as const; | ||||
|  | ||||
| const keys = ["claude-2, claude-instant-1"]; | ||||
|  | ||||
| export class ClaudeApi implements LLMApi { | ||||
|   extractMessage(res: any) { | ||||
|     console.log("[Response] claude response: ", res); | ||||
|  | ||||
|     return res?.content?.[0]?.text; | ||||
|   } | ||||
|   async chat(options: ChatOptions): Promise<void> { | ||||
|     const visionModel = isVisionModel(options.config.model); | ||||
|  | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     // try get base64image from local cache image_url | ||||
|     const messages: ChatOptions["messages"] = []; | ||||
|     for (const v of options.messages) { | ||||
|       const content = await preProcessImageContent(v.content); | ||||
|       messages.push({ role: v.role, content }); | ||||
|     } | ||||
|  | ||||
|     const keys = ["system", "user"]; | ||||
|  | ||||
|     // roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages | ||||
|     for (let i = 0; i < messages.length - 1; i++) { | ||||
|       const message = messages[i]; | ||||
|       const nextMessage = messages[i + 1]; | ||||
|  | ||||
|       if (keys.includes(message.role) && keys.includes(nextMessage.role)) { | ||||
|         messages[i] = [ | ||||
|           message, | ||||
|           { | ||||
|             role: "assistant", | ||||
|             content: ";", | ||||
|           }, | ||||
|         ] as any; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const prompt = messages | ||||
|       .flat() | ||||
|       .filter((v) => { | ||||
|         if (!v.content) return false; | ||||
|         if (typeof v.content === "string" && !v.content.trim()) return false; | ||||
|         return true; | ||||
|       }) | ||||
|       .map((v) => { | ||||
|         const { role, content } = v; | ||||
|         const insideRole = ClaudeMapper[role] ?? "user"; | ||||
|  | ||||
|         if (!visionModel || typeof content === "string") { | ||||
|           return { | ||||
|             role: insideRole, | ||||
|             content: getMessageTextContent(v), | ||||
|           }; | ||||
|         } | ||||
|         return { | ||||
|           role: insideRole, | ||||
|           content: content | ||||
|             .filter((v) => v.image_url || v.text) | ||||
|             .map(({ type, text, image_url }) => { | ||||
|               if (type === "text") { | ||||
|                 return { | ||||
|                   type, | ||||
|                   text: text!, | ||||
|                 }; | ||||
|               } | ||||
|               const { url = "" } = image_url || {}; | ||||
|               const colonIndex = url.indexOf(":"); | ||||
|               const semicolonIndex = url.indexOf(";"); | ||||
|               const comma = url.indexOf(","); | ||||
|  | ||||
|               const mimeType = url.slice(colonIndex + 1, semicolonIndex); | ||||
|               const encodeType = url.slice(semicolonIndex + 1, comma); | ||||
|               const data = url.slice(comma + 1); | ||||
|  | ||||
|               return { | ||||
|                 type: "image" as const, | ||||
|                 source: { | ||||
|                   type: encodeType, | ||||
|                   media_type: mimeType, | ||||
|                   data, | ||||
|                 }, | ||||
|               }; | ||||
|             }), | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|     if (prompt[0]?.role === "assistant") { | ||||
|       prompt.unshift({ | ||||
|         role: "user", | ||||
|         content: ";", | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     const requestBody: AnthropicChatRequest = { | ||||
|       messages: prompt, | ||||
|       stream: shouldStream, | ||||
|  | ||||
|       model: modelConfig.model, | ||||
|       max_tokens: modelConfig.max_tokens, | ||||
|       temperature: modelConfig.temperature, | ||||
|       top_p: modelConfig.top_p, | ||||
|       // top_k: modelConfig.top_k, | ||||
|       top_k: 5, | ||||
|     }; | ||||
|  | ||||
|     const path = this.path(Anthropic.ChatPath); | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     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) { | ||||
|       try { | ||||
|         const context = { | ||||
|           text: "", | ||||
|           finished: false, | ||||
|         }; | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!context.finished) { | ||||
|             options.onFinish(context.text); | ||||
|             context.finished = true; | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|         fetchEventSource(path, { | ||||
|           ...payload, | ||||
|           async onopen(res) { | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log("response content type: ", contentType); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               context.text = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [context.text]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               context.text = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             let chunkJson: | ||||
|               | undefined | ||||
|               | { | ||||
|                   type: "content_block_delta" | "content_block_stop"; | ||||
|                   delta?: { | ||||
|                     type: "text_delta"; | ||||
|                     text: string; | ||||
|                   }; | ||||
|                   index: number; | ||||
|                 }; | ||||
|             try { | ||||
|               chunkJson = JSON.parse(msg.data); | ||||
|             } catch (e) { | ||||
|               console.error("[Response] parse error", msg.data); | ||||
|             } | ||||
|  | ||||
|             if (!chunkJson || chunkJson.type === "content_block_stop") { | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             const { delta } = chunkJson; | ||||
|             if (delta?.text) { | ||||
|               context.text += delta.text; | ||||
|               options.onUpdate?.(context.text, delta.text); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } catch (e) { | ||||
|         console.error("failed to chat", e); | ||||
|         options.onError?.(e as Error); | ||||
|       } | ||||
|     } else { | ||||
|       try { | ||||
|         controller.signal.onabort = () => options.onFinish(""); | ||||
|  | ||||
|         const res = await fetch(path, payload); | ||||
|         const resJson = await res.json(); | ||||
|  | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } catch (e) { | ||||
|         console.error("failed to chat", e); | ||||
|         options.onError?.(e as Error); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|   async models() { | ||||
|     // const provider = { | ||||
|     //   id: "anthropic", | ||||
|     //   providerName: "Anthropic", | ||||
|     //   providerType: "anthropic", | ||||
|     // }; | ||||
|  | ||||
|     return [ | ||||
|       // { | ||||
|       //   name: "claude-instant-1.2", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-2.0", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-2.1", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-3-opus-20240229", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-3-sonnet-20240229", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-3-haiku-20240307", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|     ]; | ||||
|   } | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl: string = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.anthropicUrl; | ||||
|     } | ||||
|  | ||||
|     // if endpoint is empty, use default endpoint | ||||
|     if (baseUrl.trim().length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|  | ||||
|       baseUrl = isApp | ||||
|         ? DEFAULT_API_HOST + "/api/proxy/anthropic" | ||||
|         : ApiPath.Anthropic; | ||||
|     } | ||||
|  | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     baseUrl = trimEnd(baseUrl, "/"); | ||||
|  | ||||
|     // try rebuild url, when using cloudflare ai gateway in client | ||||
|     return cloudflareAIGatewayUrl(`${baseUrl}/${path}`); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function trimEnd(s: string, end = " ") { | ||||
|   if (end.length === 0) return s; | ||||
|  | ||||
|   while (s.endsWith(end)) { | ||||
|     s = s.slice(0, -end.length); | ||||
|   } | ||||
|  | ||||
|   return s; | ||||
| } | ||||
							
								
								
									
										281
									
								
								app/client/platforms/baidu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,281 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   Baidu, | ||||
|   BAIDU_BASE_URL, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
| import { getAccessToken } from "@/app/utils/baidu"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   MultimodalContent, | ||||
| } 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"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
|   data: Array<{ | ||||
|     id: string; | ||||
|     object: string; | ||||
|     root: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| interface RequestPayload { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
|   stream?: boolean; | ||||
|   model: string; | ||||
|   temperature: number; | ||||
|   presence_penalty: number; | ||||
|   frequency_penalty: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
|  | ||||
| export class ErnieApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.baiduUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       // do not use proxy for baidubce api | ||||
|       baseUrl = isApp ? BAIDU_BASE_URL : ApiPath.Baidu; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Baidu)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       // "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function", | ||||
|       role: v.role === "system" ? "user" : v.role, | ||||
|       content: getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     // "error_code": 336006, "error_msg": "the length of messages must be an odd number", | ||||
|     if (messages.length % 2 === 0) { | ||||
|       if (messages.at(0)?.role === "user") { | ||||
|         messages.splice(1, 0, { | ||||
|           role: "assistant", | ||||
|           content: " ", | ||||
|         }); | ||||
|       } else { | ||||
|         messages.unshift({ | ||||
|           role: "user", | ||||
|           content: " ", | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const requestPayload: RequestPayload = { | ||||
|       messages, | ||||
|       stream: shouldStream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|     }; | ||||
|  | ||||
|     console.log("[Request] Baidu payload: ", requestPayload); | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       let chatPath = this.path(Baidu.ChatPath(modelConfig.model)); | ||||
|  | ||||
|       // getAccessToken can not run in browser, because cors error | ||||
|       if (!!getClientConfig()?.isApp) { | ||||
|         const accessStore = useAccessStore.getState(); | ||||
|         if (accessStore.useCustomConfig) { | ||||
|           if (accessStore.isValidBaidu()) { | ||||
|             const { access_token } = await getAccessToken( | ||||
|               accessStore.baiduApiKey, | ||||
|               accessStore.baiduSecretKey, | ||||
|             ); | ||||
|             chatPath = `${chatPath}${ | ||||
|               chatPath.includes("?") ? "&" : "?" | ||||
|             }access_token=${access_token}`; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log("[Baidu] request response content type: ", contentType); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const delta = json?.result; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = resJson?.result; | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| export { Baidu }; | ||||
							
								
								
									
										255
									
								
								app/client/platforms/bytedance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,255 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   ByteDance, | ||||
|   BYTEDANCE_BASE_URL, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   MultimodalContent, | ||||
| } 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"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
|   data: Array<{ | ||||
|     id: string; | ||||
|     object: string; | ||||
|     root: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| interface RequestPayload { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
|   stream?: boolean; | ||||
|   model: string; | ||||
|   temperature: number; | ||||
|   presence_penalty: number; | ||||
|   frequency_penalty: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
|  | ||||
| export class DoubaoApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.bytedanceUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       baseUrl = isApp ? BYTEDANCE_BASE_URL : ApiPath.ByteDance; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ByteDance)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res.choices?.at(0)?.message?.content ?? ""; | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       role: v.role, | ||||
|       content: getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const requestPayload: RequestPayload = { | ||||
|       messages, | ||||
|       stream: shouldStream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|     }; | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(ByteDance.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[ByteDance] request response content type: ", | ||||
|               contentType, | ||||
|             ); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.choices as Array<{ | ||||
|                 delta: { content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.delta?.content; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| export { ByteDance }; | ||||
| @@ -1,16 +1,52 @@ | ||||
| import { 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 { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import Locale from "../../locales"; | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import de from "@/app/locales/de"; | ||||
| import { | ||||
|   getMessageTextContent, | ||||
|   getMessageImages, | ||||
|   isVisionModel, | ||||
| } from "@/app/utils"; | ||||
| import { preProcessImageContent } from "@/app/utils/chat"; | ||||
|  | ||||
| export class GeminiProApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.googleUrl; | ||||
|     } | ||||
|  | ||||
|     const isApp = !!getClientConfig()?.isApp; | ||||
|     if (baseUrl.length === 0) { | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google; | ||||
|     } | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Google)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     let chatPath = [baseUrl, path].join("/"); | ||||
|  | ||||
|     chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse"; | ||||
|     // if chatPath.startsWith('http') then add key in query string | ||||
|     if (chatPath.startsWith("http") && accessStore.googleApiKey) { | ||||
|       chatPath += `&key=${accessStore.googleApiKey}`; | ||||
|     } | ||||
|     return chatPath; | ||||
|   } | ||||
|   extractMessage(res: any) { | ||||
|     console.log("[Response] gemini-pro response: ", res); | ||||
|  | ||||
| @@ -22,10 +58,39 @@ export class GeminiProApi implements LLMApi { | ||||
|   } | ||||
|   async chat(options: ChatOptions): Promise<void> { | ||||
|     const apiClient = this; | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       role: v.role.replace("assistant", "model").replace("system", "user"), | ||||
|       parts: [{ text: v.content }], | ||||
|     })); | ||||
|     let multimodal = false; | ||||
|  | ||||
|     // try get base64image from local cache image_url | ||||
|     const _messages: ChatOptions["messages"] = []; | ||||
|     for (const v of options.messages) { | ||||
|       const content = await preProcessImageContent(v.content); | ||||
|       _messages.push({ role: v.role, content }); | ||||
|     } | ||||
|     const messages = _messages.map((v) => { | ||||
|       let parts: any[] = [{ text: getMessageTextContent(v) }]; | ||||
|       if (isVisionModel(options.config.model)) { | ||||
|         const images = getMessageImages(v); | ||||
|         if (images.length > 0) { | ||||
|           multimodal = true; | ||||
|           parts = parts.concat( | ||||
|             images.map((image) => { | ||||
|               const imageType = image.split(";")[0].split(":")[1]; | ||||
|               const imageData = image.split(",")[1]; | ||||
|               return { | ||||
|                 inline_data: { | ||||
|                   mime_type: imageType, | ||||
|                   data: imageData, | ||||
|                 }, | ||||
|               }; | ||||
|             }), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|       return { | ||||
|         role: v.role.replace("assistant", "model").replace("system", "user"), | ||||
|         parts: parts, | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
|     // google requires that role in neighboring messages must not be the same | ||||
|     for (let i = 0; i < messages.length - 1; ) { | ||||
| @@ -40,6 +105,11 @@ export class GeminiProApi implements LLMApi { | ||||
|         i++; | ||||
|       } | ||||
|     } | ||||
|     // if (visionModel && messages.length > 1) { | ||||
|     //   options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision")); | ||||
|     // } | ||||
|  | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
| @@ -62,30 +132,30 @@ export class GeminiProApi implements LLMApi { | ||||
|       safetySettings: [ | ||||
|         { | ||||
|           category: "HARM_CATEGORY_HARASSMENT", | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|         }, | ||||
|         { | ||||
|           category: "HARM_CATEGORY_HATE_SPEECH", | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|         }, | ||||
|         { | ||||
|           category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|         }, | ||||
|         { | ||||
|           category: "HARM_CATEGORY_DANGEROUS_CONTENT", | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|         }, | ||||
|       ], | ||||
|     }; | ||||
|  | ||||
|     console.log("[Request] google payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     let shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|     try { | ||||
|       const chatPath = this.path(Google.ChatPath); | ||||
|       // https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb | ||||
|       const chatPath = this.path(Google.ChatPath(modelConfig.model)); | ||||
|  | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
| @@ -98,19 +168,17 @@ export class GeminiProApi implements LLMApi { | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let streamChatPath = chatPath.replace( | ||||
|           "generateContent", | ||||
|           "streamGenerateContent", | ||||
|         ); | ||||
|         let finished = false; | ||||
|  | ||||
|         let existingTexts: string[] = []; | ||||
|         const finish = () => { | ||||
|           finished = true; | ||||
|           options.onFinish(existingTexts.join("")); | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
| @@ -134,62 +202,86 @@ export class GeminiProApi implements LLMApi { | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|         fetch(streamChatPath, chatPayload) | ||||
|           .then((response) => { | ||||
|             const reader = response?.body?.getReader(); | ||||
|             const decoder = new TextDecoder(); | ||||
|             let partialData = ""; | ||||
|  | ||||
|             return reader?.read().then(function processText({ | ||||
|               done, | ||||
|               value, | ||||
|             }): Promise<any> { | ||||
|               if (done) { | ||||
|                 console.log("Stream complete"); | ||||
|                 // options.onFinish(responseText + remainText); | ||||
|                 finished = true; | ||||
|                 return Promise.resolve(); | ||||
|               } | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|               partialData += decoder.decode(value, { stream: true }); | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[Gemini] 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 { | ||||
|                 let data = JSON.parse(ensureProperEnding(partialData)); | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|                 const textArray = data.reduce( | ||||
|                   (acc: string[], item: { candidates: any[] }) => { | ||||
|                     const texts = item.candidates.map((candidate) => | ||||
|                       candidate.content.parts | ||||
|                         .map((part: { text: any }) => part.text) | ||||
|                         .join(""), | ||||
|                     ); | ||||
|                     return acc.concat(texts); | ||||
|                   }, | ||||
|                   [], | ||||
|                 ); | ||||
|  | ||||
|                 if (textArray.length > existingTexts.length) { | ||||
|                   const deltaArray = textArray.slice(existingTexts.length); | ||||
|                   existingTexts = textArray; | ||||
|                   remainText += deltaArray.join(""); | ||||
|                 } | ||||
|               } catch (error) { | ||||
|                 // console.log("[Response Animation] error: ", error,partialData); | ||||
|                 // skip error message when parsing json | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               return reader.read().then(processText); | ||||
|             }); | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|             console.error("Error:", error); | ||||
|           }); | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const delta = 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 { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|  | ||||
|         if (resJson?.promptFeedback?.blockReason) { | ||||
|           // being blocked | ||||
|           options.onError?.( | ||||
| @@ -199,7 +291,7 @@ export class GeminiProApi implements LLMApi { | ||||
|             ), | ||||
|           ); | ||||
|         } | ||||
|         const message = this.extractMessage(resJson); | ||||
|         const message = apiClient.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
| @@ -213,14 +305,4 @@ export class GeminiProApi implements LLMApi { | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
|   path(path: string): string { | ||||
|     return "/api/google/" + path; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function ensureProperEnding(str: string) { | ||||
|   if (str.startsWith("[") && !str.endsWith("]")) { | ||||
|     return str + "]"; | ||||
|   } | ||||
|   return str; | ||||
| } | ||||
|   | ||||
							
								
								
									
										240
									
								
								app/client/platforms/iflytek.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,240 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   DEFAULT_API_HOST, | ||||
|   Iflytek, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { ChatOptions, getHeaders, LLMApi, LLMModel } 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 { OpenAIListModelResponse, 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 ? DEFAULT_API_HOST + "/proxy" + apiPath : 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 ?? ""; | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages: ChatOptions["messages"] = []; | ||||
|     for (const v of options.messages) { | ||||
|       const content = getMessageTextContent(v); | ||||
|       messages.push({ role: v.role, content }); | ||||
|     } | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|         providerName: options.config.providerName, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const requestPayload: RequestPayload = { | ||||
|       messages, | ||||
|       stream: options.config.stream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|       // max_tokens: Math.max(modelConfig.max_tokens, 1024), | ||||
|       // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. | ||||
|     }; | ||||
|  | ||||
|     console.log("[Request] Spark payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(Iflytek.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // Make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // Animate response text to make it look smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // Start animation | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log("[Spark] request response content type: ", contentType); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             // Handle different error scenarios | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 extraInfo = Locale.Error.Unauthorized; | ||||
|               } | ||||
|  | ||||
|               options.onError?.( | ||||
|                 new Error( | ||||
|                   `Request failed with status ${res.status}: ${extraInfo}`, | ||||
|                 ), | ||||
|               ); | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.choices as Array<{ | ||||
|                 delta: { content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.delta?.content; | ||||
|  | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text); | ||||
|               options.onError?.(new Error(`Failed to parse response: ${text}`)); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         if (!res.ok) { | ||||
|           const errorText = await res.text(); | ||||
|           options.onError?.( | ||||
|             new Error(`Request failed with status ${res.status}: ${errorText}`), | ||||
|           ); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										251
									
								
								app/client/platforms/moonshot.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,251 @@ | ||||
| "use client"; | ||||
| // azure and openai, using same models. so using same LLMApi. | ||||
| import { | ||||
|   ApiPath, | ||||
|   DEFAULT_API_HOST, | ||||
|   DEFAULT_MODELS, | ||||
|   Moonshot, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
| import { collectModelsWithDefaultModel } from "@/app/utils/model"; | ||||
| import { preProcessImageContent } from "@/app/utils/chat"; | ||||
| import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   LLMUsage, | ||||
|   MultimodalContent, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent } from "@/app/utils"; | ||||
|  | ||||
| import { OpenAIListModelResponse, RequestPayload } from "./openai"; | ||||
|  | ||||
| export class MoonshotApi implements LLMApi { | ||||
|   private disableListModels = true; | ||||
|  | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.moonshotUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       const apiPath = ApiPath.Moonshot; | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : 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 ?? ""; | ||||
|   } | ||||
|  | ||||
|   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) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...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; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| @@ -1,14 +1,32 @@ | ||||
| "use client"; | ||||
| // azure and openai, using same models. so using same LLMApi. | ||||
| import { | ||||
|   ApiPath, | ||||
|   DEFAULT_API_HOST, | ||||
|   DEFAULT_MODELS, | ||||
|   OpenaiPath, | ||||
|   Azure, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
| import { collectModelsWithDefaultModel } from "@/app/utils/model"; | ||||
| import { | ||||
|   preProcessImageContent, | ||||
|   uploadImage, | ||||
|   base64Image2Blob, | ||||
| } from "@/app/utils/chat"; | ||||
| import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; | ||||
| import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing"; | ||||
|  | ||||
| import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api"; | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   LLMUsage, | ||||
|   MultimodalContent, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
| @@ -16,7 +34,12 @@ import { | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { makeAzurePath } from "@/app/azure"; | ||||
| import { | ||||
|   getMessageTextContent, | ||||
|   getMessageImages, | ||||
|   isVisionModel, | ||||
|   isDalle3 as _isDalle3, | ||||
| } from "@/app/utils"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
| @@ -27,79 +50,189 @@ export interface OpenAIListModelResponse { | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| export interface RequestPayload { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
|   stream?: boolean; | ||||
|   model: string; | ||||
|   temperature: number; | ||||
|   presence_penalty: number; | ||||
|   frequency_penalty: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
|  | ||||
| export interface DalleRequestPayload { | ||||
|   model: string; | ||||
|   prompt: string; | ||||
|   response_format: "url" | "b64_json"; | ||||
|   n: number; | ||||
|   size: DalleSize; | ||||
|   quality: DalleQuality; | ||||
|   style: DalleStyle; | ||||
| } | ||||
|  | ||||
| export class ChatGPTApi implements LLMApi { | ||||
|   private disableListModels = true; | ||||
|  | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     const isAzure = accessStore.provider === ServiceProvider.Azure; | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (isAzure && !accessStore.isValidAzure()) { | ||||
|       throw Error( | ||||
|         "incomplete azure config, please check it in your settings page", | ||||
|       ); | ||||
|     const isAzure = path.includes("deployments"); | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       if (isAzure && !accessStore.isValidAzure()) { | ||||
|         throw Error( | ||||
|           "incomplete azure config, please check it in your settings page", | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl; | ||||
|     } | ||||
|  | ||||
|     let baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl; | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST : ApiPath.OpenAI; | ||||
|       const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI; | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) { | ||||
|     if ( | ||||
|       !baseUrl.startsWith("http") && | ||||
|       !isAzure && | ||||
|       !baseUrl.startsWith(ApiPath.OpenAI) | ||||
|     ) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     if (isAzure) { | ||||
|       path = makeAzurePath(path, accessStore.azureApiVersion); | ||||
|     } | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|     // try rebuild url, when using cloudflare ai gateway in client | ||||
|     return cloudflareAIGatewayUrl([baseUrl, path].join("/")); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res.choices?.at(0)?.message?.content ?? ""; | ||||
|   async extractMessage(res: any) { | ||||
|     if (res.error) { | ||||
|       return "```\n" + JSON.stringify(res, null, 4) + "\n```"; | ||||
|     } | ||||
|     // dalle3 model return url, using url create image message | ||||
|     if (res.data) { | ||||
|       let url = res.data?.at(0)?.url ?? ""; | ||||
|       const b64_json = res.data?.at(0)?.b64_json ?? ""; | ||||
|       if (!url && b64_json) { | ||||
|         // uploadImage | ||||
|         url = await uploadImage(base64Image2Blob(b64_json, "image/png")); | ||||
|       } | ||||
|       return [ | ||||
|         { | ||||
|           type: "image_url", | ||||
|           image_url: { | ||||
|             url, | ||||
|           }, | ||||
|         }, | ||||
|       ]; | ||||
|     } | ||||
|     return res.choices?.at(0)?.message?.content ?? res; | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       role: v.role, | ||||
|       content: v.content, | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|         providerName: options.config.providerName, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const 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. | ||||
|     }; | ||||
|     let requestPayload: RequestPayload | DalleRequestPayload; | ||||
|  | ||||
|     const isDalle3 = _isDalle3(options.config.model); | ||||
|     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); | ||||
|         messages.push({ role: v.role, content }); | ||||
|       } | ||||
|  | ||||
|       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. | ||||
|       }; | ||||
|  | ||||
|       // add max_tokens to vision model | ||||
|       if (visionModel && modelConfig.model.includes("preview")) { | ||||
|         requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     console.log("[Request] openai payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const shouldStream = !isDalle3 && !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(OpenaiPath.ChatPath); | ||||
|       let chatPath = ""; | ||||
|       if (modelConfig.providerName === ServiceProvider.Azure) { | ||||
|         // find model, and get displayName as deployName | ||||
|         const { models: configModels, customModels: configCustomModels } = | ||||
|           useAppConfig.getState(); | ||||
|         const { | ||||
|           defaultModel, | ||||
|           customModels: accessCustomModels, | ||||
|           useCustomConfig, | ||||
|         } = useAccessStore.getState(); | ||||
|         const models = collectModelsWithDefaultModel( | ||||
|           configModels, | ||||
|           [configCustomModels, accessCustomModels].join(","), | ||||
|           defaultModel, | ||||
|         ); | ||||
|         const model = models.find( | ||||
|           (model) => | ||||
|             model.name === modelConfig.model && | ||||
|             model?.provider?.providerName === ServiceProvider.Azure, | ||||
|         ); | ||||
|         chatPath = this.path( | ||||
|           (isDalle3 ? Azure.ImagePath : Azure.ChatPath)( | ||||
|             (model?.displayName ?? model?.name) as string, | ||||
|             useCustomConfig ? useAccessStore.getState().azureApiVersion : "", | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         chatPath = this.path( | ||||
|           isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath, | ||||
|         ); | ||||
|       } | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
| @@ -110,7 +243,7 @@ export class ChatGPTApi implements LLMApi { | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|         isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow. | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
| @@ -123,6 +256,9 @@ export class ChatGPTApi implements LLMApi { | ||||
|           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; | ||||
|           } | ||||
|  | ||||
| @@ -197,19 +333,31 @@ export class ChatGPTApi implements LLMApi { | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text) as { | ||||
|                 choices: Array<{ | ||||
|                   delta: { | ||||
|                     content: string; | ||||
|                   }; | ||||
|                 }>; | ||||
|               }; | ||||
|               const delta = json.choices[0]?.delta?.content; | ||||
|               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); | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
| @@ -226,7 +374,7 @@ export class ChatGPTApi implements LLMApi { | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         const message = await this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
| @@ -320,13 +468,17 @@ export class ChatGPTApi implements LLMApi { | ||||
|       return []; | ||||
|     } | ||||
|  | ||||
|     //由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场 | ||||
|     let seq = 1000; //同 Constant.ts 中的排序保持一致 | ||||
|     return chatModels.map((m) => ({ | ||||
|       name: m.id, | ||||
|       available: true, | ||||
|       sorted: seq++, | ||||
|       provider: { | ||||
|         id: "openai", | ||||
|         providerName: "OpenAI", | ||||
|         providerType: "openai", | ||||
|         sorted: 1, | ||||
|       }, | ||||
|     })); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										268
									
								
								app/client/platforms/tencent.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,268 @@ | ||||
| "use client"; | ||||
| import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   MultimodalContent, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent, 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"; | ||||
|  | ||||
| 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 | ||||
|         ? DEFAULT_API_HOST + "/api/proxy/tencent" | ||||
|         : 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 ?? ""; | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const visionModel = isVisionModel(options.config.model); | ||||
|     const messages = options.messages.map((v, index) => ({ | ||||
|       // "Messages 中 system 角色必须位于列表的最开始" | ||||
|       role: index !== 0 && v.role === "system" ? "user" : v.role, | ||||
|       content: visionModel ? v.content : getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const requestPayload: RequestPayload = capitalizeKeys({ | ||||
|       model: modelConfig.model, | ||||
|       messages, | ||||
|       temperature: modelConfig.temperature, | ||||
|       top_p: modelConfig.top_p, | ||||
|       stream: options.config.stream, | ||||
|     }); | ||||
|  | ||||
|     console.log("[Request] Tencent payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[Tencent] request response content type: ", | ||||
|               contentType, | ||||
|             ); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.Choices as Array<{ | ||||
|                 Delta: { Content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.Delta?.Content; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| @@ -41,13 +41,16 @@ interface ChatCommands { | ||||
|   del?: Command; | ||||
| } | ||||
|  | ||||
| export const ChatCommandPrefix = ":"; | ||||
| // Compatible with Chinese colon character ":" | ||||
| export const ChatCommandPrefix = /^[::]/; | ||||
|  | ||||
| export function useChatCommand(commands: ChatCommands = {}) { | ||||
|   function extract(userInput: string) { | ||||
|     return ( | ||||
|       userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput | ||||
|     ) as keyof ChatCommands; | ||||
|     const match = userInput.match(ChatCommandPrefix); | ||||
|     if (match) { | ||||
|       return userInput.slice(1) as keyof ChatCommands; | ||||
|     } | ||||
|     return userInput as keyof ChatCommands; | ||||
|   } | ||||
|  | ||||
|   function search(userInput: string) { | ||||
| @@ -57,7 +60,7 @@ export function useChatCommand(commands: ChatCommands = {}) { | ||||
|       .filter((c) => c.startsWith(input)) | ||||
|       .map((c) => ({ | ||||
|         title: desc[c as keyof ChatCommands], | ||||
|         content: ChatCommandPrefix + c, | ||||
|         content: ":" + c, | ||||
|       })); | ||||
|   } | ||||
|  | ||||
|   | ||||
							
								
								
									
										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); | ||||
| } | ||||
							
								
								
									
										234
									
								
								app/components/artifacts.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,234 @@ | ||||
| import { useEffect, useState, useRef, useMemo } from "react"; | ||||
| import { useParams } from "react-router"; | ||||
| import { useWindowSize } from "@/app/utils"; | ||||
| 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 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"; | ||||
|  | ||||
| export function HTMLPreview(props: { | ||||
|   code: string; | ||||
|   autoHeight?: boolean; | ||||
|   height?: number | string; | ||||
|   onLoad?: (title?: string) => void; | ||||
| }) { | ||||
|   const ref = useRef<HTMLIFrameElement>(null); | ||||
|   const frameId = useRef<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.current) { | ||||
|         setIframeHeight(height); | ||||
|       } | ||||
|     }; | ||||
|     window.addEventListener("message", handleMessage); | ||||
|     return () => { | ||||
|       window.removeEventListener("message", handleMessage); | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   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>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`; | ||||
|     if (props.code.includes("</head>")) { | ||||
|       props.code.replace("</head>", "</head>" + script); | ||||
|     } | ||||
|     return props.code + script; | ||||
|   }, [props.code]); | ||||
|  | ||||
|   const handleOnLoad = () => { | ||||
|     if (props?.onLoad) { | ||||
|       props.onLoad(title); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <iframe | ||||
|       className={styles["artifacts-iframe"]} | ||||
|       id={frameId.current} | ||||
|       ref={ref} | ||||
|       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(""); | ||||
|  | ||||
|   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> | ||||
|         <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} | ||||
|             autoHeight={false} | ||||
|             height={"100%"} | ||||
|             onLoad={(title) => { | ||||
|               setFileName(title as string); | ||||
|               setLoading(false); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| import * as React from "react"; | ||||
|  | ||||
| import styles from "./button.module.scss"; | ||||
| import { CSSProperties } from "react"; | ||||
|  | ||||
| export type ButtonType = "primary" | "danger" | null; | ||||
|  | ||||
| @@ -16,6 +17,8 @@ export function IconButton(props: { | ||||
|   disabled?: boolean; | ||||
|   tabIndex?: number; | ||||
|   autoFocus?: boolean; | ||||
|   style?: CSSProperties; | ||||
|   aria?: string; | ||||
| }) { | ||||
|   return ( | ||||
|     <button | ||||
| @@ -31,9 +34,12 @@ export function IconButton(props: { | ||||
|       role="button" | ||||
|       tabIndex={props.tabIndex} | ||||
|       autoFocus={props.autoFocus} | ||||
|       style={props.style} | ||||
|       aria-label={props.aria} | ||||
|     > | ||||
|       {props.icon && ( | ||||
|         <div | ||||
|           aria-label={props.text || props.title} | ||||
|           className={ | ||||
|             styles["icon-button-icon"] + | ||||
|             ` ${props.type === "primary" && "no-dark"}` | ||||
| @@ -44,7 +50,12 @@ export function IconButton(props: { | ||||
|       )} | ||||
|  | ||||
|       {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> | ||||
|   ); | ||||
|   | ||||
| @@ -12,7 +12,7 @@ import { | ||||
| import { useChatStore } from "../store"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
| import { Link, useNavigate } from "react-router-dom"; | ||||
| import { Link, useLocation, useNavigate } from "react-router-dom"; | ||||
| import { Path } from "../constant"; | ||||
| import { MaskAvatar } from "./mask"; | ||||
| import { Mask } from "../store/mask"; | ||||
| @@ -40,12 +40,16 @@ export function ChatItem(props: { | ||||
|       }); | ||||
|     } | ||||
|   }, [props.selected]); | ||||
|  | ||||
|   const { pathname: currentPath } = useLocation(); | ||||
|   return ( | ||||
|     <Draggable draggableId={`${props.id}`} index={props.index}> | ||||
|       {(provided) => ( | ||||
|         <div | ||||
|           className={`${styles["chat-item"]} ${ | ||||
|             props.selected && styles["chat-item-selected"] | ||||
|             props.selected && | ||||
|             (currentPath === Path.Chat || currentPath === Path.Home) && | ||||
|             styles["chat-item-selected"] | ||||
|           }`} | ||||
|           onClick={props.onClick} | ||||
|           ref={(ele) => { | ||||
|   | ||||
| @@ -1,5 +1,47 @@ | ||||
| @import "../styles/animation.scss"; | ||||
|  | ||||
| .attach-images { | ||||
|   position: absolute; | ||||
|   left: 30px; | ||||
|   bottom: 32px; | ||||
|   display: flex; | ||||
| } | ||||
|  | ||||
| .attach-image { | ||||
|   cursor: default; | ||||
|   width: 64px; | ||||
|   height: 64px; | ||||
|   border: rgba($color: #888, $alpha: 0.2) 1px solid; | ||||
|   border-radius: 5px; | ||||
|   margin-right: 10px; | ||||
|   background-size: cover; | ||||
|   background-position: center; | ||||
|   background-color: var(--white); | ||||
|  | ||||
|   .attach-image-mask { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     opacity: 0; | ||||
|     transition: all ease 0.2s; | ||||
|   } | ||||
|  | ||||
|   .attach-image-mask:hover { | ||||
|     opacity: 1; | ||||
|   } | ||||
|  | ||||
|   .delete-image { | ||||
|     width: 24px; | ||||
|     height: 24px; | ||||
|     cursor: pointer; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     border-radius: 5px; | ||||
|     float: right; | ||||
|     background-color: var(--white); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-input-actions { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
| @@ -189,12 +231,10 @@ | ||||
|  | ||||
|   animation: slide-in ease 0.3s; | ||||
|  | ||||
|   $linear: linear-gradient( | ||||
|     to right, | ||||
|     rgba(0, 0, 0, 0), | ||||
|     rgba(0, 0, 0, 1), | ||||
|     rgba(0, 0, 0, 0) | ||||
|   ); | ||||
|   $linear: linear-gradient(to right, | ||||
|       rgba(0, 0, 0, 0), | ||||
|       rgba(0, 0, 0, 1), | ||||
|       rgba(0, 0, 0, 0)); | ||||
|   mask-image: $linear; | ||||
|  | ||||
|   @mixin show { | ||||
| @@ -306,6 +346,12 @@ | ||||
|       flex-wrap: nowrap; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .chat-model-name { | ||||
|     font-size: 12px; | ||||
|     color: var(--black); | ||||
|     margin-left: 6px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-message-container { | ||||
| @@ -327,7 +373,7 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-message-user > .chat-message-container { | ||||
| .chat-message-user>.chat-message-container { | ||||
|   align-items: flex-end; | ||||
| } | ||||
|  | ||||
| @@ -349,6 +395,7 @@ | ||||
|       padding: 7px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /* Specific styles for iOS devices */ | ||||
|   @media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) { | ||||
|     @supports (-webkit-touch-callout: none) { | ||||
| @@ -381,6 +428,64 @@ | ||||
|   transition: all ease 0.3s; | ||||
| } | ||||
|  | ||||
| .chat-message-item-image { | ||||
|   width: 100%; | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| .chat-message-item-images { | ||||
|   width: 100%; | ||||
|   display: grid; | ||||
|   justify-content: left; | ||||
|   grid-gap: 10px; | ||||
|   grid-template-columns: repeat(var(--image-count), auto); | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| .chat-message-item-image-multi { | ||||
|   object-fit: cover; | ||||
|   background-size: cover; | ||||
|   background-position: center; | ||||
|   background-repeat: no-repeat; | ||||
| } | ||||
|  | ||||
| .chat-message-item-image, | ||||
| .chat-message-item-image-multi { | ||||
|   box-sizing: border-box; | ||||
|   border-radius: 10px; | ||||
|   border: rgba($color: #888, $alpha: 0.2) 1px solid; | ||||
| } | ||||
|  | ||||
|  | ||||
| @media only screen and (max-width: 600px) { | ||||
|   $calc-image-width: calc(100vw/3*2/var(--image-count)); | ||||
|  | ||||
|   .chat-message-item-image-multi { | ||||
|     width: $calc-image-width; | ||||
|     height: $calc-image-width; | ||||
|   } | ||||
|    | ||||
|   .chat-message-item-image { | ||||
|     max-width: calc(100vw/3*2); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media screen and (min-width: 600px) { | ||||
|   $max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count)); | ||||
|   $image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count)); | ||||
|  | ||||
|   .chat-message-item-image-multi { | ||||
|     width: $image-width; | ||||
|     height: $image-width; | ||||
|     max-width: $max-image-width; | ||||
|     max-height: $max-image-width; | ||||
|   } | ||||
|  | ||||
|   .chat-message-item-image { | ||||
|     max-width: calc(calc(1200px - var(--sidebar-width))/3*2); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-message-action-date { | ||||
|   font-size: 12px; | ||||
|   opacity: 0.2; | ||||
| @@ -395,7 +500,7 @@ | ||||
|   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); | ||||
|  | ||||
|   &:hover { | ||||
| @@ -460,6 +565,7 @@ | ||||
|  | ||||
|       @include single-line(); | ||||
|     } | ||||
|  | ||||
|     .hint-content { | ||||
|       font-size: 12px; | ||||
|  | ||||
| @@ -474,15 +580,26 @@ | ||||
| } | ||||
|  | ||||
| .chat-input-panel-inner { | ||||
|   cursor: text; | ||||
|   display: flex; | ||||
|   flex: 1; | ||||
|   border-radius: 10px; | ||||
|   border: var(--border-in-light); | ||||
| } | ||||
|  | ||||
| .chat-input-panel-inner-attach { | ||||
|   padding-bottom: 80px; | ||||
| } | ||||
|  | ||||
| .chat-input-panel-inner:has(.chat-input:focus) { | ||||
|   border: 1px solid var(--primary); | ||||
| } | ||||
|  | ||||
| .chat-input { | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|   border-radius: 10px; | ||||
|   border: var(--border-in-light); | ||||
|   border: none; | ||||
|   box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03); | ||||
|   background-color: var(--white); | ||||
|   color: var(--black); | ||||
| @@ -494,9 +611,7 @@ | ||||
|   min-height: 68px; | ||||
| } | ||||
|  | ||||
| .chat-input:focus { | ||||
|   border: 1px solid var(--primary); | ||||
| } | ||||
| .chat-input:focus {} | ||||
|  | ||||
| .chat-input-send { | ||||
|   background-color: var(--primary); | ||||
| @@ -515,4 +630,4 @@ | ||||
|   .chat-input-send { | ||||
|     bottom: 30px; | ||||
|   } | ||||
| } | ||||
| } | ||||
| @@ -6,6 +6,7 @@ import React, { | ||||
|   useMemo, | ||||
|   useCallback, | ||||
|   Fragment, | ||||
|   RefObject, | ||||
| } from "react"; | ||||
|  | ||||
| import SendWhiteIcon from "../icons/send-white.svg"; | ||||
| @@ -15,6 +16,7 @@ import ExportIcon from "../icons/share.svg"; | ||||
| import ReturnIcon from "../icons/return.svg"; | ||||
| import CopyIcon from "../icons/copy.svg"; | ||||
| import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import LoadingButtonIcon from "../icons/loading.svg"; | ||||
| import PromptIcon from "../icons/prompt.svg"; | ||||
| import MaskIcon from "../icons/mask.svg"; | ||||
| import MaxIcon from "../icons/max.svg"; | ||||
| @@ -27,6 +29,7 @@ import PinIcon from "../icons/pin.svg"; | ||||
| import EditIcon from "../icons/rename.svg"; | ||||
| import ConfirmIcon from "../icons/confirm.svg"; | ||||
| import CancelIcon from "../icons/cancel.svg"; | ||||
| import ImageIcon from "../icons/image.svg"; | ||||
|  | ||||
| import LightIcon from "../icons/light.svg"; | ||||
| import DarkIcon from "../icons/dark.svg"; | ||||
| @@ -34,6 +37,10 @@ import AutoIcon from "../icons/auto.svg"; | ||||
| import BottomIcon from "../icons/bottom.svg"; | ||||
| import StopIcon from "../icons/pause.svg"; | ||||
| import RobotIcon from "../icons/robot.svg"; | ||||
| import SizeIcon from "../icons/size.svg"; | ||||
| import QualityIcon from "../icons/hd.svg"; | ||||
| import StyleIcon from "../icons/palette.svg"; | ||||
| import PluginIcon from "../icons/plugin.svg"; | ||||
|  | ||||
| import { | ||||
|   ChatMessage, | ||||
| @@ -53,11 +60,18 @@ import { | ||||
|   selectOrCopy, | ||||
|   autoGrowTextArea, | ||||
|   useMobileScreen, | ||||
|   getMessageTextContent, | ||||
|   getMessageImages, | ||||
|   isVisionModel, | ||||
|   isDalle3, | ||||
| } from "../utils"; | ||||
|  | ||||
| import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; | ||||
|  | ||||
| import dynamic from "next/dynamic"; | ||||
|  | ||||
| import { ChatControllerPool } from "../client/controller"; | ||||
| import { DalleSize, DalleQuality, DalleStyle } from "../typing"; | ||||
| import { Prompt, usePromptStore } from "../store/prompt"; | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| @@ -80,6 +94,8 @@ import { | ||||
|   Path, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
|   UNFINISHED_INPUT, | ||||
|   ServiceProvider, | ||||
|   Plugin, | ||||
| } from "../constant"; | ||||
| import { Avatar } from "./emoji"; | ||||
| import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; | ||||
| @@ -89,6 +105,7 @@ import { prettyObject } from "../utils/format"; | ||||
| import { ExportMessageModal } from "./exporter"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import { useAllModels } from "../utils/hooks"; | ||||
| import { MultimodalContent } from "../client/api"; | ||||
|  | ||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| @@ -211,6 +228,8 @@ function useSubmitHandler() { | ||||
|   }, []); | ||||
|  | ||||
|   const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | ||||
|     // Fix Chinese input method "Enter" on Safari | ||||
|     if (e.keyCode == 229) return false; | ||||
|     if (e.key !== "Enter") return false; | ||||
|     if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current)) | ||||
|       return false; | ||||
| @@ -233,11 +252,11 @@ function useSubmitHandler() { | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export type RenderPompt = Pick<Prompt, "title" | "content">; | ||||
| export type RenderPrompt = Pick<Prompt, "title" | "content">; | ||||
|  | ||||
| export function PromptHints(props: { | ||||
|   prompts: RenderPompt[]; | ||||
|   onPromptSelect: (prompt: RenderPompt) => void; | ||||
|   prompts: RenderPrompt[]; | ||||
|   onPromptSelect: (prompt: RenderPrompt) => void; | ||||
| }) { | ||||
|   const noPrompts = props.prompts.length === 0; | ||||
|   const [selectIndex, setSelectIndex] = useState(0); | ||||
| @@ -326,7 +345,7 @@ function ClearContextDivider() { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function ChatAction(props: { | ||||
| export function ChatAction(props: { | ||||
|   text: string; | ||||
|   icon: JSX.Element; | ||||
|   onClick: () => void; | ||||
| @@ -375,11 +394,13 @@ function ChatAction(props: { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function useScrollToBottom() { | ||||
| function useScrollToBottom( | ||||
|   scrollRef: RefObject<HTMLDivElement>, | ||||
|   detach: boolean = false, | ||||
| ) { | ||||
|   // for auto-scroll | ||||
|   const scrollRef = useRef<HTMLDivElement>(null); | ||||
|   const [autoScroll, setAutoScroll] = useState(true); | ||||
|  | ||||
|   const [autoScroll, setAutoScroll] = useState(true); | ||||
|   function scrollDomToBottom() { | ||||
|     const dom = scrollRef.current; | ||||
|     if (dom) { | ||||
| @@ -392,7 +413,7 @@ function useScrollToBottom() { | ||||
|  | ||||
|   // auto scroll | ||||
|   useEffect(() => { | ||||
|     if (autoScroll) { | ||||
|     if (autoScroll && !detach) { | ||||
|       scrollDomToBottom(); | ||||
|     } | ||||
|   }); | ||||
| @@ -406,10 +427,14 @@ function useScrollToBottom() { | ||||
| } | ||||
|  | ||||
| export function ChatActions(props: { | ||||
|   uploadImage: () => void; | ||||
|   setAttachImages: (images: string[]) => void; | ||||
|   setUploading: (uploading: boolean) => void; | ||||
|   showPromptModal: () => void; | ||||
|   scrollToBottom: () => void; | ||||
|   showPromptHints: () => void; | ||||
|   hitBottom: boolean; | ||||
|   uploading: boolean; | ||||
| }) { | ||||
|   const config = useAppConfig(); | ||||
|   const navigate = useNavigate(); | ||||
| @@ -431,23 +456,73 @@ export function ChatActions(props: { | ||||
|  | ||||
|   // switch model | ||||
|   const currentModel = chatStore.currentSession().mask.modelConfig.model; | ||||
|   const currentProviderName = | ||||
|     chatStore.currentSession().mask.modelConfig?.providerName || | ||||
|     ServiceProvider.OpenAI; | ||||
|   const allModels = useAllModels(); | ||||
|   const models = useMemo( | ||||
|     () => allModels.filter((m) => m.available), | ||||
|     [allModels], | ||||
|   ); | ||||
|   const models = useMemo(() => { | ||||
|     const filteredModels = allModels.filter((m) => m.available); | ||||
|     const defaultModel = filteredModels.find((m) => m.isDefault); | ||||
|  | ||||
|     if (defaultModel) { | ||||
|       const arr = [ | ||||
|         defaultModel, | ||||
|         ...filteredModels.filter((m) => m !== defaultModel), | ||||
|       ]; | ||||
|       return arr; | ||||
|     } else { | ||||
|       return filteredModels; | ||||
|     } | ||||
|   }, [allModels]); | ||||
|   const currentModelName = useMemo(() => { | ||||
|     const model = models.find( | ||||
|       (m) => | ||||
|         m.name == currentModel && | ||||
|         m?.provider?.providerName == currentProviderName, | ||||
|     ); | ||||
|     return model?.displayName ?? ""; | ||||
|   }, [models, currentModel, currentProviderName]); | ||||
|   const [showModelSelector, setShowModelSelector] = useState(false); | ||||
|   const [showPluginSelector, setShowPluginSelector] = useState(false); | ||||
|   const [showUploadImage, setShowUploadImage] = useState(false); | ||||
|  | ||||
|   const [showSizeSelector, setShowSizeSelector] = useState(false); | ||||
|   const [showQualitySelector, setShowQualitySelector] = useState(false); | ||||
|   const [showStyleSelector, setShowStyleSelector] = useState(false); | ||||
|   const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"]; | ||||
|   const dalle3Qualitys: DalleQuality[] = ["standard", "hd"]; | ||||
|   const dalle3Styles: DalleStyle[] = ["vivid", "natural"]; | ||||
|   const currentSize = | ||||
|     chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024"; | ||||
|   const currentQuality = | ||||
|     chatStore.currentSession().mask.modelConfig?.quality ?? "standard"; | ||||
|   const currentStyle = | ||||
|     chatStore.currentSession().mask.modelConfig?.style ?? "vivid"; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const show = isVisionModel(currentModel); | ||||
|     setShowUploadImage(show); | ||||
|     if (!show) { | ||||
|       props.setAttachImages([]); | ||||
|       props.setUploading(false); | ||||
|     } | ||||
|  | ||||
|     // if current model is not available | ||||
|     // switch to first available model | ||||
|     const isUnavaliableModel = !models.some((m) => m.name === currentModel); | ||||
|     if (isUnavaliableModel && models.length > 0) { | ||||
|       const nextModel = models[0].name as ModelType; | ||||
|       chatStore.updateCurrentSession( | ||||
|         (session) => (session.mask.modelConfig.model = nextModel), | ||||
|       // show next model to default model if exist | ||||
|       let nextModel = models.find((model) => model.isDefault) || models[0]; | ||||
|       chatStore.updateCurrentSession((session) => { | ||||
|         session.mask.modelConfig.model = nextModel.name; | ||||
|         session.mask.modelConfig.providerName = nextModel?.provider | ||||
|           ?.providerName as ServiceProvider; | ||||
|       }); | ||||
|       showToast( | ||||
|         nextModel?.provider?.providerName == "ByteDance" | ||||
|           ? nextModel.displayName | ||||
|           : nextModel.name, | ||||
|       ); | ||||
|       showToast(nextModel); | ||||
|     } | ||||
|   }, [chatStore, currentModel, models]); | ||||
|  | ||||
| @@ -475,6 +550,13 @@ export function ChatActions(props: { | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {showUploadImage && ( | ||||
|         <ChatAction | ||||
|           onClick={props.uploadImage} | ||||
|           text={Locale.Chat.InputActions.UploadImage} | ||||
|           icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />} | ||||
|         /> | ||||
|       )} | ||||
|       <ChatAction | ||||
|         onClick={nextTheme} | ||||
|         text={Locale.Chat.InputActions.Theme[theme]} | ||||
| @@ -522,25 +604,149 @@ export function ChatActions(props: { | ||||
|  | ||||
|       <ChatAction | ||||
|         onClick={() => setShowModelSelector(true)} | ||||
|         text={currentModel} | ||||
|         text={currentModelName} | ||||
|         icon={<RobotIcon />} | ||||
|       /> | ||||
|  | ||||
|       {showModelSelector && ( | ||||
|         <Selector | ||||
|           defaultSelectedValue={currentModel} | ||||
|           defaultSelectedValue={`${currentModel}@${currentProviderName}`} | ||||
|           items={models.map((m) => ({ | ||||
|             title: m.displayName, | ||||
|             value: m.name, | ||||
|             title: `${m.displayName}${ | ||||
|               m?.provider?.providerName | ||||
|                 ? "(" + m?.provider?.providerName + ")" | ||||
|                 : "" | ||||
|             }`, | ||||
|             value: `${m.name}@${m?.provider?.providerName}`, | ||||
|           }))} | ||||
|           onClose={() => setShowModelSelector(false)} | ||||
|           onSelection={(s) => { | ||||
|             if (s.length === 0) return; | ||||
|             const [model, providerName] = s[0].split("@"); | ||||
|             chatStore.updateCurrentSession((session) => { | ||||
|               session.mask.modelConfig.model = s[0] as ModelType; | ||||
|               session.mask.modelConfig.model = model as ModelType; | ||||
|               session.mask.modelConfig.providerName = | ||||
|                 providerName as ServiceProvider; | ||||
|               session.mask.syncGlobalConfig = false; | ||||
|             }); | ||||
|             showToast(s[0]); | ||||
|             if (providerName == "ByteDance") { | ||||
|               const selectedModel = models.find( | ||||
|                 (m) => | ||||
|                   m.name == model && m?.provider?.providerName == providerName, | ||||
|               ); | ||||
|               showToast(selectedModel?.displayName ?? ""); | ||||
|             } else { | ||||
|               showToast(model); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {isDalle3(currentModel) && ( | ||||
|         <ChatAction | ||||
|           onClick={() => setShowSizeSelector(true)} | ||||
|           text={currentSize} | ||||
|           icon={<SizeIcon />} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {showSizeSelector && ( | ||||
|         <Selector | ||||
|           defaultSelectedValue={currentSize} | ||||
|           items={dalle3Sizes.map((m) => ({ | ||||
|             title: m, | ||||
|             value: m, | ||||
|           }))} | ||||
|           onClose={() => setShowSizeSelector(false)} | ||||
|           onSelection={(s) => { | ||||
|             if (s.length === 0) return; | ||||
|             const size = s[0]; | ||||
|             chatStore.updateCurrentSession((session) => { | ||||
|               session.mask.modelConfig.size = size; | ||||
|             }); | ||||
|             showToast(size); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {isDalle3(currentModel) && ( | ||||
|         <ChatAction | ||||
|           onClick={() => setShowQualitySelector(true)} | ||||
|           text={currentQuality} | ||||
|           icon={<QualityIcon />} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {showQualitySelector && ( | ||||
|         <Selector | ||||
|           defaultSelectedValue={currentQuality} | ||||
|           items={dalle3Qualitys.map((m) => ({ | ||||
|             title: m, | ||||
|             value: m, | ||||
|           }))} | ||||
|           onClose={() => setShowQualitySelector(false)} | ||||
|           onSelection={(q) => { | ||||
|             if (q.length === 0) return; | ||||
|             const quality = q[0]; | ||||
|             chatStore.updateCurrentSession((session) => { | ||||
|               session.mask.modelConfig.quality = quality; | ||||
|             }); | ||||
|             showToast(quality); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {isDalle3(currentModel) && ( | ||||
|         <ChatAction | ||||
|           onClick={() => setShowStyleSelector(true)} | ||||
|           text={currentStyle} | ||||
|           icon={<StyleIcon />} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {showStyleSelector && ( | ||||
|         <Selector | ||||
|           defaultSelectedValue={currentStyle} | ||||
|           items={dalle3Styles.map((m) => ({ | ||||
|             title: m, | ||||
|             value: m, | ||||
|           }))} | ||||
|           onClose={() => setShowStyleSelector(false)} | ||||
|           onSelection={(s) => { | ||||
|             if (s.length === 0) return; | ||||
|             const style = s[0]; | ||||
|             chatStore.updateCurrentSession((session) => { | ||||
|               session.mask.modelConfig.style = style; | ||||
|             }); | ||||
|             showToast(style); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <ChatAction | ||||
|         onClick={() => setShowPluginSelector(true)} | ||||
|         text={Locale.Plugin.Name} | ||||
|         icon={<PluginIcon />} | ||||
|       /> | ||||
|       {showPluginSelector && ( | ||||
|         <Selector | ||||
|           multiple | ||||
|           defaultSelectedValue={chatStore.currentSession().mask?.plugin} | ||||
|           items={[ | ||||
|             { | ||||
|               title: Locale.Plugin.Artifacts, | ||||
|               value: Plugin.Artifacts, | ||||
|             }, | ||||
|           ]} | ||||
|           onClose={() => setShowPluginSelector(false)} | ||||
|           onSelection={(s) => { | ||||
|             const plugin = s[0]; | ||||
|             chatStore.updateCurrentSession((session) => { | ||||
|               session.mask.plugin = s; | ||||
|             }); | ||||
|             if (plugin) { | ||||
|               showToast(plugin); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
| @@ -610,6 +816,14 @@ export function EditMessageModal(props: { onClose: () => void }) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function DeleteImageButton(props: { deleteImage: () => void }) { | ||||
|   return ( | ||||
|     <div className={styles["delete-image"]} onClick={props.deleteImage}> | ||||
|       <DeleteIcon /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function _Chat() { | ||||
|   type RenderMessage = ChatMessage & { preview?: boolean }; | ||||
|  | ||||
| @@ -617,6 +831,7 @@ function _Chat() { | ||||
|   const session = chatStore.currentSession(); | ||||
|   const config = useAppConfig(); | ||||
|   const fontSize = config.fontSize; | ||||
|   const fontFamily = config.fontFamily; | ||||
|  | ||||
|   const [showExport, setShowExport] = useState(false); | ||||
|  | ||||
| @@ -624,14 +839,26 @@ function _Chat() { | ||||
|   const [userInput, setUserInput] = useState(""); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { submitKey, shouldSubmit } = useSubmitHandler(); | ||||
|   const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom(); | ||||
|   const scrollRef = useRef<HTMLDivElement>(null); | ||||
|   const isScrolledToBottom = scrollRef?.current | ||||
|     ? Math.abs( | ||||
|         scrollRef.current.scrollHeight - | ||||
|           (scrollRef.current.scrollTop + scrollRef.current.clientHeight), | ||||
|       ) <= 1 | ||||
|     : false; | ||||
|   const { setAutoScroll, scrollDomToBottom } = useScrollToBottom( | ||||
|     scrollRef, | ||||
|     isScrolledToBottom, | ||||
|   ); | ||||
|   const [hitBottom, setHitBottom] = useState(true); | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const navigate = useNavigate(); | ||||
|   const [attachImages, setAttachImages] = useState<string[]>([]); | ||||
|   const [uploading, setUploading] = useState(false); | ||||
|  | ||||
|   // prompt hints | ||||
|   const promptStore = usePromptStore(); | ||||
|   const [promptHints, setPromptHints] = useState<RenderPompt[]>([]); | ||||
|   const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]); | ||||
|   const onSearch = useDebouncedCallback( | ||||
|     (text: string) => { | ||||
|       const matchedPrompts = promptStore.search(text); | ||||
| @@ -684,7 +911,7 @@ function _Chat() { | ||||
|     // clear search results | ||||
|     if (n === 0) { | ||||
|       setPromptHints([]); | ||||
|     } else if (text.startsWith(ChatCommandPrefix)) { | ||||
|     } else if (text.match(ChatCommandPrefix)) { | ||||
|       setPromptHints(chatCommands.search(text)); | ||||
|     } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { | ||||
|       // check if need to trigger auto completion | ||||
| @@ -705,7 +932,10 @@ function _Chat() { | ||||
|       return; | ||||
|     } | ||||
|     setIsLoading(true); | ||||
|     chatStore.onUserInput(userInput).then(() => setIsLoading(false)); | ||||
|     chatStore | ||||
|       .onUserInput(userInput, attachImages) | ||||
|       .then(() => setIsLoading(false)); | ||||
|     setAttachImages([]); | ||||
|     localStorage.setItem(LAST_INPUT_KEY, userInput); | ||||
|     setUserInput(""); | ||||
|     setPromptHints([]); | ||||
| @@ -713,7 +943,7 @@ function _Chat() { | ||||
|     setAutoScroll(true); | ||||
|   }; | ||||
|  | ||||
|   const onPromptSelect = (prompt: RenderPompt) => { | ||||
|   const onPromptSelect = (prompt: RenderPrompt) => { | ||||
|     setTimeout(() => { | ||||
|       setPromptHints([]); | ||||
|  | ||||
| @@ -783,9 +1013,9 @@ function _Chat() { | ||||
|   }; | ||||
|   const onRightClick = (e: any, message: ChatMessage) => { | ||||
|     // copy to clipboard | ||||
|     if (selectOrCopy(e.currentTarget, message.content)) { | ||||
|     if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) { | ||||
|       if (userInput.length === 0) { | ||||
|         setUserInput(message.content); | ||||
|         setUserInput(getMessageTextContent(message)); | ||||
|       } | ||||
|  | ||||
|       e.preventDefault(); | ||||
| @@ -853,7 +1083,9 @@ function _Chat() { | ||||
|  | ||||
|     // resend the message | ||||
|     setIsLoading(true); | ||||
|     chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false)); | ||||
|     const textContent = getMessageTextContent(userMessage); | ||||
|     const images = getMessageImages(userMessage); | ||||
|     chatStore.onUserInput(textContent, images).then(() => setIsLoading(false)); | ||||
|     inputRef.current?.focus(); | ||||
|   }; | ||||
|  | ||||
| @@ -962,7 +1194,6 @@ function _Chat() { | ||||
|     setHitBottom(isHitBottom); | ||||
|     setAutoScroll(isHitBottom); | ||||
|   }; | ||||
|  | ||||
|   function scrollToBottom() { | ||||
|     setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); | ||||
|     scrollDomToBottom(); | ||||
| @@ -1020,6 +1251,7 @@ function _Chat() { | ||||
|             if (payload.url) { | ||||
|               accessStore.update((access) => (access.openaiUrl = payload.url!)); | ||||
|             } | ||||
|             accessStore.update((access) => (access.useCustomConfig = true)); | ||||
|           }); | ||||
|         } | ||||
|       } catch { | ||||
| @@ -1048,6 +1280,94 @@ function _Chat() { | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
|   }, []); | ||||
|  | ||||
|   const handlePaste = useCallback( | ||||
|     async (event: React.ClipboardEvent<HTMLTextAreaElement>) => { | ||||
|       const currentModel = chatStore.currentSession().mask.modelConfig.model; | ||||
|       if (!isVisionModel(currentModel)) { | ||||
|         return; | ||||
|       } | ||||
|       const items = (event.clipboardData || window.clipboardData).items; | ||||
|       for (const item of items) { | ||||
|         if (item.kind === "file" && item.type.startsWith("image/")) { | ||||
|           event.preventDefault(); | ||||
|           const file = item.getAsFile(); | ||||
|           if (file) { | ||||
|             const images: string[] = []; | ||||
|             images.push(...attachImages); | ||||
|             images.push( | ||||
|               ...(await new Promise<string[]>((res, rej) => { | ||||
|                 setUploading(true); | ||||
|                 const imagesData: string[] = []; | ||||
|                 uploadImageRemote(file) | ||||
|                   .then((dataUrl) => { | ||||
|                     imagesData.push(dataUrl); | ||||
|                     setUploading(false); | ||||
|                     res(imagesData); | ||||
|                   }) | ||||
|                   .catch((e) => { | ||||
|                     setUploading(false); | ||||
|                     rej(e); | ||||
|                   }); | ||||
|               })), | ||||
|             ); | ||||
|             const imagesLength = images.length; | ||||
|  | ||||
|             if (imagesLength > 3) { | ||||
|               images.splice(3, imagesLength - 3); | ||||
|             } | ||||
|             setAttachImages(images); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     [attachImages, chatStore], | ||||
|   ); | ||||
|  | ||||
|   async function uploadImage() { | ||||
|     const images: string[] = []; | ||||
|     images.push(...attachImages); | ||||
|  | ||||
|     images.push( | ||||
|       ...(await new Promise<string[]>((res, rej) => { | ||||
|         const fileInput = document.createElement("input"); | ||||
|         fileInput.type = "file"; | ||||
|         fileInput.accept = | ||||
|           "image/png, image/jpeg, image/webp, image/heic, image/heif"; | ||||
|         fileInput.multiple = true; | ||||
|         fileInput.onchange = (event: any) => { | ||||
|           setUploading(true); | ||||
|           const files = event.target.files; | ||||
|           const imagesData: string[] = []; | ||||
|           for (let i = 0; i < files.length; i++) { | ||||
|             const file = event.target.files[i]; | ||||
|             uploadImageRemote(file) | ||||
|               .then((dataUrl) => { | ||||
|                 imagesData.push(dataUrl); | ||||
|                 if ( | ||||
|                   imagesData.length === 3 || | ||||
|                   imagesData.length === files.length | ||||
|                 ) { | ||||
|                   setUploading(false); | ||||
|                   res(imagesData); | ||||
|                 } | ||||
|               }) | ||||
|               .catch((e) => { | ||||
|                 setUploading(false); | ||||
|                 rej(e); | ||||
|               }); | ||||
|           } | ||||
|         }; | ||||
|         fileInput.click(); | ||||
|       })), | ||||
|     ); | ||||
|  | ||||
|     const imagesLength = images.length; | ||||
|     if (imagesLength > 3) { | ||||
|       images.splice(3, imagesLength - 3); | ||||
|     } | ||||
|     setAttachImages(images); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles.chat} key={session.id}> | ||||
|       <div className="window-header" data-tauri-drag-region> | ||||
| @@ -1081,6 +1401,8 @@ function _Chat() { | ||||
|               <IconButton | ||||
|                 icon={<RenameIcon />} | ||||
|                 bordered | ||||
|                 title={Locale.Chat.EditMessage.Title} | ||||
|                 aria={Locale.Chat.EditMessage.Title} | ||||
|                 onClick={() => setIsEditingMessage(true)} | ||||
|               /> | ||||
|             </div> | ||||
| @@ -1100,6 +1422,8 @@ function _Chat() { | ||||
|               <IconButton | ||||
|                 icon={config.tightBorder ? <MinIcon /> : <MaxIcon />} | ||||
|                 bordered | ||||
|                 title={Locale.Chat.Actions.FullScreen} | ||||
|                 aria={Locale.Chat.Actions.FullScreen} | ||||
|                 onClick={() => { | ||||
|                   config.update( | ||||
|                     (config) => (config.tightBorder = !config.tightBorder), | ||||
| @@ -1151,18 +1475,33 @@ function _Chat() { | ||||
|                       <div className={styles["chat-message-edit"]}> | ||||
|                         <IconButton | ||||
|                           icon={<EditIcon />} | ||||
|                           aria={Locale.Chat.Actions.Edit} | ||||
|                           onClick={async () => { | ||||
|                             const newMessage = await showPrompt( | ||||
|                               Locale.Chat.Actions.Edit, | ||||
|                               message.content, | ||||
|                               getMessageTextContent(message), | ||||
|                               10, | ||||
|                             ); | ||||
|                             let newContent: string | MultimodalContent[] = | ||||
|                               newMessage; | ||||
|                             const images = getMessageImages(message); | ||||
|                             if (images.length > 0) { | ||||
|                               newContent = [{ type: "text", text: newMessage }]; | ||||
|                               for (let i = 0; i < images.length; i++) { | ||||
|                                 newContent.push({ | ||||
|                                   type: "image_url", | ||||
|                                   image_url: { | ||||
|                                     url: images[i], | ||||
|                                   }, | ||||
|                                 }); | ||||
|                               } | ||||
|                             } | ||||
|                             chatStore.updateCurrentSession((session) => { | ||||
|                               const m = session.mask.context | ||||
|                                 .concat(session.messages) | ||||
|                                 .find((m) => m.id === message.id); | ||||
|                               if (m) { | ||||
|                                 m.content = newMessage; | ||||
|                                 m.content = newContent; | ||||
|                               } | ||||
|                             }); | ||||
|                           }} | ||||
| @@ -1185,6 +1524,11 @@ function _Chat() { | ||||
|                         </> | ||||
|                       )} | ||||
|                     </div> | ||||
|                     {!isUser && ( | ||||
|                       <div className={styles["chat-model-name"]}> | ||||
|                         {message.model} | ||||
|                       </div> | ||||
|                     )} | ||||
|  | ||||
|                     {showActions && ( | ||||
|                       <div className={styles["chat-message-actions"]}> | ||||
| @@ -1217,7 +1561,11 @@ function _Chat() { | ||||
|                               <ChatAction | ||||
|                                 text={Locale.Chat.Actions.Copy} | ||||
|                                 icon={<CopyIcon />} | ||||
|                                 onClick={() => copyToClipboard(message.content)} | ||||
|                                 onClick={() => | ||||
|                                   copyToClipboard( | ||||
|                                     getMessageTextContent(message), | ||||
|                                   ) | ||||
|                                 } | ||||
|                               /> | ||||
|                             </> | ||||
|                           )} | ||||
| @@ -1232,7 +1580,8 @@ function _Chat() { | ||||
|                   )} | ||||
|                   <div className={styles["chat-message-item"]}> | ||||
|                     <Markdown | ||||
|                       content={message.content} | ||||
|                       key={message.streaming ? "loading" : "done"} | ||||
|                       content={getMessageTextContent(message)} | ||||
|                       loading={ | ||||
|                         (message.preview || message.streaming) && | ||||
|                         message.content.length === 0 && | ||||
| @@ -1241,12 +1590,43 @@ function _Chat() { | ||||
|                       onContextMenu={(e) => onRightClick(e, message)} | ||||
|                       onDoubleClickCapture={() => { | ||||
|                         if (!isMobileScreen) return; | ||||
|                         setUserInput(message.content); | ||||
|                         setUserInput(getMessageTextContent(message)); | ||||
|                       }} | ||||
|                       fontSize={fontSize} | ||||
|                       fontFamily={fontFamily} | ||||
|                       parentRef={scrollRef} | ||||
|                       defaultShow={i >= messages.length - 6} | ||||
|                     /> | ||||
|                     {getMessageImages(message).length == 1 && ( | ||||
|                       <img | ||||
|                         className={styles["chat-message-item-image"]} | ||||
|                         src={getMessageImages(message)[0]} | ||||
|                         alt="" | ||||
|                       /> | ||||
|                     )} | ||||
|                     {getMessageImages(message).length > 1 && ( | ||||
|                       <div | ||||
|                         className={styles["chat-message-item-images"]} | ||||
|                         style={ | ||||
|                           { | ||||
|                             "--image-count": getMessageImages(message).length, | ||||
|                           } as React.CSSProperties | ||||
|                         } | ||||
|                       > | ||||
|                         {getMessageImages(message).map((image, index) => { | ||||
|                           return ( | ||||
|                             <img | ||||
|                               className={ | ||||
|                                 styles["chat-message-item-image-multi"] | ||||
|                               } | ||||
|                               key={index} | ||||
|                               src={image} | ||||
|                               alt="" | ||||
|                             /> | ||||
|                           ); | ||||
|                         })} | ||||
|                       </div> | ||||
|                     )} | ||||
|                   </div> | ||||
|  | ||||
|                   <div className={styles["chat-message-action-date"]}> | ||||
| @@ -1266,9 +1646,13 @@ function _Chat() { | ||||
|         <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} /> | ||||
|  | ||||
|         <ChatActions | ||||
|           uploadImage={uploadImage} | ||||
|           setAttachImages={setAttachImages} | ||||
|           setUploading={setUploading} | ||||
|           showPromptModal={() => setShowPromptModal(true)} | ||||
|           scrollToBottom={scrollToBottom} | ||||
|           hitBottom={hitBottom} | ||||
|           uploading={uploading} | ||||
|           showPromptHints={() => { | ||||
|             // Click again to close | ||||
|             if (promptHints.length > 0) { | ||||
| @@ -1281,8 +1665,16 @@ function _Chat() { | ||||
|             onSearch(""); | ||||
|           }} | ||||
|         /> | ||||
|         <div className={styles["chat-input-panel-inner"]}> | ||||
|         <label | ||||
|           className={`${styles["chat-input-panel-inner"]} ${ | ||||
|             attachImages.length != 0 | ||||
|               ? styles["chat-input-panel-inner-attach"] | ||||
|               : "" | ||||
|           }`} | ||||
|           htmlFor="chat-input" | ||||
|         > | ||||
|           <textarea | ||||
|             id="chat-input" | ||||
|             ref={inputRef} | ||||
|             className={styles["chat-input"]} | ||||
|             placeholder={Locale.Chat.Input(submitKey)} | ||||
| @@ -1291,12 +1683,37 @@ function _Chat() { | ||||
|             onKeyDown={onInputKeyDown} | ||||
|             onFocus={scrollToBottom} | ||||
|             onClick={scrollToBottom} | ||||
|             onPaste={handlePaste} | ||||
|             rows={inputRows} | ||||
|             autoFocus={autoFocus} | ||||
|             style={{ | ||||
|               fontSize: config.fontSize, | ||||
|               fontFamily: config.fontFamily, | ||||
|             }} | ||||
|           /> | ||||
|           {attachImages.length != 0 && ( | ||||
|             <div className={styles["attach-images"]}> | ||||
|               {attachImages.map((image, index) => { | ||||
|                 return ( | ||||
|                   <div | ||||
|                     key={index} | ||||
|                     className={styles["attach-image"]} | ||||
|                     style={{ backgroundImage: `url("${image}")` }} | ||||
|                   > | ||||
|                     <div className={styles["attach-image-mask"]}> | ||||
|                       <DeleteImageButton | ||||
|                         deleteImage={() => { | ||||
|                           setAttachImages( | ||||
|                             attachImages.filter((_, i) => i !== index), | ||||
|                           ); | ||||
|                         }} | ||||
|                       /> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 ); | ||||
|               })} | ||||
|             </div> | ||||
|           )} | ||||
|           <IconButton | ||||
|             icon={<SendWhiteIcon />} | ||||
|             text={Locale.Chat.Send} | ||||
| @@ -1304,7 +1721,7 @@ function _Chat() { | ||||
|             type="primary" | ||||
|             onClick={() => doSubmit(userInput)} | ||||
|           /> | ||||
|         </div> | ||||
|         </label> | ||||
|       </div> | ||||
|  | ||||
|       {showExport && ( | ||||
|   | ||||
| @@ -21,6 +21,7 @@ export function AvatarPicker(props: { | ||||
| }) { | ||||
|   return ( | ||||
|     <EmojiPicker | ||||
|       width={"100%"} | ||||
|       lazyLoadEmojis | ||||
|       theme={EmojiTheme.AUTO} | ||||
|       getEmojiUrl={getEmojiUrl} | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| "use client"; | ||||
|  | ||||
| import React from "react"; | ||||
| import { IconButton } from "./button"; | ||||
| import GithubIcon from "../icons/github.svg"; | ||||
|   | ||||
| @@ -94,6 +94,7 @@ | ||||
|  | ||||
|   button { | ||||
|     flex-grow: 1; | ||||
|  | ||||
|     &:not(:last-child) { | ||||
|       margin-right: 10px; | ||||
|     } | ||||
| @@ -190,6 +191,59 @@ | ||||
|         pre { | ||||
|           overflow: hidden; | ||||
|         } | ||||
|  | ||||
|         .message-image { | ||||
|           width: 100%; | ||||
|           margin-top: 10px; | ||||
|         } | ||||
|  | ||||
|         .message-images { | ||||
|           display: grid; | ||||
|           justify-content: left; | ||||
|           grid-gap: 10px; | ||||
|           grid-template-columns: repeat(var(--image-count), auto); | ||||
|           margin-top: 10px; | ||||
|         } | ||||
|  | ||||
|         @media screen and (max-width: 600px) { | ||||
|           $image-width: calc(calc(100vw/2)/var(--image-count)); | ||||
|  | ||||
|           .message-image-multi { | ||||
|             width: $image-width; | ||||
|             height: $image-width; | ||||
|           } | ||||
|  | ||||
|           .message-image { | ||||
|             max-width: calc(100vw/3*2); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         @media screen and (min-width: 600px) { | ||||
|           $max-image-width: calc(900px/3*2/var(--image-count)); | ||||
|           $image-width: calc(80vw/3*2/var(--image-count)); | ||||
|  | ||||
|           .message-image-multi { | ||||
|             width: $image-width; | ||||
|             height: $image-width; | ||||
|             max-width: $max-image-width; | ||||
|             max-height: $max-image-width; | ||||
|           } | ||||
|  | ||||
|           .message-image { | ||||
|             max-width: calc(100vw/3*2); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         .message-image-multi { | ||||
|           object-fit: cover; | ||||
|         } | ||||
|  | ||||
|         .message-image, | ||||
|         .message-image-multi { | ||||
|           box-sizing: border-box; | ||||
|           border-radius: 10px; | ||||
|           border: rgba($color: #888, $alpha: 0.2) 1px solid; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       &-assistant { | ||||
| @@ -213,6 +267,5 @@ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .default-theme { | ||||
|   } | ||||
| } | ||||
|   .default-theme {} | ||||
| } | ||||
| @@ -12,7 +12,12 @@ import { | ||||
|   showToast, | ||||
| } from "./ui-lib"; | ||||
| import { IconButton } from "./button"; | ||||
| import { copyToClipboard, downloadAs, useMobileScreen } from "../utils"; | ||||
| import { | ||||
|   copyToClipboard, | ||||
|   downloadAs, | ||||
|   getMessageImages, | ||||
|   useMobileScreen, | ||||
| } from "../utils"; | ||||
|  | ||||
| import CopyIcon from "../icons/copy.svg"; | ||||
| import LoadingIcon from "../icons/three-dots.svg"; | ||||
| @@ -31,9 +36,10 @@ import { toBlob, toPng } from "html-to-image"; | ||||
| import { DEFAULT_MASK_AVATAR } from "../store/mask"; | ||||
|  | ||||
| import { prettyObject } from "../utils/format"; | ||||
| import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant"; | ||||
| import { EXPORT_MESSAGE_CLASS_NAME } from "../constant"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import { ClientApi } from "../client/api"; | ||||
| import { type ClientApi, getClientApi } from "../client/api"; | ||||
| import { getMessageTextContent } from "../utils"; | ||||
|  | ||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| @@ -287,7 +293,7 @@ export function RenderExport(props: { | ||||
|           id={`${m.role}:${i}`} | ||||
|           className={EXPORT_MESSAGE_CLASS_NAME} | ||||
|         > | ||||
|           <Markdown content={m.content} defaultShow /> | ||||
|           <Markdown content={getMessageTextContent(m)} defaultShow /> | ||||
|         </div> | ||||
|       ))} | ||||
|     </div> | ||||
| @@ -306,12 +312,7 @@ export function PreviewActions(props: { | ||||
|   const onRenderMsgs = (msgs: ChatMessage[]) => { | ||||
|     setShouldExport(false); | ||||
|  | ||||
|     var api: ClientApi; | ||||
|     if (config.modelConfig.model === "gemini-pro") { | ||||
|       api = new ClientApi(ModelProvider.GeminiPro); | ||||
|     } else { | ||||
|       api = new ClientApi(ModelProvider.GPT); | ||||
|     } | ||||
|     const api: ClientApi = getClientApi(config.modelConfig.providerName); | ||||
|  | ||||
|     api | ||||
|       .share(msgs) | ||||
| @@ -540,7 +541,7 @@ export function ImagePreviewer(props: { | ||||
|           <div> | ||||
|             <div className={styles["main-title"]}>NextChat</div> | ||||
|             <div className={styles["sub-title"]}> | ||||
|               github.com/Yidadaa/ChatGPT-Next-Web | ||||
|               github.com/ChatGPTNextWeb/ChatGPT-Next-Web | ||||
|             </div> | ||||
|             <div className={styles["icons"]}> | ||||
|               <ExportAvatar avatar={config.avatar} /> | ||||
| @@ -580,10 +581,38 @@ export function ImagePreviewer(props: { | ||||
|  | ||||
|               <div className={styles["body"]}> | ||||
|                 <Markdown | ||||
|                   content={m.content} | ||||
|                   content={getMessageTextContent(m)} | ||||
|                   fontSize={config.fontSize} | ||||
|                   fontFamily={config.fontFamily} | ||||
|                   defaultShow | ||||
|                 /> | ||||
|                 {getMessageImages(m).length == 1 && ( | ||||
|                   <img | ||||
|                     key={i} | ||||
|                     src={getMessageImages(m)[0]} | ||||
|                     alt="message" | ||||
|                     className={styles["message-image"]} | ||||
|                   /> | ||||
|                 )} | ||||
|                 {getMessageImages(m).length > 1 && ( | ||||
|                   <div | ||||
|                     className={styles["message-images"]} | ||||
|                     style={ | ||||
|                       { | ||||
|                         "--image-count": getMessageImages(m).length, | ||||
|                       } as React.CSSProperties | ||||
|                     } | ||||
|                   > | ||||
|                     {getMessageImages(m).map((src, i) => ( | ||||
|                       <img | ||||
|                         key={i} | ||||
|                         src={src} | ||||
|                         alt="message" | ||||
|                         className={styles["message-image-multi"]} | ||||
|                       /> | ||||
|                     ))} | ||||
|                   </div> | ||||
|                 )} | ||||
|               </div> | ||||
|             </div> | ||||
|           ); | ||||
| @@ -602,8 +631,10 @@ export function MarkdownPreviewer(props: { | ||||
|     props.messages | ||||
|       .map((m) => { | ||||
|         return m.role === "user" | ||||
|           ? `## ${Locale.Export.MessageFromYou}:\n${m.content}` | ||||
|           : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`; | ||||
|           ? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}` | ||||
|           : `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent( | ||||
|               m, | ||||
|             ).trim()}`; | ||||
|       }) | ||||
|       .join("\n\n"); | ||||
|  | ||||
|   | ||||
| @@ -137,12 +137,18 @@ | ||||
|   position: relative; | ||||
|   padding-top: 20px; | ||||
|   padding-bottom: 20px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .sidebar-logo { | ||||
|   position: absolute; | ||||
|   right: 0; | ||||
|   bottom: 18px; | ||||
|   display: inline-flex; | ||||
| } | ||||
|  | ||||
| .sidebar-title-container { | ||||
|   display: inline-flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| .sidebar-title { | ||||
|   | ||||
| @@ -12,7 +12,7 @@ import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import { getCSSVar, useMobileScreen } from "../utils"; | ||||
|  | ||||
| import dynamic from "next/dynamic"; | ||||
| import { ModelProvider, Path, SlotID } from "../constant"; | ||||
| import { Path, SlotID } from "../constant"; | ||||
| import { ErrorBoundary } from "./error"; | ||||
|  | ||||
| import { getISOLang, getLang } from "../locales"; | ||||
| @@ -27,7 +27,7 @@ import { SideBar } from "./sidebar"; | ||||
| import { useAppConfig } from "../store/config"; | ||||
| import { AuthPage } from "./auth"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import { ClientApi } from "../client/api"; | ||||
| import { type ClientApi, getClientApi } from "../client/api"; | ||||
| import { useAccessStore } from "../store"; | ||||
|  | ||||
| export function Loading(props: { noLogo?: boolean }) { | ||||
| @@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| const Settings = dynamic(async () => (await import("./settings")).Settings, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
| @@ -55,6 +59,10 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| const Sd = dynamic(async () => (await import("./sd")).Sd, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| export function useSwitchTheme() { | ||||
|   const config = useAppConfig(); | ||||
|  | ||||
| @@ -122,11 +130,23 @@ const loadAsyncGoogleFont = () => { | ||||
|   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() { | ||||
|   const config = useAppConfig(); | ||||
|   const location = useLocation(); | ||||
|   const isArtifact = location.pathname.includes(Path.Artifacts); | ||||
|   const isHome = location.pathname === Path.Home; | ||||
|   const isAuth = location.pathname === Path.Auth; | ||||
|   const isSd = location.pathname === Path.Sd; | ||||
|   const isSdNew = location.pathname === Path.SdNew; | ||||
|  | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const shouldTightBorder = | ||||
|     getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen); | ||||
| @@ -135,34 +155,40 @@ function Screen() { | ||||
|     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={isHome ? styles["sidebar-show"] : ""} /> | ||||
|         <WindowContent> | ||||
|           <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> | ||||
|         </WindowContent> | ||||
|       </> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className={ | ||||
|         styles.container + | ||||
|         ` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${ | ||||
|           getLang() === "ar" ? styles["rtl-screen"] : "" | ||||
|         }` | ||||
|       } | ||||
|       className={`${styles.container} ${ | ||||
|         shouldTightBorder ? styles["tight-container"] : styles.container | ||||
|       } ${getLang() === "ar" ? styles["rtl-screen"] : ""}`} | ||||
|     > | ||||
|       {isAuth ? ( | ||||
|         <> | ||||
|           <AuthPage /> | ||||
|         </> | ||||
|       ) : ( | ||||
|         <> | ||||
|           <SideBar className={isHome ? styles["sidebar-show"] : ""} /> | ||||
|  | ||||
|           <div className={styles["window-content"]} id={SlotID.AppBody}> | ||||
|             <Routes> | ||||
|               <Route path={Path.Home} element={<Chat />} /> | ||||
|               <Route path={Path.NewChat} element={<NewChat />} /> | ||||
|               <Route path={Path.Masks} element={<MaskPage />} /> | ||||
|               <Route path={Path.Chat} element={<Chat />} /> | ||||
|               <Route path={Path.Settings} element={<Settings />} /> | ||||
|             </Routes> | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
|       {renderContent()} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @@ -170,12 +196,8 @@ function Screen() { | ||||
| export function useLoadData() { | ||||
|   const config = useAppConfig(); | ||||
|  | ||||
|   var api: ClientApi; | ||||
|   if (config.modelConfig.model === "gemini-pro") { | ||||
|     api = new ClientApi(ModelProvider.GeminiPro); | ||||
|   } else { | ||||
|     api = new ClientApi(ModelProvider.GPT); | ||||
|   } | ||||
|   const api: ClientApi = getClientApi(config.modelConfig.providerName); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       const models = await api.llm.models(); | ||||
|   | ||||
| @@ -9,6 +9,7 @@ interface InputRangeProps { | ||||
|   min: string; | ||||
|   max: string; | ||||
|   step: string; | ||||
|   aria: string; | ||||
| } | ||||
|  | ||||
| export function InputRange({ | ||||
| @@ -19,11 +20,13 @@ export function InputRange({ | ||||
|   min, | ||||
|   max, | ||||
|   step, | ||||
|   aria, | ||||
| }: InputRangeProps) { | ||||
|   return ( | ||||
|     <div className={styles["input-range"] + ` ${className ?? ""}`}> | ||||
|       {title || value} | ||||
|       <input | ||||
|         aria-label={aria} | ||||
|         type="range" | ||||
|         title={title} | ||||
|         value={value} | ||||
|   | ||||
| @@ -6,14 +6,16 @@ import RehypeKatex from "rehype-katex"; | ||||
| import RemarkGfm from "remark-gfm"; | ||||
| import RehypeHighlight from "rehype-highlight"; | ||||
| import { useRef, useState, RefObject, useEffect, useMemo } from "react"; | ||||
| import { copyToClipboard } from "../utils"; | ||||
| import { copyToClipboard, useWindowSize } from "../utils"; | ||||
| import mermaid from "mermaid"; | ||||
|  | ||||
| import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import React from "react"; | ||||
| import { useDebouncedCallback } from "use-debounce"; | ||||
| import { showImageModal } from "./ui-lib"; | ||||
|  | ||||
| import { showImageModal, FullScreen } from "./ui-lib"; | ||||
| import { ArtifactsShareButton, HTMLPreview } from "./artifacts"; | ||||
| import { Plugin } from "../constant"; | ||||
| import { useChatStore } from "../store"; | ||||
| export function Mermaid(props: { code: string }) { | ||||
|   const ref = useRef<HTMLDivElement>(null); | ||||
|   const [hasError, setHasError] = useState(false); | ||||
| @@ -64,25 +66,64 @@ export function PreCode(props: { children: any }) { | ||||
|   const ref = useRef<HTMLPreElement>(null); | ||||
|   const refText = ref.current?.innerText; | ||||
|   const [mermaidCode, setMermaidCode] = useState(""); | ||||
|   const [htmlCode, setHtmlCode] = useState(""); | ||||
|   const { height } = useWindowSize(); | ||||
|   const chatStore = useChatStore(); | ||||
|   const session = chatStore.currentSession(); | ||||
|   const plugins = session.mask?.plugin; | ||||
|  | ||||
|   const renderMermaid = useDebouncedCallback(() => { | ||||
|   const renderArtifacts = useDebouncedCallback(() => { | ||||
|     if (!ref.current) return; | ||||
|     const mermaidDom = ref.current.querySelector("code.language-mermaid"); | ||||
|     if (mermaidDom) { | ||||
|       setMermaidCode((mermaidDom as HTMLElement).innerText); | ||||
|     } | ||||
|     const htmlDom = ref.current.querySelector("code.language-html"); | ||||
|     if (htmlDom) { | ||||
|       setHtmlCode((htmlDom as HTMLElement).innerText); | ||||
|     } else if (refText?.startsWith("<!DOCTYPE")) { | ||||
|       setHtmlCode(refText); | ||||
|     } | ||||
|   }, 600); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setTimeout(renderMermaid, 1); | ||||
|     setTimeout(renderArtifacts, 1); | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
|   }, [refText]); | ||||
|  | ||||
|   const enableArtifacts = useMemo( | ||||
|     () => plugins?.includes(Plugin.Artifacts), | ||||
|     [plugins], | ||||
|   ); | ||||
|  | ||||
|   //Wrap the paragraph for plain-text | ||||
|   useEffect(() => { | ||||
|     if (ref.current) { | ||||
|       const codeElements = ref.current.querySelectorAll( | ||||
|         "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"; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {mermaidCode.length > 0 && ( | ||||
|         <Mermaid code={mermaidCode} key={mermaidCode} /> | ||||
|       )} | ||||
|       <pre ref={ref}> | ||||
|         <span | ||||
|           className="copy-code-button" | ||||
| @@ -95,6 +136,22 @@ export function PreCode(props: { children: any }) { | ||||
|         ></span> | ||||
|         {props.children} | ||||
|       </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} | ||||
|           /> | ||||
|           <HTMLPreview | ||||
|             code={htmlCode} | ||||
|             autoHeight={!document.fullscreenElement} | ||||
|             height={!document.fullscreenElement ? 600 : height} | ||||
|           /> | ||||
|         </FullScreen> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -116,11 +173,28 @@ function escapeDollarNumber(text: string) { | ||||
|   return escapedText; | ||||
| } | ||||
|  | ||||
| function _MarkDownContent(props: { content: string }) { | ||||
|   const escapedContent = useMemo( | ||||
|     () => escapeDollarNumber(props.content), | ||||
|     [props.content], | ||||
| function escapeBrackets(text: string) { | ||||
|   const pattern = | ||||
|     /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g; | ||||
|   return text.replace( | ||||
|     pattern, | ||||
|     (match, codeBlock, squareBracket, roundBracket) => { | ||||
|       if (codeBlock) { | ||||
|         return codeBlock; | ||||
|       } else if (squareBracket) { | ||||
|         return `$$${squareBracket}$$`; | ||||
|       } else if (roundBracket) { | ||||
|         return `$${roundBracket}$`; | ||||
|       } | ||||
|       return match; | ||||
|     }, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function _MarkDownContent(props: { content: string }) { | ||||
|   const escapedContent = useMemo(() => { | ||||
|     return escapeBrackets(escapeDollarNumber(props.content)); | ||||
|   }, [props.content]); | ||||
|  | ||||
|   return ( | ||||
|     <ReactMarkdown | ||||
| @@ -158,6 +232,7 @@ export function Markdown( | ||||
|     content: string; | ||||
|     loading?: boolean; | ||||
|     fontSize?: number; | ||||
|     fontFamily?: string; | ||||
|     parentRef?: RefObject<HTMLDivElement>; | ||||
|     defaultShow?: boolean; | ||||
|   } & React.DOMAttributes<HTMLDivElement>, | ||||
| @@ -169,6 +244,7 @@ export function Markdown( | ||||
|       className="markdown-body" | ||||
|       style={{ | ||||
|         fontSize: `${props.fontSize ?? 14}px`, | ||||
|         fontFamily: props.fontFamily || "inherit", | ||||
|       }} | ||||
|       ref={mdRef} | ||||
|       onContextMenu={props.onContextMenu} | ||||
|   | ||||
| @@ -22,7 +22,7 @@ import { | ||||
|   useAppConfig, | ||||
|   useChatStore, | ||||
| } from "../store"; | ||||
| import { ROLES } from "../client/api"; | ||||
| import { MultimodalContent, ROLES } from "../client/api"; | ||||
| import { | ||||
|   Input, | ||||
|   List, | ||||
| @@ -38,7 +38,12 @@ import { useNavigate } from "react-router-dom"; | ||||
|  | ||||
| import chatStyle from "./chat.module.scss"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { copyToClipboard, downloadAs, readFromFile } from "../utils"; | ||||
| import { | ||||
|   copyToClipboard, | ||||
|   downloadAs, | ||||
|   getMessageImages, | ||||
|   readFromFile, | ||||
| } from "../utils"; | ||||
| import { Updater } from "../typing"; | ||||
| import { ModelConfigList } from "./model-config"; | ||||
| import { FileName, Path } from "../constant"; | ||||
| @@ -50,6 +55,7 @@ import { | ||||
|   Draggable, | ||||
|   OnDragEndResponder, | ||||
| } from "@hello-pangea/dnd"; | ||||
| import { getMessageTextContent } from "../utils"; | ||||
|  | ||||
| // drag and drop helper function | ||||
| function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] { | ||||
| @@ -121,6 +127,8 @@ export function MaskConfig(props: { | ||||
|             onClose={() => setShowPicker(false)} | ||||
|           > | ||||
|             <div | ||||
|               tabIndex={0} | ||||
|               aria-label={Locale.Mask.Config.Avatar} | ||||
|               onClick={() => setShowPicker(true)} | ||||
|               style={{ cursor: "pointer" }} | ||||
|             > | ||||
| @@ -133,6 +141,7 @@ export function MaskConfig(props: { | ||||
|         </ListItem> | ||||
|         <ListItem title={Locale.Mask.Config.Name}> | ||||
|           <input | ||||
|             aria-label={Locale.Mask.Config.Name} | ||||
|             type="text" | ||||
|             value={props.mask.name} | ||||
|             onInput={(e) => | ||||
| @@ -147,6 +156,7 @@ export function MaskConfig(props: { | ||||
|           subTitle={Locale.Mask.Config.HideContext.SubTitle} | ||||
|         > | ||||
|           <input | ||||
|             aria-label={Locale.Mask.Config.HideContext.Title} | ||||
|             type="checkbox" | ||||
|             checked={props.mask.hideContext} | ||||
|             onChange={(e) => { | ||||
| @@ -163,6 +173,7 @@ export function MaskConfig(props: { | ||||
|             subTitle={Locale.Mask.Config.Share.SubTitle} | ||||
|           > | ||||
|             <IconButton | ||||
|               aria={Locale.Mask.Config.Share.Title} | ||||
|               icon={<CopyIcon />} | ||||
|               text={Locale.Mask.Config.Share.Action} | ||||
|               onClick={copyMaskLink} | ||||
| @@ -176,6 +187,7 @@ export function MaskConfig(props: { | ||||
|             subTitle={Locale.Mask.Config.Sync.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               aria-label={Locale.Mask.Config.Sync.Title} | ||||
|               type="checkbox" | ||||
|               checked={props.mask.syncGlobalConfig} | ||||
|               onChange={async (e) => { | ||||
| @@ -244,7 +256,7 @@ function ContextPromptItem(props: { | ||||
|         </> | ||||
|       )} | ||||
|       <Input | ||||
|         value={props.prompt.content} | ||||
|         value={getMessageTextContent(props.prompt)} | ||||
|         type="text" | ||||
|         className={chatStyle["context-content"]} | ||||
|         rows={focusingInput ? 5 : 1} | ||||
| @@ -289,7 +301,18 @@ export function ContextPrompts(props: { | ||||
|   }; | ||||
|  | ||||
|   const updateContextPrompt = (i: number, prompt: ChatMessage) => { | ||||
|     props.updateContext((context) => (context[i] = prompt)); | ||||
|     props.updateContext((context) => { | ||||
|       const images = getMessageImages(context[i]); | ||||
|       context[i] = prompt; | ||||
|       if (images.length > 0) { | ||||
|         const text = getMessageTextContent(context[i]); | ||||
|         const newContext: MultimodalContent[] = [{ type: "text", text }]; | ||||
|         for (const img of images) { | ||||
|           newContext.push({ type: "image_url", image_url: { url: img } }); | ||||
|         } | ||||
|         context[i].content = newContext; | ||||
|       } | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const onDragEnd: OnDragEndResponder = (result) => { | ||||
| @@ -387,7 +410,16 @@ export function MaskPage() { | ||||
|   const maskStore = useMaskStore(); | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   const [filterLang, setFilterLang] = useState<Lang>(); | ||||
|   const [filterLang, setFilterLang] = useState<Lang | undefined>( | ||||
|     () => localStorage.getItem("Mask-language") as Lang | undefined, | ||||
|   ); | ||||
|   useEffect(() => { | ||||
|     if (filterLang) { | ||||
|       localStorage.setItem("Mask-language", filterLang); | ||||
|     } else { | ||||
|       localStorage.removeItem("Mask-language"); | ||||
|     } | ||||
|   }, [filterLang]); | ||||
|  | ||||
|   const allMasks = maskStore | ||||
|     .getAll() | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { MaskAvatar } from "./mask"; | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| import styles from "./message-selector.module.scss"; | ||||
| import { getMessageTextContent } from "../utils"; | ||||
|  | ||||
| function useShiftRange() { | ||||
|   const [startIndex, setStartIndex] = useState<number>(); | ||||
| @@ -103,7 +104,9 @@ export function MessageSelector(props: { | ||||
|     const searchResults = new Set<string>(); | ||||
|     if (text.length > 0) { | ||||
|       messages.forEach((m) => | ||||
|         m.content.includes(text) ? searchResults.add(m.id!) : null, | ||||
|         getMessageTextContent(m).includes(text) | ||||
|           ? searchResults.add(m.id!) | ||||
|           : null, | ||||
|       ); | ||||
|     } | ||||
|     setSearchIds(searchResults); | ||||
| @@ -219,12 +222,12 @@ export function MessageSelector(props: { | ||||
|                   {new Date(m.date).toLocaleString()} | ||||
|                 </div> | ||||
|                 <div className={`${styles["content"]} one-line`}> | ||||
|                   {m.content} | ||||
|                   {getMessageTextContent(m)} | ||||
|                 </div> | ||||
|               </div> | ||||
|  | ||||
|               <div className={styles["checkbox"]}> | ||||
|                 <input type="checkbox" checked={isSelected}></input> | ||||
|                 <input type="checkbox" checked={isSelected} readOnly></input> | ||||
|               </div> | ||||
|             </div> | ||||
|           ); | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { ServiceProvider } from "@/app/constant"; | ||||
| import { ModalConfigValidator, ModelConfig } from "../store"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
| @@ -10,25 +11,26 @@ export function ModelConfigList(props: { | ||||
|   updateConfig: (updater: (config: ModelConfig) => void) => void; | ||||
| }) { | ||||
|   const allModels = useAllModels(); | ||||
|   const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ListItem title={Locale.Settings.Model}> | ||||
|         <Select | ||||
|           value={props.modelConfig.model} | ||||
|           aria-label={Locale.Settings.Model} | ||||
|           value={value} | ||||
|           onChange={(e) => { | ||||
|             props.updateConfig( | ||||
|               (config) => | ||||
|                 (config.model = ModalConfigValidator.model( | ||||
|                   e.currentTarget.value, | ||||
|                 )), | ||||
|             ); | ||||
|             const [model, providerName] = e.currentTarget.value.split("@"); | ||||
|             props.updateConfig((config) => { | ||||
|               config.model = ModalConfigValidator.model(model); | ||||
|               config.providerName = providerName as ServiceProvider; | ||||
|             }); | ||||
|           }} | ||||
|         > | ||||
|           {allModels | ||||
|             .filter((v) => v.available) | ||||
|             .map((v, i) => ( | ||||
|               <option value={v.name} key={i}> | ||||
|               <option value={`${v.name}@${v.provider?.providerName}`} key={i}> | ||||
|                 {v.displayName}({v.provider?.providerName}) | ||||
|               </option> | ||||
|             ))} | ||||
| @@ -39,6 +41,7 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.Temperature.SubTitle} | ||||
|       > | ||||
|         <InputRange | ||||
|           aria={Locale.Settings.Temperature.Title} | ||||
|           value={props.modelConfig.temperature?.toFixed(1)} | ||||
|           min="0" | ||||
|           max="1" // lets limit it to 0-1 | ||||
| @@ -58,6 +61,7 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.TopP.SubTitle} | ||||
|       > | ||||
|         <InputRange | ||||
|           aria={Locale.Settings.TopP.Title} | ||||
|           value={(props.modelConfig.top_p ?? 1).toFixed(1)} | ||||
|           min="0" | ||||
|           max="1" | ||||
| @@ -77,6 +81,7 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.MaxTokens.SubTitle} | ||||
|       > | ||||
|         <input | ||||
|           aria-label={Locale.Settings.MaxTokens.Title} | ||||
|           type="number" | ||||
|           min={1024} | ||||
|           max={512000} | ||||
| @@ -92,13 +97,14 @@ export function ModelConfigList(props: { | ||||
|         ></input> | ||||
|       </ListItem> | ||||
|  | ||||
|       {props.modelConfig.model === "gemini-pro" ? null : ( | ||||
|       {props.modelConfig?.providerName == ServiceProvider.Google ? null : ( | ||||
|         <> | ||||
|           <ListItem | ||||
|             title={Locale.Settings.PresencePenalty.Title} | ||||
|             subTitle={Locale.Settings.PresencePenalty.SubTitle} | ||||
|           > | ||||
|             <InputRange | ||||
|               aria={Locale.Settings.PresencePenalty.Title} | ||||
|               value={props.modelConfig.presence_penalty?.toFixed(1)} | ||||
|               min="-2" | ||||
|               max="2" | ||||
| @@ -120,6 +126,7 @@ export function ModelConfigList(props: { | ||||
|             subTitle={Locale.Settings.FrequencyPenalty.SubTitle} | ||||
|           > | ||||
|             <InputRange | ||||
|               aria={Locale.Settings.FrequencyPenalty.Title} | ||||
|               value={props.modelConfig.frequency_penalty?.toFixed(1)} | ||||
|               min="-2" | ||||
|               max="2" | ||||
| @@ -141,6 +148,7 @@ export function ModelConfigList(props: { | ||||
|             subTitle={Locale.Settings.InjectSystemPrompts.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               aria-label={Locale.Settings.InjectSystemPrompts.Title} | ||||
|               type="checkbox" | ||||
|               checked={props.modelConfig.enableInjectSystemPrompts} | ||||
|               onChange={(e) => | ||||
| @@ -158,6 +166,7 @@ export function ModelConfigList(props: { | ||||
|             subTitle={Locale.Settings.InputTemplate.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               aria-label={Locale.Settings.InputTemplate.Title} | ||||
|               type="text" | ||||
|               value={props.modelConfig.template} | ||||
|               onChange={(e) => | ||||
| @@ -174,6 +183,7 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.HistoryCount.SubTitle} | ||||
|       > | ||||
|         <InputRange | ||||
|           aria={Locale.Settings.HistoryCount.Title} | ||||
|           title={props.modelConfig.historyMessageCount.toString()} | ||||
|           value={props.modelConfig.historyMessageCount} | ||||
|           min="0" | ||||
| @@ -192,6 +202,7 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.CompressThreshold.SubTitle} | ||||
|       > | ||||
|         <input | ||||
|           aria-label={Locale.Settings.CompressThreshold.Title} | ||||
|           type="number" | ||||
|           min={500} | ||||
|           max={4000} | ||||
| @@ -207,6 +218,7 @@ export function ModelConfigList(props: { | ||||
|       </ListItem> | ||||
|       <ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}> | ||||
|         <input | ||||
|           aria-label={Locale.Memory.Title} | ||||
|           type="checkbox" | ||||
|           checked={props.modelConfig.sendMemory} | ||||
|           onChange={(e) => | ||||
|   | ||||
							
								
								
									
										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
									
								
							
							
						
						| @@ -0,0 +1,45 @@ | ||||
| .ctrl-param-item { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   min-height: 40px; | ||||
|   padding: 10px 0; | ||||
|   animation: slide-in ease 0.6s; | ||||
|   flex-direction: column; | ||||
|  | ||||
|   .ctrl-param-item-header { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|  | ||||
|     .ctrl-param-item-title { | ||||
|       font-size: 14px; | ||||
|       font-weight: bolder; | ||||
|       margin-bottom: 5px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .ctrl-param-item-sub-title { | ||||
|     font-size: 12px; | ||||
|     font-weight: normal; | ||||
|     margin-top: 3px; | ||||
|   } | ||||
|   textarea { | ||||
|     appearance: none; | ||||
|     border-radius: 10px; | ||||
|     border: var(--border-in-light); | ||||
|     min-height: 36px; | ||||
|     box-sizing: border-box; | ||||
|     background: var(--white); | ||||
|     color: var(--black); | ||||
|     padding: 0 10px; | ||||
|     max-width: 50%; | ||||
|     font-family: inherit; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .ai-models { | ||||
|   button { | ||||
|     margin-bottom: 10px; | ||||
|     padding: 10px; | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										320
									
								
								app/components/sd/sd-panel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,320 @@ | ||||
| import styles from "./sd-panel.module.scss"; | ||||
| import React from "react"; | ||||
| import { Select } from "@/app/components/ui-lib"; | ||||
| import { IconButton } from "@/app/components/button"; | ||||
| import Locale from "@/app/locales"; | ||||
| import { useSdStore } from "@/app/store/sd"; | ||||
|  | ||||
| export const params = [ | ||||
|   { | ||||
|     name: Locale.SdPanel.Prompt, | ||||
|     value: "prompt", | ||||
|     type: "textarea", | ||||
|     placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.Prompt), | ||||
|     required: true, | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.ModelVersion, | ||||
|     value: "model", | ||||
|     type: "select", | ||||
|     default: "sd3-medium", | ||||
|     support: ["sd3"], | ||||
|     options: [ | ||||
|       { name: "SD3 Medium", value: "sd3-medium" }, | ||||
|       { name: "SD3 Large", value: "sd3-large" }, | ||||
|       { name: "SD3 Large Turbo", value: "sd3-large-turbo" }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.NegativePrompt, | ||||
|     value: "negative_prompt", | ||||
|     type: "textarea", | ||||
|     placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.NegativePrompt), | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.AspectRatio, | ||||
|     value: "aspect_ratio", | ||||
|     type: "select", | ||||
|     default: "1:1", | ||||
|     options: [ | ||||
|       { name: "1:1", value: "1:1" }, | ||||
|       { name: "16:9", value: "16:9" }, | ||||
|       { name: "21:9", value: "21:9" }, | ||||
|       { name: "2:3", value: "2:3" }, | ||||
|       { name: "3:2", value: "3:2" }, | ||||
|       { name: "4:5", value: "4:5" }, | ||||
|       { name: "5:4", value: "5:4" }, | ||||
|       { name: "9:16", value: "9:16" }, | ||||
|       { name: "9:21", value: "9:21" }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.ImageStyle, | ||||
|     value: "style", | ||||
|     type: "select", | ||||
|     default: "3d-model", | ||||
|     support: ["core"], | ||||
|     options: [ | ||||
|       { name: Locale.SdPanel.Styles.D3Model, value: "3d-model" }, | ||||
|       { name: Locale.SdPanel.Styles.AnalogFilm, value: "analog-film" }, | ||||
|       { name: Locale.SdPanel.Styles.Anime, value: "anime" }, | ||||
|       { name: Locale.SdPanel.Styles.Cinematic, value: "cinematic" }, | ||||
|       { name: Locale.SdPanel.Styles.ComicBook, value: "comic-book" }, | ||||
|       { name: Locale.SdPanel.Styles.DigitalArt, value: "digital-art" }, | ||||
|       { name: Locale.SdPanel.Styles.Enhance, value: "enhance" }, | ||||
|       { name: Locale.SdPanel.Styles.FantasyArt, value: "fantasy-art" }, | ||||
|       { name: Locale.SdPanel.Styles.Isometric, value: "isometric" }, | ||||
|       { name: Locale.SdPanel.Styles.LineArt, value: "line-art" }, | ||||
|       { name: Locale.SdPanel.Styles.LowPoly, value: "low-poly" }, | ||||
|       { | ||||
|         name: Locale.SdPanel.Styles.ModelingCompound, | ||||
|         value: "modeling-compound", | ||||
|       }, | ||||
|       { name: Locale.SdPanel.Styles.NeonPunk, value: "neon-punk" }, | ||||
|       { name: Locale.SdPanel.Styles.Origami, value: "origami" }, | ||||
|       { name: Locale.SdPanel.Styles.Photographic, value: "photographic" }, | ||||
|       { name: Locale.SdPanel.Styles.PixelArt, value: "pixel-art" }, | ||||
|       { name: Locale.SdPanel.Styles.TileTexture, value: "tile-texture" }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     name: "Seed", | ||||
|     value: "seed", | ||||
|     type: "number", | ||||
|     default: 0, | ||||
|     min: 0, | ||||
|     max: 4294967294, | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.OutFormat, | ||||
|     value: "output_format", | ||||
|     type: "select", | ||||
|     default: "png", | ||||
|     options: [ | ||||
|       { name: "PNG", value: "png" }, | ||||
|       { name: "JPEG", value: "jpeg" }, | ||||
|       { name: "WebP", value: "webp" }, | ||||
|     ], | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| const sdCommonParams = (model: string, data: any) => { | ||||
|   return params.filter((item) => { | ||||
|     return !(item.support && !item.support.includes(model)); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const models = [ | ||||
|   { | ||||
|     name: "Stable Image Ultra", | ||||
|     value: "ultra", | ||||
|     params: (data: any) => sdCommonParams("ultra", data), | ||||
|   }, | ||||
|   { | ||||
|     name: "Stable Image Core", | ||||
|     value: "core", | ||||
|     params: (data: any) => sdCommonParams("core", data), | ||||
|   }, | ||||
|   { | ||||
|     name: "Stable Diffusion 3", | ||||
|     value: "sd3", | ||||
|     params: (data: any) => { | ||||
|       return sdCommonParams("sd3", data).filter((item) => { | ||||
|         return !( | ||||
|           data.model === "sd3-large-turbo" && item.value == "negative_prompt" | ||||
|         ); | ||||
|       }); | ||||
|     }, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| export function ControlParamItem(props: { | ||||
|   title: string; | ||||
|   subTitle?: string; | ||||
|   required?: boolean; | ||||
|   children?: JSX.Element | JSX.Element[]; | ||||
|   className?: string; | ||||
| }) { | ||||
|   return ( | ||||
|     <div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}> | ||||
|       <div className={styles["ctrl-param-item-header"]}> | ||||
|         <div className={styles["ctrl-param-item-title"]}> | ||||
|           <div> | ||||
|             {props.title} | ||||
|             {props.required && <span style={{ color: "red" }}>*</span>} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       {props.children} | ||||
|       {props.subTitle && ( | ||||
|         <div className={styles["ctrl-param-item-sub-title"]}> | ||||
|           {props.subTitle} | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function ControlParam(props: { | ||||
|   columns: any[]; | ||||
|   data: any; | ||||
|   onChange: (field: string, val: any) => void; | ||||
| }) { | ||||
|   return ( | ||||
|     <> | ||||
|       {props.columns?.map((item) => { | ||||
|         let element: null | JSX.Element; | ||||
|         switch (item.type) { | ||||
|           case "textarea": | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <textarea | ||||
|                   rows={item.rows || 3} | ||||
|                   style={{ maxWidth: "100%", width: "100%", padding: "10px" }} | ||||
|                   placeholder={item.placeholder} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, e.currentTarget.value); | ||||
|                   }} | ||||
|                   value={props.data[item.value]} | ||||
|                 ></textarea> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|             break; | ||||
|           case "select": | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <Select | ||||
|                   aria-label={item.name} | ||||
|                   value={props.data[item.value]} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, e.currentTarget.value); | ||||
|                   }} | ||||
|                 > | ||||
|                   {item.options.map((opt: any) => { | ||||
|                     return ( | ||||
|                       <option value={opt.value} key={opt.value}> | ||||
|                         {opt.name} | ||||
|                       </option> | ||||
|                     ); | ||||
|                   })} | ||||
|                 </Select> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|             break; | ||||
|           case "number": | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <input | ||||
|                   aria-label={item.name} | ||||
|                   type="number" | ||||
|                   min={item.min} | ||||
|                   max={item.max} | ||||
|                   value={props.data[item.value] || 0} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, parseInt(e.currentTarget.value)); | ||||
|                   }} | ||||
|                 /> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|             break; | ||||
|           default: | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <input | ||||
|                   aria-label={item.name} | ||||
|                   type="text" | ||||
|                   value={props.data[item.value]} | ||||
|                   style={{ maxWidth: "100%", width: "100%" }} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, e.currentTarget.value); | ||||
|                   }} | ||||
|                 /> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|         } | ||||
|         return <div key={item.value}>{element}</div>; | ||||
|       })} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export const getModelParamBasicData = ( | ||||
|   columns: any[], | ||||
|   data: any, | ||||
|   clearText?: boolean, | ||||
| ) => { | ||||
|   const newParams: any = {}; | ||||
|   columns.forEach((item: any) => { | ||||
|     if (clearText && ["text", "textarea", "number"].includes(item.type)) { | ||||
|       newParams[item.value] = item.default || ""; | ||||
|     } else { | ||||
|       // @ts-ignore | ||||
|       newParams[item.value] = data[item.value] || item.default || ""; | ||||
|     } | ||||
|   }); | ||||
|   return newParams; | ||||
| }; | ||||
|  | ||||
| export const getParams = (model: any, params: any) => { | ||||
|   return models.find((m) => m.value === model.value)?.params(params) || []; | ||||
| }; | ||||
|  | ||||
| export function SdPanel() { | ||||
|   const sdStore = useSdStore(); | ||||
|   const currentModel = sdStore.currentModel; | ||||
|   const setCurrentModel = sdStore.setCurrentModel; | ||||
|   const params = sdStore.currentParams; | ||||
|   const setParams = sdStore.setCurrentParams; | ||||
|  | ||||
|   const handleValueChange = (field: string, val: any) => { | ||||
|     setParams({ | ||||
|       ...params, | ||||
|       [field]: val, | ||||
|     }); | ||||
|   }; | ||||
|   const handleModelChange = (model: any) => { | ||||
|     setCurrentModel(model); | ||||
|     setParams(getModelParamBasicData(model.params({}), params)); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ControlParamItem title={Locale.SdPanel.AIModel}> | ||||
|         <div className={styles["ai-models"]}> | ||||
|           {models.map((item) => { | ||||
|             return ( | ||||
|               <IconButton | ||||
|                 text={item.name} | ||||
|                 key={item.value} | ||||
|                 type={currentModel.value == item.value ? "primary" : null} | ||||
|                 shadow | ||||
|                 onClick={() => handleModelChange(item)} | ||||
|               /> | ||||
|             ); | ||||
|           })} | ||||
|         </div> | ||||
|       </ControlParamItem> | ||||
|       <ControlParam | ||||
|         columns={getParams?.(currentModel, params) as any[]} | ||||
|         data={params} | ||||
|         onChange={handleValueChange} | ||||
|       ></ControlParam> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										140
									
								
								app/components/sd/sd-sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -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
									
								
							
							
						
						| @@ -0,0 +1,53 @@ | ||||
| .sd-img-list{ | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   justify-content: space-between; | ||||
|   .sd-img-item{ | ||||
|     width: 48%; | ||||
|     .sd-img-item-info{ | ||||
|       flex:1; | ||||
|       width: 100%; | ||||
|       overflow: hidden; | ||||
|       user-select: text; | ||||
|       p{ | ||||
|         margin: 6px; | ||||
|         font-size: 12px; | ||||
|       } | ||||
|       .line-1{ | ||||
|         overflow: hidden; | ||||
|         white-space: nowrap; | ||||
|         text-overflow: ellipsis; | ||||
|       } | ||||
|     } | ||||
|     .pre-img{ | ||||
|       display: flex; | ||||
|       width: 130px; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       background-color: var(--second); | ||||
|       border-radius: 10px; | ||||
|     } | ||||
|     .img{ | ||||
|       width: 130px; | ||||
|       height: 130px; | ||||
|       border-radius: 10px; | ||||
|       overflow: hidden; | ||||
|       cursor: pointer; | ||||
|       transition: all .3s; | ||||
|       &:hover{ | ||||
|         opacity: .7; | ||||
|       } | ||||
|     } | ||||
|     &:not(:last-child){ | ||||
|       margin-bottom: 20px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 600px) { | ||||
|   .sd-img-list{ | ||||
|     .sd-img-item{ | ||||
|       width: 100%; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										336
									
								
								app/components/sd/sd.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,336 @@ | ||||
| import chatStyles from "@/app/components/chat.module.scss"; | ||||
| import styles from "@/app/components/sd/sd.module.scss"; | ||||
| import homeStyles from "@/app/components/home.module.scss"; | ||||
|  | ||||
| import { IconButton } from "@/app/components/button"; | ||||
| import ReturnIcon from "@/app/icons/return.svg"; | ||||
| import Locale from "@/app/locales"; | ||||
| import { Path } from "@/app/constant"; | ||||
| import React, { useEffect, useMemo, useRef, useState } from "react"; | ||||
| import { | ||||
|   copyToClipboard, | ||||
|   getMessageTextContent, | ||||
|   useMobileScreen, | ||||
| } from "@/app/utils"; | ||||
| import { useNavigate, useLocation } from "react-router-dom"; | ||||
| import { useAppConfig } from "@/app/store"; | ||||
| import MinIcon from "@/app/icons/min.svg"; | ||||
| import MaxIcon from "@/app/icons/max.svg"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { ChatAction } from "@/app/components/chat"; | ||||
| import DeleteIcon from "@/app/icons/clear.svg"; | ||||
| import CopyIcon from "@/app/icons/copy.svg"; | ||||
| import PromptIcon from "@/app/icons/prompt.svg"; | ||||
| import ResetIcon from "@/app/icons/reload.svg"; | ||||
| import { useSdStore } from "@/app/store/sd"; | ||||
| import LoadingIcon from "@/app/icons/three-dots.svg"; | ||||
| import ErrorIcon from "@/app/icons/delete.svg"; | ||||
| import SDIcon from "@/app/icons/sd.svg"; | ||||
| import { Property } from "csstype"; | ||||
| import { | ||||
|   showConfirm, | ||||
|   showImageModal, | ||||
|   showModal, | ||||
| } from "@/app/components/ui-lib"; | ||||
| import { removeImage } from "@/app/utils/chat"; | ||||
| import { SideBar } from "./sd-sidebar"; | ||||
| import { WindowContent } from "@/app/components/home"; | ||||
| import { params } from "./sd-panel"; | ||||
|  | ||||
| function getSdTaskStatus(item: any) { | ||||
|   let s: string; | ||||
|   let color: Property.Color | undefined = undefined; | ||||
|   switch (item.status) { | ||||
|     case "success": | ||||
|       s = Locale.Sd.Status.Success; | ||||
|       color = "green"; | ||||
|       break; | ||||
|     case "error": | ||||
|       s = Locale.Sd.Status.Error; | ||||
|       color = "red"; | ||||
|       break; | ||||
|     case "wait": | ||||
|       s = Locale.Sd.Status.Wait; | ||||
|       color = "yellow"; | ||||
|       break; | ||||
|     case "running": | ||||
|       s = Locale.Sd.Status.Running; | ||||
|       color = "blue"; | ||||
|       break; | ||||
|     default: | ||||
|       s = item.status.toUpperCase(); | ||||
|   } | ||||
|   return ( | ||||
|     <p className={styles["line-1"]} title={item.error} style={{ color: color }}> | ||||
|       <span> | ||||
|         {Locale.Sd.Status.Name}: {s} | ||||
|       </span> | ||||
|       {item.status === "error" && ( | ||||
|         <span | ||||
|           className="clickable" | ||||
|           onClick={() => { | ||||
|             showModal({ | ||||
|               title: Locale.Sd.Detail, | ||||
|               children: ( | ||||
|                 <div style={{ color: color, userSelect: "text" }}> | ||||
|                   {item.error} | ||||
|                 </div> | ||||
|               ), | ||||
|             }); | ||||
|           }} | ||||
|         > | ||||
|           - {item.error} | ||||
|         </span> | ||||
|       )} | ||||
|     </p> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function Sd() { | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|   const clientConfig = useMemo(() => getClientConfig(), []); | ||||
|   const showMaxIcon = !isMobileScreen && !clientConfig?.isApp; | ||||
|   const config = useAppConfig(); | ||||
|   const scrollRef = useRef<HTMLDivElement>(null); | ||||
|   const sdStore = useSdStore(); | ||||
|   const [sdImages, setSdImages] = useState(sdStore.draw); | ||||
|   const isSd = location.pathname === Path.Sd; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setSdImages(sdStore.draw); | ||||
|   }, [sdStore.currentId]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <SideBar className={isSd ? homeStyles["sidebar-show"] : ""} /> | ||||
|       <WindowContent> | ||||
|         <div className={chatStyles.chat} key={"1"}> | ||||
|           <div className="window-header" data-tauri-drag-region> | ||||
|             {isMobileScreen && ( | ||||
|               <div className="window-actions"> | ||||
|                 <div className={"window-action-button"}> | ||||
|                   <IconButton | ||||
|                     icon={<ReturnIcon />} | ||||
|                     bordered | ||||
|                     title={Locale.Chat.Actions.ChatList} | ||||
|                     onClick={() => navigate(Path.Sd)} | ||||
|                   /> | ||||
|                 </div> | ||||
|               </div> | ||||
|             )} | ||||
|             <div | ||||
|               className={`window-header-title ${chatStyles["chat-body-title"]}`} | ||||
|             > | ||||
|               <div className={`window-header-main-title`}>Stability AI</div> | ||||
|               <div className="window-header-sub-title"> | ||||
|                 {Locale.Sd.SubTitle(sdImages.length || 0)} | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div className="window-actions"> | ||||
|               {showMaxIcon && ( | ||||
|                 <div className="window-action-button"> | ||||
|                   <IconButton | ||||
|                     aria={Locale.Chat.Actions.FullScreen} | ||||
|                     icon={config.tightBorder ? <MinIcon /> : <MaxIcon />} | ||||
|                     bordered | ||||
|                     onClick={() => { | ||||
|                       config.update( | ||||
|                         (config) => (config.tightBorder = !config.tightBorder), | ||||
|                       ); | ||||
|                     }} | ||||
|                   /> | ||||
|                 </div> | ||||
|               )} | ||||
|               {isMobileScreen && <SDIcon width={50} height={50} />} | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className={chatStyles["chat-body"]} ref={scrollRef}> | ||||
|             <div className={styles["sd-img-list"]}> | ||||
|               {sdImages.length > 0 ? ( | ||||
|                 sdImages.map((item: any) => { | ||||
|                   return ( | ||||
|                     <div | ||||
|                       key={item.id} | ||||
|                       style={{ display: "flex" }} | ||||
|                       className={styles["sd-img-item"]} | ||||
|                     > | ||||
|                       {item.status === "success" ? ( | ||||
|                         <img | ||||
|                           className={styles["img"]} | ||||
|                           src={item.img_data} | ||||
|                           alt={item.id} | ||||
|                           onClick={(e) => | ||||
|                             showImageModal( | ||||
|                               item.img_data, | ||||
|                               true, | ||||
|                               isMobileScreen | ||||
|                                 ? { width: "100%", height: "fit-content" } | ||||
|                                 : { maxWidth: "100%", maxHeight: "100%" }, | ||||
|                               isMobileScreen | ||||
|                                 ? { width: "100%", height: "fit-content" } | ||||
|                                 : { width: "100%", height: "100%" }, | ||||
|                             ) | ||||
|                           } | ||||
|                         /> | ||||
|                       ) : item.status === "error" ? ( | ||||
|                         <div className={styles["pre-img"]}> | ||||
|                           <ErrorIcon /> | ||||
|                         </div> | ||||
|                       ) : ( | ||||
|                         <div className={styles["pre-img"]}> | ||||
|                           <LoadingIcon /> | ||||
|                         </div> | ||||
|                       )} | ||||
|                       <div | ||||
|                         style={{ marginLeft: "10px" }} | ||||
|                         className={styles["sd-img-item-info"]} | ||||
|                       > | ||||
|                         <p className={styles["line-1"]}> | ||||
|                           {Locale.SdPanel.Prompt}:{" "} | ||||
|                           <span | ||||
|                             className="clickable" | ||||
|                             title={item.params.prompt} | ||||
|                             onClick={() => { | ||||
|                               showModal({ | ||||
|                                 title: Locale.Sd.Detail, | ||||
|                                 children: ( | ||||
|                                   <div style={{ userSelect: "text" }}> | ||||
|                                     {item.params.prompt} | ||||
|                                   </div> | ||||
|                                 ), | ||||
|                               }); | ||||
|                             }} | ||||
|                           > | ||||
|                             {item.params.prompt} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p> | ||||
|                           {Locale.SdPanel.AIModel}: {item.model_name} | ||||
|                         </p> | ||||
|                         {getSdTaskStatus(item)} | ||||
|                         <p>{item.created_at}</p> | ||||
|                         <div className={chatStyles["chat-message-actions"]}> | ||||
|                           <div className={chatStyles["chat-input-actions"]}> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Params} | ||||
|                               icon={<PromptIcon />} | ||||
|                               onClick={() => { | ||||
|                                 showModal({ | ||||
|                                   title: Locale.Sd.GenerateParams, | ||||
|                                   children: ( | ||||
|                                     <div style={{ userSelect: "text" }}> | ||||
|                                       {Object.keys(item.params).map((key) => { | ||||
|                                         let label = key; | ||||
|                                         let value = item.params[key]; | ||||
|                                         switch (label) { | ||||
|                                           case "prompt": | ||||
|                                             label = Locale.SdPanel.Prompt; | ||||
|                                             break; | ||||
|                                           case "negative_prompt": | ||||
|                                             label = | ||||
|                                               Locale.SdPanel.NegativePrompt; | ||||
|                                             break; | ||||
|                                           case "aspect_ratio": | ||||
|                                             label = Locale.SdPanel.AspectRatio; | ||||
|                                             break; | ||||
|                                           case "seed": | ||||
|                                             label = "Seed"; | ||||
|                                             value = value || 0; | ||||
|                                             break; | ||||
|                                           case "output_format": | ||||
|                                             label = Locale.SdPanel.OutFormat; | ||||
|                                             value = value?.toUpperCase(); | ||||
|                                             break; | ||||
|                                           case "style": | ||||
|                                             label = Locale.SdPanel.ImageStyle; | ||||
|                                             value = params | ||||
|                                               .find( | ||||
|                                                 (item) => | ||||
|                                                   item.value === "style", | ||||
|                                               ) | ||||
|                                               ?.options?.find( | ||||
|                                                 (item) => item.value === value, | ||||
|                                               )?.name; | ||||
|                                             break; | ||||
|                                           default: | ||||
|                                             break; | ||||
|                                         } | ||||
|  | ||||
|                                         return ( | ||||
|                                           <div | ||||
|                                             key={key} | ||||
|                                             style={{ margin: "10px" }} | ||||
|                                           > | ||||
|                                             <strong>{label}: </strong> | ||||
|                                             {value} | ||||
|                                           </div> | ||||
|                                         ); | ||||
|                                       })} | ||||
|                                     </div> | ||||
|                                   ), | ||||
|                                 }); | ||||
|                               }} | ||||
|                             /> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Copy} | ||||
|                               icon={<CopyIcon />} | ||||
|                               onClick={() => | ||||
|                                 copyToClipboard( | ||||
|                                   getMessageTextContent({ | ||||
|                                     role: "user", | ||||
|                                     content: item.params.prompt, | ||||
|                                   }), | ||||
|                                 ) | ||||
|                               } | ||||
|                             /> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Retry} | ||||
|                               icon={<ResetIcon />} | ||||
|                               onClick={() => { | ||||
|                                 const reqData = { | ||||
|                                   model: item.model, | ||||
|                                   model_name: item.model_name, | ||||
|                                   status: "wait", | ||||
|                                   params: { ...item.params }, | ||||
|                                   created_at: new Date().toLocaleString(), | ||||
|                                   img_data: "", | ||||
|                                 }; | ||||
|                                 sdStore.sendTask(reqData); | ||||
|                               }} | ||||
|                             /> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Delete} | ||||
|                               icon={<DeleteIcon />} | ||||
|                               onClick={async () => { | ||||
|                                 if ( | ||||
|                                   await showConfirm(Locale.Sd.Danger.Delete) | ||||
|                                 ) { | ||||
|                                   // remove img_data + remove item in list | ||||
|                                   removeImage(item.img_data).finally(() => { | ||||
|                                     sdStore.draw = sdImages.filter( | ||||
|                                       (i: any) => i.id !== item.id, | ||||
|                                     ); | ||||
|                                     sdStore.getNextId(); | ||||
|                                   }); | ||||
|                                 } | ||||
|                               }} | ||||
|                             /> | ||||
|                           </div> | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   ); | ||||
|                 }) | ||||
|               ) : ( | ||||
|                 <div>{Locale.Sd.EmptyRecord}</div> | ||||
|               )} | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </WindowContent> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -5,6 +5,8 @@ | ||||
|  | ||||
| .avatar { | ||||
|   cursor: pointer; | ||||
|   position: relative; | ||||
|   z-index: 1; | ||||
| } | ||||
|  | ||||
| .edit-prompt-modal { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useEffect, useRef, useMemo } from "react"; | ||||
| import React, { useEffect, useRef, useMemo, useState, Fragment } from "react"; | ||||
|  | ||||
| import styles from "./home.module.scss"; | ||||
|  | ||||
| @@ -10,8 +10,8 @@ import AddIcon from "../icons/add.svg"; | ||||
| import CloseIcon from "../icons/close.svg"; | ||||
| import DeleteIcon from "../icons/delete.svg"; | ||||
| import MaskIcon from "../icons/mask.svg"; | ||||
| import PluginIcon from "../icons/plugin.svg"; | ||||
| import DragIcon from "../icons/drag.svg"; | ||||
| import DiscoveryIcon from "../icons/discovery.svg"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| @@ -23,19 +23,20 @@ import { | ||||
|   MIN_SIDEBAR_WIDTH, | ||||
|   NARROW_SIDEBAR_WIDTH, | ||||
|   Path, | ||||
|   PLUGINS, | ||||
|   REPO_URL, | ||||
| } from "../constant"; | ||||
|  | ||||
| import { Link, useNavigate } from "react-router-dom"; | ||||
| import { isIOS, useMobileScreen } from "../utils"; | ||||
| import dynamic from "next/dynamic"; | ||||
| import { showConfirm, showToast } from "./ui-lib"; | ||||
| import { showConfirm, Selector } from "./ui-lib"; | ||||
|  | ||||
| const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { | ||||
|   loading: () => null, | ||||
| }); | ||||
|  | ||||
| function useHotKey() { | ||||
| export function useHotKey() { | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -54,7 +55,7 @@ function useHotKey() { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function useDragSideBar() { | ||||
| export function useDragSideBar() { | ||||
|   const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x); | ||||
|  | ||||
|   const config = useAppConfig(); | ||||
| @@ -127,25 +128,21 @@ function useDragSideBar() { | ||||
|     shouldNarrow, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export function SideBar(props: { className?: string }) { | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   // drag side bar | ||||
|   const { onDragStart, shouldNarrow } = useDragSideBar(); | ||||
|   const navigate = useNavigate(); | ||||
|   const config = useAppConfig(); | ||||
| export function SideBarContainer(props: { | ||||
|   children: React.ReactNode; | ||||
|   onDragStart: (e: MouseEvent) => void; | ||||
|   shouldNarrow: boolean; | ||||
|   className?: string; | ||||
| }) { | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const isIOSMobile = useMemo( | ||||
|     () => isIOS() && isMobileScreen, | ||||
|     [isMobileScreen], | ||||
|   ); | ||||
|  | ||||
|   useHotKey(); | ||||
|  | ||||
|   const { children, className, onDragStart, shouldNarrow } = props; | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles.sidebar} ${props.className} ${ | ||||
|       className={`${styles.sidebar} ${className} ${ | ||||
|         shouldNarrow && styles["narrow-sidebar"] | ||||
|       }`} | ||||
|       style={{ | ||||
| @@ -153,43 +150,130 @@ export function SideBar(props: { className?: string }) { | ||||
|         transition: isMobileScreen && isIOSMobile ? "none" : undefined, | ||||
|       }} | ||||
|     > | ||||
|       <div className={styles["sidebar-header"]} data-tauri-drag-region> | ||||
|         <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> | ||||
|  | ||||
|       {children} | ||||
|       <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; | ||||
| }) { | ||||
|   const { title, subTitle, logo, children } = props; | ||||
|   return ( | ||||
|     <Fragment> | ||||
|       <div className={styles["sidebar-header"]} data-tauri-drag-region> | ||||
|         <div className={styles["sidebar-title-container"]}> | ||||
|           <div className={styles["sidebar-title"]} data-tauri-drag-region> | ||||
|             {title} | ||||
|           </div> | ||||
|           <div className={styles["sidebar-sub-title"]}>{subTitle}</div> | ||||
|         </div> | ||||
|         <div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div> | ||||
|       </div> | ||||
|       {children} | ||||
|     </Fragment> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function SideBarBody(props: { | ||||
|   children: React.ReactNode; | ||||
|   onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; | ||||
| }) { | ||||
|   const { onClick, children } = props; | ||||
|   return ( | ||||
|     <div className={styles["sidebar-body"]} onClick={onClick}> | ||||
|       {children} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function SideBarTail(props: { | ||||
|   primaryAction?: React.ReactNode; | ||||
|   secondaryAction?: React.ReactNode; | ||||
| }) { | ||||
|   const { primaryAction, secondaryAction } = props; | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["sidebar-tail"]}> | ||||
|       <div className={styles["sidebar-actions"]}>{primaryAction}</div> | ||||
|       <div className={styles["sidebar-actions"]}>{secondaryAction}</div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function SideBar(props: { className?: string }) { | ||||
|   useHotKey(); | ||||
|   const { onDragStart, shouldNarrow } = useDragSideBar(); | ||||
|   const [showPluginSelector, setShowPluginSelector] = useState(false); | ||||
|   const navigate = useNavigate(); | ||||
|   const config = useAppConfig(); | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   return ( | ||||
|     <SideBarContainer | ||||
|       onDragStart={onDragStart} | ||||
|       shouldNarrow={shouldNarrow} | ||||
|       {...props} | ||||
|     > | ||||
|       <SideBarHeader | ||||
|         title="NextChat" | ||||
|         subTitle="Build your own AI assistant." | ||||
|         logo={<ChatGptIcon />} | ||||
|       > | ||||
|         <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={<DiscoveryIcon />} | ||||
|             text={shouldNarrow ? undefined : Locale.Discovery.Name} | ||||
|             className={styles["sidebar-bar-button"]} | ||||
|             onClick={() => setShowPluginSelector(true)} | ||||
|             shadow | ||||
|           /> | ||||
|         </div> | ||||
|         {showPluginSelector && ( | ||||
|           <Selector | ||||
|             items={[ | ||||
|               { | ||||
|                 title: "👇 Please select the plugin you need to use", | ||||
|                 value: "-", | ||||
|                 disable: true, | ||||
|               }, | ||||
|               ...PLUGINS.map((item) => { | ||||
|                 return { | ||||
|                   title: item.name, | ||||
|                   value: item.path, | ||||
|                 }; | ||||
|               }), | ||||
|             ]} | ||||
|             onClose={() => setShowPluginSelector(false)} | ||||
|             onSelection={(s) => { | ||||
|               navigate(s[0], { state: { fromHome: true } }); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </SideBarHeader> | ||||
|       <SideBarBody | ||||
|         onClick={(e) => { | ||||
|           if (e.target === e.currentTarget) { | ||||
|             navigate(Path.Home); | ||||
| @@ -197,32 +281,41 @@ export function SideBar(props: { className?: string }) { | ||||
|         }} | ||||
|       > | ||||
|         <ChatList narrow={shouldNarrow} /> | ||||
|       </div> | ||||
|  | ||||
|       <div className={styles["sidebar-tail"]}> | ||||
|         <div className={styles["sidebar-actions"]}> | ||||
|           <div className={styles["sidebar-action"] + " " + styles.mobile}> | ||||
|             <IconButton | ||||
|               icon={<DeleteIcon />} | ||||
|               onClick={async () => { | ||||
|                 if (await showConfirm(Locale.Home.DeleteChat)) { | ||||
|                   chatStore.deleteSession(chatStore.currentSessionIndex); | ||||
|                 } | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div className={styles["sidebar-action"]}> | ||||
|             <Link to={Path.Settings}> | ||||
|               <IconButton icon={<SettingsIcon />} shadow /> | ||||
|             </Link> | ||||
|           </div> | ||||
|           <div className={styles["sidebar-action"]}> | ||||
|             <a href={REPO_URL} target="_blank" rel="noopener noreferrer"> | ||||
|               <IconButton icon={<GithubIcon />} shadow /> | ||||
|             </a> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div> | ||||
|       </SideBarBody> | ||||
|       <SideBarTail | ||||
|         primaryAction={ | ||||
|           <> | ||||
|             <div className={styles["sidebar-action"] + " " + styles.mobile}> | ||||
|               <IconButton | ||||
|                 icon={<DeleteIcon />} | ||||
|                 onClick={async () => { | ||||
|                   if (await showConfirm(Locale.Home.DeleteChat)) { | ||||
|                     chatStore.deleteSession(chatStore.currentSessionIndex); | ||||
|                   } | ||||
|                 }} | ||||
|               /> | ||||
|             </div> | ||||
|             <div className={styles["sidebar-action"]}> | ||||
|               <Link to={Path.Settings}> | ||||
|                 <IconButton | ||||
|                   aria={Locale.Settings.Title} | ||||
|                   icon={<SettingsIcon />} | ||||
|                   shadow | ||||
|                 /> | ||||
|               </Link> | ||||
|             </div> | ||||
|             <div className={styles["sidebar-action"]}> | ||||
|               <a href={REPO_URL} target="_blank" rel="noopener noreferrer"> | ||||
|                 <IconButton | ||||
|                   aria={Locale.Export.MessageFromChatGPT} | ||||
|                   icon={<GithubIcon />} | ||||
|                   shadow | ||||
|                 /> | ||||
|               </a> | ||||
|             </div> | ||||
|           </> | ||||
|         } | ||||
|         secondaryAction={ | ||||
|           <IconButton | ||||
|             icon={<AddIcon />} | ||||
|             text={shouldNarrow ? undefined : Locale.Home.NewChat} | ||||
| @@ -236,15 +329,8 @@ export function SideBar(props: { className?: string }) { | ||||
|             }} | ||||
|             shadow | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div | ||||
|         className={styles["sidebar-drag"]} | ||||
|         onPointerDown={(e) => onDragStart(e as any)} | ||||
|       > | ||||
|         <DragIcon /> | ||||
|       </div> | ||||
|     </div> | ||||
|         } | ||||
|       /> | ||||
|     </SideBarContainer> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -14,17 +14,24 @@ | ||||
|  | ||||
| .popover-content { | ||||
|   position: absolute; | ||||
|   width: 350px; | ||||
|   animation: slide-in 0.3s ease; | ||||
|   right: 0; | ||||
|   top: calc(100% + 10px); | ||||
| } | ||||
|  | ||||
| @media screen and (max-width: 600px) { | ||||
|   .popover-content { | ||||
|     width: auto; | ||||
|   } | ||||
| } | ||||
| .popover-mask { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100vw; | ||||
|   height: 100vh; | ||||
|   background-color: rgba(0, 0, 0, 0.3); | ||||
|   backdrop-filter: blur(5px); | ||||
| } | ||||
|  | ||||
| .list-item { | ||||
| @@ -54,6 +61,19 @@ | ||||
|       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 { | ||||
| @@ -284,7 +304,12 @@ | ||||
|   justify-content: center; | ||||
|   z-index: 999; | ||||
|  | ||||
|   .selector-item-disabled{ | ||||
|     opacity: 0.6; | ||||
|   } | ||||
|  | ||||
|   &-content { | ||||
|     min-width: 300px; | ||||
|     .list { | ||||
|       max-height: 90vh; | ||||
|       overflow-x: hidden; | ||||
|   | ||||
| @@ -13,7 +13,15 @@ import MinIcon from "../icons/min.svg"; | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| 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"; | ||||
|  | ||||
| export function Popover(props: { | ||||
| @@ -26,10 +34,10 @@ export function Popover(props: { | ||||
|     <div className={styles.popover}> | ||||
|       {props.children} | ||||
|       {props.open && ( | ||||
|         <div className={styles["popover-content"]}> | ||||
|           <div className={styles["popover-mask"]} onClick={props.onClose}></div> | ||||
|           {props.content} | ||||
|         </div> | ||||
|         <div className={styles["popover-mask"]} onClick={props.onClose}></div> | ||||
|       )} | ||||
|       {props.open && ( | ||||
|         <div className={styles["popover-content"]}>{props.content}</div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| @@ -47,11 +55,16 @@ export function ListItem(props: { | ||||
|   children?: JSX.Element | JSX.Element[]; | ||||
|   icon?: JSX.Element; | ||||
|   className?: string; | ||||
|   onClick?: () => void; | ||||
|   onClick?: (e: MouseEvent) => void; | ||||
|   vertical?: boolean; | ||||
| }) { | ||||
|   return ( | ||||
|     <div | ||||
|       className={styles["list-item"] + ` ${props.className || ""}`} | ||||
|       className={ | ||||
|         styles["list-item"] + | ||||
|         ` ${props.vertical ? styles["vertical"] : ""} ` + | ||||
|         ` ${props.className || ""}` | ||||
|       } | ||||
|       onClick={props.onClick} | ||||
|     > | ||||
|       <div className={styles["list-header"]}> | ||||
| @@ -252,9 +265,10 @@ export function Input(props: InputProps) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function PasswordInput(props: HTMLProps<HTMLInputElement>) { | ||||
| export function PasswordInput( | ||||
|   props: HTMLProps<HTMLInputElement> & { aria?: string }, | ||||
| ) { | ||||
|   const [visible, setVisible] = useState(false); | ||||
|  | ||||
|   function changeVisibility() { | ||||
|     setVisible(!visible); | ||||
|   } | ||||
| @@ -262,6 +276,7 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) { | ||||
|   return ( | ||||
|     <div className={"password-input-container"}> | ||||
|       <IconButton | ||||
|         aria={props.aria} | ||||
|         icon={visible ? <EyeIcon /> : <EyeOffIcon />} | ||||
|         onClick={changeVisibility} | ||||
|         className={"password-eye"} | ||||
| @@ -420,17 +435,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({ | ||||
|     title: Locale.Export.Image.Modal, | ||||
|     defaultMax: defaultMax, | ||||
|     children: ( | ||||
|       <div> | ||||
|       <div style={{ display: "flex", justifyContent: "center", ...boxStyle }}> | ||||
|         <img | ||||
|           src={img} | ||||
|           alt="preview" | ||||
|           style={{ | ||||
|             maxWidth: "100%", | ||||
|           }} | ||||
|           style={ | ||||
|             style ?? { | ||||
|               maxWidth: "100%", | ||||
|             } | ||||
|           } | ||||
|         ></img> | ||||
|       </div> | ||||
|     ), | ||||
| @@ -442,27 +465,56 @@ export function Selector<T>(props: { | ||||
|     title: string; | ||||
|     subTitle?: string; | ||||
|     value: T; | ||||
|     disable?: boolean; | ||||
|   }>; | ||||
|   defaultSelectedValue?: T; | ||||
|   defaultSelectedValue?: T[] | T; | ||||
|   onSelection?: (selection: T[]) => void; | ||||
|   onClose?: () => void; | ||||
|   multiple?: boolean; | ||||
| }) { | ||||
|   const [selectedValues, setSelectedValues] = useState<T[]>( | ||||
|     Array.isArray(props.defaultSelectedValue) | ||||
|       ? props.defaultSelectedValue | ||||
|       : props.defaultSelectedValue !== undefined | ||||
|       ? [props.defaultSelectedValue] | ||||
|       : [], | ||||
|   ); | ||||
|  | ||||
|   const handleSelection = (e: MouseEvent, value: T) => { | ||||
|     if (props.multiple) { | ||||
|       e.stopPropagation(); | ||||
|       const newSelectedValues = selectedValues.includes(value) | ||||
|         ? selectedValues.filter((v) => v !== value) | ||||
|         : [...selectedValues, value]; | ||||
|       setSelectedValues(newSelectedValues); | ||||
|       props.onSelection?.(newSelectedValues); | ||||
|     } else { | ||||
|       setSelectedValues([value]); | ||||
|       props.onSelection?.([value]); | ||||
|       props.onClose?.(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["selector"]} onClick={() => props.onClose?.()}> | ||||
|       <div className={styles["selector-content"]}> | ||||
|         <List> | ||||
|           {props.items.map((item, i) => { | ||||
|             const selected = props.defaultSelectedValue === item.value; | ||||
|             const selected = selectedValues.includes(item.value); | ||||
|             return ( | ||||
|               <ListItem | ||||
|                 className={styles["selector-item"]} | ||||
|                 className={`${styles["selector-item"]} ${ | ||||
|                   item.disable && styles["selector-item-disabled"] | ||||
|                 }`} | ||||
|                 key={i} | ||||
|                 title={item.title} | ||||
|                 subTitle={item.subTitle} | ||||
|                 onClick={() => { | ||||
|                   props.onSelection?.([item.value]); | ||||
|                   props.onClose?.(); | ||||
|                 onClick={(e) => { | ||||
|                   if (item.disable) { | ||||
|                     e.stopPropagation(); | ||||
|                   } else { | ||||
|                     handleSelection(e, item.value); | ||||
|                   } | ||||
|                 }} | ||||
|               > | ||||
|                 {selected ? ( | ||||
| @@ -485,3 +537,38 @@ export function Selector<T>(props: { | ||||
|     </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,4 +1,5 @@ | ||||
| import tauriConfig from "../../src-tauri/tauri.conf.json"; | ||||
| import { DEFAULT_INPUT_TEMPLATE } from "../constant"; | ||||
|  | ||||
| export const getBuildConfig = () => { | ||||
|   if (typeof process === "undefined") { | ||||
| @@ -38,6 +39,7 @@ export const getBuildConfig = () => { | ||||
|     ...commitInfo, | ||||
|     buildMode, | ||||
|     isApp, | ||||
|     template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build"; | ||||
| export function getClientConfig() { | ||||
|   if (typeof document !== "undefined") { | ||||
|     // client side | ||||
|     return JSON.parse(queryMeta("config")) as BuildConfig; | ||||
|     return JSON.parse(queryMeta("config") || "{}") as BuildConfig; | ||||
|   } | ||||
|  | ||||
|   if (typeof process !== "undefined") { | ||||
|   | ||||
| @@ -21,6 +21,11 @@ declare global { | ||||
|       ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not | ||||
|       DISABLE_FAST_LINK?: string; // disallow parse settings from url or not | ||||
|       CUSTOM_MODELS?: string; // to control custom models | ||||
|       DEFAULT_MODEL?: string; // to control default model in every new chat window | ||||
|  | ||||
|       // stability only | ||||
|       STABILITY_URL?: string; | ||||
|       STABILITY_API_KEY?: string; | ||||
|  | ||||
|       // azure only | ||||
|       AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name} | ||||
| @@ -30,6 +35,44 @@ declare global { | ||||
|       // google only | ||||
|       GOOGLE_API_KEY?: string; | ||||
|       GOOGLE_URL?: string; | ||||
|  | ||||
|       // google tag manager | ||||
|       GTM_ID?: string; | ||||
|  | ||||
|       // anthropic only | ||||
|       ANTHROPIC_URL?: string; | ||||
|       ANTHROPIC_API_KEY?: string; | ||||
|       ANTHROPIC_API_VERSION?: string; | ||||
|  | ||||
|       // baidu only | ||||
|       BAIDU_URL?: string; | ||||
|       BAIDU_API_KEY?: string; | ||||
|       BAIDU_SECRET_KEY?: string; | ||||
|  | ||||
|       // bytedance only | ||||
|       BYTEDANCE_URL?: string; | ||||
|       BYTEDANCE_API_KEY?: string; | ||||
|  | ||||
|       // alibaba only | ||||
|       ALIBABA_URL?: string; | ||||
|       ALIBABA_API_KEY?: string; | ||||
|  | ||||
|       // tencent only | ||||
|       TENCENT_URL?: string; | ||||
|       TENCENT_SECRET_KEY?: string; | ||||
|       TENCENT_SECRET_ID?: string; | ||||
|  | ||||
|       // moonshot only | ||||
|       MOONSHOT_URL?: string; | ||||
|       MOONSHOT_API_KEY?: string; | ||||
|  | ||||
|       // iflytek only | ||||
|       IFLYTEK_URL?: string; | ||||
|       IFLYTEK_API_KEY?: string; | ||||
|       IFLYTEK_API_SECRET?: string; | ||||
|  | ||||
|       // custom template for preprocessing user input | ||||
|       DEFAULT_INPUT_TEMPLATE?: string; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -47,6 +90,22 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> { | ||||
|   } | ||||
| })(); | ||||
|  | ||||
| function getApiKey(keys?: string) { | ||||
|   const apiKeyEnvVar = keys ?? ""; | ||||
|   const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); | ||||
|   const randomIndex = Math.floor(Math.random() * apiKeys.length); | ||||
|   const apiKey = apiKeys[randomIndex]; | ||||
|   if (apiKey) { | ||||
|     console.log( | ||||
|       `[Server Config] using ${randomIndex + 1} of ${ | ||||
|         apiKeys.length | ||||
|       } api key - ${apiKey}`, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return apiKey; | ||||
| } | ||||
|  | ||||
| export const getServerSideConfig = () => { | ||||
|   if (typeof process === "undefined") { | ||||
|     throw Error( | ||||
| @@ -56,39 +115,101 @@ export const getServerSideConfig = () => { | ||||
|  | ||||
|   const disableGPT4 = !!process.env.DISABLE_GPT4; | ||||
|   let customModels = process.env.CUSTOM_MODELS ?? ""; | ||||
|   let defaultModel = process.env.DEFAULT_MODEL ?? ""; | ||||
|  | ||||
|   if (disableGPT4) { | ||||
|     if (customModels) customModels += ","; | ||||
|     customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4")) | ||||
|     customModels += DEFAULT_MODELS.filter( | ||||
|       (m) => m.name.startsWith("gpt-4") && !m.name.startsWith("gpt-4o-mini"), | ||||
|     ) | ||||
|       .map((m) => "-" + m.name) | ||||
|       .join(","); | ||||
|     if ( | ||||
|       defaultModel.startsWith("gpt-4") && | ||||
|       !defaultModel.startsWith("gpt-4o-mini") | ||||
|     ) | ||||
|       defaultModel = ""; | ||||
|   } | ||||
|  | ||||
|   const isStability = !!process.env.STABILITY_API_KEY; | ||||
|  | ||||
|   const isAzure = !!process.env.AZURE_URL; | ||||
|   const isGoogle = !!process.env.GOOGLE_API_KEY; | ||||
|   const isAnthropic = !!process.env.ANTHROPIC_API_KEY; | ||||
|   const isTencent = !!process.env.TENCENT_API_KEY; | ||||
|  | ||||
|   const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; | ||||
|   const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); | ||||
|   const randomIndex = Math.floor(Math.random() * apiKeys.length); | ||||
|   const apiKey = apiKeys[randomIndex]; | ||||
|   console.log( | ||||
|     `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`, | ||||
|   ); | ||||
|   const isBaidu = !!process.env.BAIDU_API_KEY; | ||||
|   const isBytedance = !!process.env.BYTEDANCE_API_KEY; | ||||
|   const isAlibaba = !!process.env.ALIBABA_API_KEY; | ||||
|   const isMoonshot = !!process.env.MOONSHOT_API_KEY; | ||||
|   const isIflytek = !!process.env.IFLYTEK_API_KEY; | ||||
|   // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; | ||||
|   // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); | ||||
|   // const randomIndex = Math.floor(Math.random() * apiKeys.length); | ||||
|   // const apiKey = apiKeys[randomIndex]; | ||||
|   // console.log( | ||||
|   //   `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`, | ||||
|   // ); | ||||
|  | ||||
|   const allowedWebDevEndpoints = ( | ||||
|     process.env.WHITE_WEBDEV_ENDPOINTS ?? "" | ||||
|   ).split(","); | ||||
|  | ||||
|   return { | ||||
|     baseUrl: process.env.BASE_URL, | ||||
|     apiKey, | ||||
|     apiKey: getApiKey(process.env.OPENAI_API_KEY), | ||||
|     openaiOrgId: process.env.OPENAI_ORG_ID, | ||||
|  | ||||
|     isStability, | ||||
|     stabilityUrl: process.env.STABILITY_URL, | ||||
|     stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY), | ||||
|  | ||||
|     isAzure, | ||||
|     azureUrl: process.env.AZURE_URL, | ||||
|     azureApiKey: process.env.AZURE_API_KEY, | ||||
|     azureApiKey: getApiKey(process.env.AZURE_API_KEY), | ||||
|     azureApiVersion: process.env.AZURE_API_VERSION, | ||||
|  | ||||
|     isGoogle, | ||||
|     googleApiKey: process.env.GOOGLE_API_KEY, | ||||
|     googleApiKey: getApiKey(process.env.GOOGLE_API_KEY), | ||||
|     googleUrl: process.env.GOOGLE_URL, | ||||
|  | ||||
|     isAnthropic, | ||||
|     anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY), | ||||
|     anthropicApiVersion: process.env.ANTHROPIC_API_VERSION, | ||||
|     anthropicUrl: process.env.ANTHROPIC_URL, | ||||
|  | ||||
|     isBaidu, | ||||
|     baiduUrl: process.env.BAIDU_URL, | ||||
|     baiduApiKey: getApiKey(process.env.BAIDU_API_KEY), | ||||
|     baiduSecretKey: process.env.BAIDU_SECRET_KEY, | ||||
|  | ||||
|     isBytedance, | ||||
|     bytedanceApiKey: getApiKey(process.env.BYTEDANCE_API_KEY), | ||||
|     bytedanceUrl: process.env.BYTEDANCE_URL, | ||||
|  | ||||
|     isAlibaba, | ||||
|     alibabaUrl: process.env.ALIBABA_URL, | ||||
|     alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY), | ||||
|  | ||||
|     isTencent, | ||||
|     tencentUrl: process.env.TENCENT_URL, | ||||
|     tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY), | ||||
|     tencentSecretId: process.env.TENCENT_SECRET_ID, | ||||
|  | ||||
|     isMoonshot, | ||||
|     moonshotUrl: process.env.MOONSHOT_URL, | ||||
|     moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY), | ||||
|  | ||||
|     isIflytek, | ||||
|     iflytekUrl: process.env.IFLYTEK_URL, | ||||
|     iflytekApiKey: process.env.IFLYTEK_API_KEY, | ||||
|     iflytekApiSecret: process.env.IFLYTEK_API_SECRET, | ||||
|  | ||||
|     cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID, | ||||
|     cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID, | ||||
|     cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY), | ||||
|     cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL, | ||||
|  | ||||
|     gtmId: process.env.GTM_ID, | ||||
|  | ||||
|     needCode: ACCESS_CODES.size > 0, | ||||
| @@ -103,5 +224,7 @@ export const getServerSideConfig = () => { | ||||
|     hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY, | ||||
|     disableFastLink: !!process.env.DISABLE_FAST_LINK, | ||||
|     customModels, | ||||
|     defaultModel, | ||||
|     allowedWebDevEndpoints, | ||||
|   }; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										512
									
								
								app/constant.ts
									
									
									
									
									
								
							
							
						
						| @@ -1,4 +1,4 @@ | ||||
| export const OWNER = "Yidadaa"; | ||||
| export const OWNER = "ChatGPTNextWeb"; | ||||
| export const REPO = "ChatGPT-Next-Web"; | ||||
| export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; | ||||
| export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`; | ||||
| @@ -8,12 +8,29 @@ 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 RUNTIME_CONFIG_DOM = "danger-runtime-config"; | ||||
|  | ||||
| export const DEFAULT_CORS_HOST = "https://a.nextweb.fun"; | ||||
| export const DEFAULT_API_HOST = `${DEFAULT_CORS_HOST}/api/proxy`; | ||||
| export const STABILITY_BASE_URL = "https://api.stability.ai"; | ||||
|  | ||||
| export const DEFAULT_API_HOST = "https://api.nextchat.dev"; | ||||
| export const OPENAI_BASE_URL = "https://api.openai.com"; | ||||
| export const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; | ||||
|  | ||||
| export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/"; | ||||
|  | ||||
| export const BAIDU_BASE_URL = "https://aip.baidubce.com"; | ||||
| export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`; | ||||
|  | ||||
| export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com"; | ||||
|  | ||||
| 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 CACHE_URL_PREFIX = "/api/cache"; | ||||
| export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`; | ||||
|  | ||||
| export enum Path { | ||||
|   Home = "/", | ||||
|   Chat = "/chat", | ||||
| @@ -21,11 +38,25 @@ export enum Path { | ||||
|   NewChat = "/new-chat", | ||||
|   Masks = "/masks", | ||||
|   Auth = "/auth", | ||||
|   Sd = "/sd", | ||||
|   SdNew = "/sd-new", | ||||
|   Artifacts = "/artifacts", | ||||
| } | ||||
|  | ||||
| export enum ApiPath { | ||||
|   Cors = "/api/cors", | ||||
|   Cors = "", | ||||
|   Azure = "/api/azure", | ||||
|   OpenAI = "/api/openai", | ||||
|   Anthropic = "/api/anthropic", | ||||
|   Google = "/api/google", | ||||
|   Baidu = "/api/baidu", | ||||
|   ByteDance = "/api/bytedance", | ||||
|   Alibaba = "/api/alibaba", | ||||
|   Tencent = "/api/tencent", | ||||
|   Moonshot = "/api/moonshot", | ||||
|   Iflytek = "/api/iflytek", | ||||
|   Stability = "/api/stability", | ||||
|   Artifacts = "/api/artifacts", | ||||
| } | ||||
|  | ||||
| export enum SlotID { | ||||
| @@ -38,6 +69,10 @@ export enum FileName { | ||||
|   Prompts = "prompts.json", | ||||
| } | ||||
|  | ||||
| export enum Plugin { | ||||
|   Artifacts = "artifacts", | ||||
| } | ||||
|  | ||||
| export enum StoreKey { | ||||
|   Chat = "chat-next-web-store", | ||||
|   Access = "access-control", | ||||
| @@ -46,6 +81,7 @@ export enum StoreKey { | ||||
|   Prompt = "prompt-store", | ||||
|   Update = "chat-update", | ||||
|   Sync = "sync", | ||||
|   SdList = "sd-list", | ||||
| } | ||||
|  | ||||
| export const DEFAULT_SIDEBAR_WIDTH = 300; | ||||
| @@ -68,218 +104,374 @@ export enum ServiceProvider { | ||||
|   OpenAI = "OpenAI", | ||||
|   Azure = "Azure", | ||||
|   Google = "Google", | ||||
|   Anthropic = "Anthropic", | ||||
|   Baidu = "Baidu", | ||||
|   ByteDance = "ByteDance", | ||||
|   Alibaba = "Alibaba", | ||||
|   Tencent = "Tencent", | ||||
|   Moonshot = "Moonshot", | ||||
|   Stability = "Stability", | ||||
|   Iflytek = "Iflytek", | ||||
| } | ||||
|  | ||||
| // 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 { | ||||
|   Stability = "Stability", | ||||
|   GPT = "GPT", | ||||
|   GeminiPro = "GeminiPro", | ||||
|   Claude = "Claude", | ||||
|   Ernie = "Ernie", | ||||
|   Doubao = "Doubao", | ||||
|   Qwen = "Qwen", | ||||
|   Hunyuan = "Hunyuan", | ||||
|   Moonshot = "Moonshot", | ||||
|   Iflytek = "Iflytek", | ||||
| } | ||||
|  | ||||
| export const Stability = { | ||||
|   GeneratePath: "v2beta/stable-image/generate", | ||||
|   ExampleEndpoint: "https://api.stability.ai", | ||||
| }; | ||||
|  | ||||
| export const Anthropic = { | ||||
|   ChatPath: "v1/messages", | ||||
|   ChatPath1: "v1/complete", | ||||
|   ExampleEndpoint: "https://api.anthropic.com", | ||||
|   Vision: "2023-06-01", | ||||
| }; | ||||
|  | ||||
| export const OpenaiPath = { | ||||
|   ChatPath: "v1/chat/completions", | ||||
|   ImagePath: "v1/images/generations", | ||||
|   UsagePath: "dashboard/billing/usage", | ||||
|   SubsPath: "dashboard/billing/subscription", | ||||
|   ListModelPath: "v1/models", | ||||
| }; | ||||
|  | ||||
| export const Azure = { | ||||
|   ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}", | ||||
|   ChatPath: (deployName: string, apiVersion: string) => | ||||
|     `deployments/${deployName}/chat/completions?api-version=${apiVersion}`, | ||||
|   // 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 = { | ||||
|   ExampleEndpoint: "https://generativelanguage.googleapis.com/", | ||||
|   ChatPath: "v1beta/models/gemini-pro:generateContent", | ||||
|   ChatPath: (modelName: string) => | ||||
|     `v1beta/models/${modelName}:streamGenerateContent`, | ||||
| }; | ||||
|  | ||||
|   // /api/openai/v1/chat/completions | ||||
| export const Baidu = { | ||||
|   ExampleEndpoint: BAIDU_BASE_URL, | ||||
|   ChatPath: (modelName: string) => { | ||||
|     let endpoint = modelName; | ||||
|     if (modelName === "ernie-4.0-8k") { | ||||
|       endpoint = "completions_pro"; | ||||
|     } | ||||
|     if (modelName === "ernie-4.0-8k-preview-0518") { | ||||
|       endpoint = "completions_adv_pro"; | ||||
|     } | ||||
|     if (modelName === "ernie-3.5-8k") { | ||||
|       endpoint = "completions"; | ||||
|     } | ||||
|     if (modelName === "ernie-speed-8k") { | ||||
|       endpoint = "ernie_speed"; | ||||
|     } | ||||
|     return `rpc/2.0/ai_custom/v1/wenxinworkshop/chat/${endpoint}`; | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const ByteDance = { | ||||
|   ExampleEndpoint: "https://ark.cn-beijing.volces.com/api/", | ||||
|   ChatPath: "api/v3/chat/completions", | ||||
| }; | ||||
|  | ||||
| export const Alibaba = { | ||||
|   ExampleEndpoint: ALIBABA_BASE_URL, | ||||
|   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 DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang | ||||
| // export const DEFAULT_SYSTEM_TEMPLATE = ` | ||||
| // You are ChatGPT, a large language model trained by {{ServiceProvider}}. | ||||
| // Knowledge cutoff: {{cutoff}} | ||||
| // Current model: {{model}} | ||||
| // Current time: {{time}} | ||||
| // Latex inline: $x^2$ | ||||
| // Latex block: $$e=mc^2$$ | ||||
| // `; | ||||
| export const DEFAULT_SYSTEM_TEMPLATE = ` | ||||
| You are ChatGPT, a large language model trained by {{ServiceProvider}}. | ||||
| Knowledge cutoff: {{cutoff}} | ||||
| Current model: {{model}} | ||||
| Current time: {{time}} | ||||
| Latex inline: $x^2$  | ||||
| Latex inline: \\(x^2\\)  | ||||
| Latex block: $$e=mc^2$$ | ||||
| `; | ||||
|  | ||||
| export const SUMMARIZE_MODEL = "gpt-3.5-turbo"; | ||||
| export const SUMMARIZE_MODEL = "gpt-4o-mini"; | ||||
| export const GEMINI_SUMMARIZE_MODEL = "gemini-pro"; | ||||
|  | ||||
| export const KnowledgeCutOffDate: Record<string, string> = { | ||||
|   default: "2021-09", | ||||
|   "gpt-4-turbo-preview": "2023-04", | ||||
|   "gpt-4-1106-preview": "2023-04", | ||||
|   "gpt-4-0125-preview": "2023-04", | ||||
|   "gpt-4-turbo": "2023-12", | ||||
|   "gpt-4-turbo-2024-04-09": "2023-12", | ||||
|   "gpt-4-turbo-preview": "2023-12", | ||||
|   "gpt-4o": "2023-10", | ||||
|   "gpt-4o-2024-05-13": "2023-10", | ||||
|   "gpt-4o-2024-08-06": "2023-10", | ||||
|   "gpt-4o-mini": "2023-10", | ||||
|   "gpt-4o-mini-2024-07-18": "2023-10", | ||||
|   "gpt-4-vision-preview": "2023-04", | ||||
|   // After improvements,  | ||||
|   // After improvements, | ||||
|   // it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously. | ||||
|   "gemini-pro": "2023-12", | ||||
|   "gemini-pro-vision": "2023-12", | ||||
| }; | ||||
|  | ||||
| const openaiModels = [ | ||||
|   "gpt-3.5-turbo", | ||||
|   "gpt-3.5-turbo-1106", | ||||
|   "gpt-3.5-turbo-0125", | ||||
|   "gpt-4", | ||||
|   "gpt-4-0613", | ||||
|   "gpt-4-32k", | ||||
|   "gpt-4-32k-0613", | ||||
|   "gpt-4-turbo", | ||||
|   "gpt-4-turbo-preview", | ||||
|   "gpt-4o", | ||||
|   "gpt-4o-2024-05-13", | ||||
|   "gpt-4o-2024-08-06", | ||||
|   "gpt-4o-mini", | ||||
|   "gpt-4o-mini-2024-07-18", | ||||
|   "gpt-4-vision-preview", | ||||
|   "gpt-4-turbo-2024-04-09", | ||||
|   "gpt-4-1106-preview", | ||||
|   "dall-e-3", | ||||
| ]; | ||||
|  | ||||
| const googleModels = [ | ||||
|   "gemini-1.0-pro", | ||||
|   "gemini-1.5-pro-latest", | ||||
|   "gemini-1.5-flash-latest", | ||||
|   "gemini-pro-vision", | ||||
| ]; | ||||
|  | ||||
| const anthropicModels = [ | ||||
|   "claude-instant-1.2", | ||||
|   "claude-2.0", | ||||
|   "claude-2.1", | ||||
|   "claude-3-sonnet-20240229", | ||||
|   "claude-3-opus-20240229", | ||||
|   "claude-3-haiku-20240307", | ||||
|   "claude-3-5-sonnet-20240620", | ||||
| ]; | ||||
|  | ||||
| const baiduModels = [ | ||||
|   "ernie-4.0-turbo-8k", | ||||
|   "ernie-4.0-8k", | ||||
|   "ernie-4.0-8k-preview", | ||||
|   "ernie-4.0-8k-preview-0518", | ||||
|   "ernie-4.0-8k-latest", | ||||
|   "ernie-3.5-8k", | ||||
|   "ernie-3.5-8k-0205", | ||||
|   "ernie-speed-128k", | ||||
|   "ernie-speed-8k", | ||||
|   "ernie-lite-8k", | ||||
|   "ernie-tiny-8k", | ||||
| ]; | ||||
|  | ||||
| const bytedanceModels = [ | ||||
|   "Doubao-lite-4k", | ||||
|   "Doubao-lite-32k", | ||||
|   "Doubao-lite-128k", | ||||
|   "Doubao-pro-4k", | ||||
|   "Doubao-pro-32k", | ||||
|   "Doubao-pro-128k", | ||||
| ]; | ||||
|  | ||||
| const alibabaModes = [ | ||||
|   "qwen-turbo", | ||||
|   "qwen-plus", | ||||
|   "qwen-max", | ||||
|   "qwen-max-0428", | ||||
|   "qwen-max-0403", | ||||
|   "qwen-max-0107", | ||||
|   "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", | ||||
| ]; | ||||
|  | ||||
| let seq = 1000; // 内置的模型序号生成器从1000开始 | ||||
| export const DEFAULT_MODELS = [ | ||||
|   { | ||||
|     name: "gpt-4", | ||||
|   ...openaiModels.map((name) => ({ | ||||
|     name, | ||||
|     available: true, | ||||
|     sorted: seq++, // Global sequence sort(index) | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|       sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致 | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-0314", | ||||
|   })), | ||||
|   ...openaiModels.map((name) => ({ | ||||
|     name, | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|       id: "azure", | ||||
|       providerName: "Azure", | ||||
|       providerType: "azure", | ||||
|       sorted: 2, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-32k", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-32k-0314", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-32k-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-turbo-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-1106-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-0125-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-vision-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-0125", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-0301", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-1106", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-16k", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-16k-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gemini-pro", | ||||
|   })), | ||||
|   ...googleModels.map((name) => ({ | ||||
|     name, | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "google", | ||||
|       providerName: "Google", | ||||
|       providerType: "google", | ||||
|       sorted: 3, | ||||
|     }, | ||||
|   }, | ||||
|   })), | ||||
|   ...anthropicModels.map((name) => ({ | ||||
|     name, | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "anthropic", | ||||
|       providerName: "Anthropic", | ||||
|       providerType: "anthropic", | ||||
|       sorted: 4, | ||||
|     }, | ||||
|   })), | ||||
|   ...baiduModels.map((name) => ({ | ||||
|     name, | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "baidu", | ||||
|       providerName: "Baidu", | ||||
|       providerType: "baidu", | ||||
|       sorted: 5, | ||||
|     }, | ||||
|   })), | ||||
|   ...bytedanceModels.map((name) => ({ | ||||
|     name, | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "bytedance", | ||||
|       providerName: "ByteDance", | ||||
|       providerType: "bytedance", | ||||
|       sorted: 6, | ||||
|     }, | ||||
|   })), | ||||
|   ...alibabaModes.map((name) => ({ | ||||
|     name, | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "alibaba", | ||||
|       providerName: "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, | ||||
|     }, | ||||
|   })), | ||||
| ] as const; | ||||
|  | ||||
| export const CHAT_PAGE_SIZE = 15; | ||||
| export const MAX_RENDER_MSG_COUNT = 45; | ||||
|  | ||||
| // some famous webdav endpoints | ||||
| export const internalAllowedWebDavEndpoints = [ | ||||
|   "https://dav.jianguoyun.com/dav/", | ||||
|   "https://dav.dropdav.com/", | ||||
|   "https://dav.box.com/dav", | ||||
|   "https://nanao.teracloud.jp/dav/", | ||||
|   "https://bora.teracloud.jp/dav/", | ||||
|   "https://webdav.4shared.com/", | ||||
|   "https://dav.idrivesync.com", | ||||
|   "https://webdav.yandex.com", | ||||
|   "https://app.koofr.net/dav/Koofr", | ||||
| ]; | ||||
|  | ||||
| export const PLUGINS = [{ name: "Stable Diffusion", path: Path.Sd }]; | ||||
|   | ||||
							
								
								
									
										1
									
								
								app/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -19,6 +19,7 @@ declare interface Window { | ||||
|     }; | ||||
|     fs: { | ||||
|       writeBinaryFile(path: string, data: Uint8Array): Promise<void>; | ||||
|       writeTextFile(path: string, data: string): Promise<void>; | ||||
|     }; | ||||
|     notification:{ | ||||
|       requestPermission(): Promise<Permission>; | ||||
|   | ||||
							
								
								
									
										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 | 
							
								
								
									
										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 | 
							
								
								
									
										10
									
								
								app/icons/history.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| <?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="M5.81836 6.72729V14H13.0911" stroke="#333" stroke-width="4" stroke-linecap="round" | ||||
|         stroke-linejoin="round" /> | ||||
|     <path | ||||
|         d="M4 24C4 35.0457 12.9543 44 24 44V44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C16.598 4 10.1351 8.02111 6.67677 13.9981" | ||||
|         stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> | ||||
|     <path d="M24.005 12L24.0038 24.0088L32.4832 32.4882" stroke="#333" stroke-width="4" | ||||
|         stroke-linecap="round" stroke-linejoin="round" /> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 660 B | 
							
								
								
									
										1
									
								
								app/icons/image.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" height="16" width="16" version="1.1" xml:space="preserve" style=""><rect id="backgroundrect" width="100%" height="100%" x="0" y="0" fill="none" stroke="none"/><g class="currentLayer" style=""><title>Layer 1</title><g id="svg_1" class="" fill="#333" fill-opacity="1"><polygon points="2.4690866470336914,2.4690725803375244 4.447190761566162,2.4690725803375244 4.447190761566162,1.6882386207580566 1.6882381439208984,1.6882386207580566 1.6882381439208984,4.44719123840332 2.4690866470336914,4.44719123840332 " id="svg_2" fill="#333" fill-opacity="1"/><polygon points="11.552804470062256,1.6882386207580566 11.552804470062256,2.4690725803375244 13.530910968780518,2.4690725803375244 13.530910968780518,4.44719123840332 14.311760425567627,4.44719123840332 14.311760425567627,1.6882386207580566 " id="svg_3" fill="#333" fill-opacity="1"/><polygon points="13.530910968780518,13.530919075012207 11.552804470062256,13.530919075012207 11.552804470062256,14.311760902404785 14.311760425567627,14.311760902404785 14.311760425567627,11.552801132202148 13.530910968780518,11.552801132202148 " id="svg_4" fill="#333" fill-opacity="1"/><polygon points="2.4690866470336914,11.552801132202148 1.6882381439208984,11.552801132202148 1.6882381439208984,14.311760902404785 4.447190761566162,14.311760902404785 4.447190761566162,13.530919075012207 2.4690866470336914,13.530919075012207 " id="svg_5" fill="#333" fill-opacity="1"/><path d="M8.830417847409231,6.243117030680995 c0.68169614081525,0 1.2363241834494423,-0.5546280426341942 1.2363241834494423,-1.2363241834494423 S9.51214001610201,3.770468663782117 8.830417847409231,3.770468663782117 s-1.2363241834494423,0.5546280426341942 -1.2363241834494423,1.2363241834494423 S8.14872170659398,6.243117030680995 8.830417847409231,6.243117030680995 z" id="svg_6" fill="#333" fill-opacity="1"/><polygon points="3.7704806327819824,12.229532241821289 12.229516506195068,12.229532241821289 12.229516506195068,9.709510803222656 10.70320463180542,8.099010467529297 8.852166652679443,9.175727844238281 6.275332450866699,7.334256172180176 3.7704806327819824,9.977211952209473 " id="svg_7" fill="#333" fill-opacity="1"/></g></g></svg> | ||||
| After Width: | Height: | Size: 2.2 KiB | 
							
								
								
									
										1
									
								
								app/icons/loading.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fff" style=""><rect id="backgroundrect" width="100%" height="100%" x="0" y="0" fill="none" stroke="none" style="" class="" /><g class="currentLayer" style=""><title>Layer 1</title><circle cx="4" cy="8" r="1.926" fill="#333" id="svg_1" class=""><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="2" repeatCount="indefinite" to="2" values="2;1.2;2" /><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1" /></circle><circle cx="8" cy="8" r="1.2736" fill="#333" fill-opacity=".3" id="svg_2" class=""><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="1.2" repeatCount="indefinite" to="1.2" values="1.2;2;1.2" /><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from=".5" repeatCount="indefinite" to=".5" values=".5;1;.5" /></circle><circle cx="12" cy="8" r="1.926" fill="#333" id="svg_3" class=""><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="2" repeatCount="indefinite" to="2" values="2;1.2;2" /><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1" /></circle></g></svg> | ||||
| After Width: | Height: | Size: 1.3 KiB | 
							
								
								
									
										4
									
								
								app/icons/palette.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-palette" viewBox="0 0 16 16"> | ||||
|   <path d="M8 5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m4 3a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M5.5 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/> | ||||
|   <path d="M16 8c0 3.15-1.866 2.585-3.567 2.07C11.42 9.763 10.465 9.473 10 10c-.603.683-.475 1.819-.351 2.92C9.826 14.495 9.996 16 8 16a8 8 0 1 1 8-8m-8 7c.611 0 .654-.171.655-.176.078-.146.124-.464.07-1.119-.014-.168-.037-.37-.061-.591-.052-.464-.112-1.005-.118-1.462-.01-.707.083-1.61.704-2.314.369-.417.845-.578 1.272-.618.404-.038.812.026 1.16.104.343.077.702.186 1.025.284l.028.008c.346.105.658.199.953.266.653.148.904.083.991.024C14.717 9.38 15 9.161 15 8a7 7 0 1 0-7 7"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 781 B | 
							
								
								
									
										12
									
								
								app/icons/sd.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="1.21em" height="1em" viewBox="0 0 256 213"> | ||||
|     <defs> | ||||
|         <linearGradient id="logosStabilityAiIcon0" x1="50%" x2="50%" y1="0%" y2="100%"> | ||||
|             <stop offset="0%" stop-color="#9d39ff" /> | ||||
|             <stop offset="100%" stop-color="#a380ff" /> | ||||
|         </linearGradient> | ||||
|     </defs> | ||||
|     <path fill="url(#logosStabilityAiIcon0)" | ||||
|         d="M72.418 212.45c49.478 0 81.658-26.205 81.658-65.626c0-30.572-19.572-49.998-54.569-58.043l-22.469-6.74c-19.71-4.424-31.215-9.738-28.505-23.312c2.255-11.292 9.002-17.667 24.69-17.667c49.872 0 68.35 17.667 68.35 17.667V16.237S123.583 0 73.223 0C25.757 0 0 24.424 0 62.236c0 30.571 17.85 48.35 54.052 56.798q3.802.95 3.885.976q8.26 2.556 22.293 6.755c18.504 4.425 23.262 9.121 23.262 23.2c0 12.872-13.374 20.19-31.074 20.19C21.432 170.154 0 144.36 0 144.36v47.078s13.402 21.01 72.418 21.01" /> | ||||
|     <path fill="#e80000" | ||||
|         d="M225.442 209.266c17.515 0 30.558-12.67 30.558-29.812c0-17.515-12.67-29.813-30.558-29.813c-17.515 0-30.185 12.298-30.185 29.813s12.67 29.812 30.185 29.812" /> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
							
								
								
									
										1
									
								
								app/icons/size.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <?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="M42 7H6C4.89543 7 4 7.89543 4 9V39C4 40.1046 4.89543 41 6 41H42C43.1046 41 44 40.1046 44 39V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#333" stroke-width="4"/><path d="M30 30V18L38 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 30V18L18 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M24 20V21" stroke="#333" stroke-width="4" stroke-linecap="round"/><path d="M24 27V28" stroke="#333" stroke-width="4" stroke-linecap="round"/></svg> | ||||
| After Width: | Height: | Size: 681 B | 
| @@ -3,7 +3,7 @@ import "./styles/globals.scss"; | ||||
| import "./styles/markdown.scss"; | ||||
| import "./styles/highlight.scss"; | ||||
| import { getClientConfig } from "./config/client"; | ||||
| import { type Metadata } from "next"; | ||||
| import type { Metadata, Viewport } from "next"; | ||||
| import { SpeedInsights } from "@vercel/speed-insights/next"; | ||||
| import { getServerSideConfig } from "./config/server"; | ||||
| import { GoogleTagManager } from "@next/third-parties/google"; | ||||
| @@ -12,21 +12,22 @@ const serverConfig = getServerSideConfig(); | ||||
| export const metadata: Metadata = { | ||||
|   title: "NextChat", | ||||
|   description: "Your personal ChatGPT Chat Bot.", | ||||
|   viewport: { | ||||
|     width: "device-width", | ||||
|     initialScale: 1, | ||||
|     maximumScale: 1, | ||||
|   }, | ||||
|   themeColor: [ | ||||
|     { media: "(prefers-color-scheme: light)", color: "#fafafa" }, | ||||
|     { media: "(prefers-color-scheme: dark)", color: "#151515" }, | ||||
|   ], | ||||
|   appleWebApp: { | ||||
|     title: "NextChat", | ||||
|     statusBarStyle: "default", | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const viewport: Viewport = { | ||||
|   width: "device-width", | ||||
|   initialScale: 1, | ||||
|   maximumScale: 1, | ||||
|   themeColor: [ | ||||
|     { media: "(prefers-color-scheme: light)", color: "#fafafa" }, | ||||
|     { media: "(prefers-color-scheme: dark)", color: "#151515" }, | ||||
|   ], | ||||
| }; | ||||
|  | ||||
| export default function RootLayout({ | ||||
|   children, | ||||
| }: { | ||||
| @@ -36,6 +37,10 @@ export default function RootLayout({ | ||||
|     <html lang="en"> | ||||
|       <head> | ||||
|         <meta name="config" content={JSON.stringify(getClientConfig())} /> | ||||
|         <meta | ||||
|           name="viewport" | ||||
|           content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" | ||||
|         /> | ||||
|         <link rel="manifest" href="/site.webmanifest"></link> | ||||
|         <script src="/serviceWorkerRegister.js" defer></script> | ||||
|       </head> | ||||
|   | ||||
| @@ -111,6 +111,11 @@ const ar: PartialLocaleType = { | ||||
|       Title: "حجم الخط", | ||||
|       SubTitle: "ضبط حجم الخط لمحتوى الدردشة", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "خط الدردشة", | ||||
|       SubTitle: "خط محتوى الدردشة، اتركه فارغًا لتطبيق الخط الافتراضي العالمي", | ||||
|       Placeholder: "اسم الخط", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "حقن تلميحات النظام", | ||||
|       SubTitle: | ||||
|   | ||||
| @@ -136,6 +136,12 @@ const bn: PartialLocaleType = { | ||||
|       Title: "ফন্ট সাইজ", | ||||
|       SubTitle: "চ্যাট সামগ্রীর ফন্ট সাইজ সংশোধন করুন", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "চ্যাট ফন্ট", | ||||
|       SubTitle: | ||||
|         "চ্যাট সামগ্রীর ফন্ট, বিশ্বব্যাপী ডিফল্ট ফন্ট প্রয়োগ করতে খালি রাখুন", | ||||
|       Placeholder: "ফন্টের নাম", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "حقن تلميحات النظام", | ||||
|       SubTitle: | ||||
|   | ||||
| @@ -42,6 +42,7 @@ const cn = { | ||||
|       PinToastAction: "查看", | ||||
|       Delete: "删除", | ||||
|       Edit: "编辑", | ||||
|       FullScreen: "全屏", | ||||
|     }, | ||||
|     Commands: { | ||||
|       new: "新建聊天", | ||||
| @@ -63,6 +64,7 @@ const cn = { | ||||
|       Masks: "所有面具", | ||||
|       Clear: "清除聊天", | ||||
|       Settings: "对话设置", | ||||
|       UploadImage: "上传图片", | ||||
|     }, | ||||
|     Rename: "重命名对话", | ||||
|     Typing: "正在输入…", | ||||
| @@ -103,6 +105,10 @@ const cn = { | ||||
|       Toast: "正在生成截图", | ||||
|       Modal: "长按或右键保存图片", | ||||
|     }, | ||||
|     Artifacts: { | ||||
|       Title: "分享页面", | ||||
|       Error: "分享失败", | ||||
|     }, | ||||
|   }, | ||||
|   Select: { | ||||
|     Search: "搜索消息", | ||||
| @@ -127,6 +133,7 @@ const cn = { | ||||
|   Settings: { | ||||
|     Title: "设置", | ||||
|     SubTitle: "所有设置选项", | ||||
|     ShowPassword: "显示密码", | ||||
|  | ||||
|     Danger: { | ||||
|       Reset: { | ||||
| @@ -151,6 +158,11 @@ const cn = { | ||||
|       Title: "字体大小", | ||||
|       SubTitle: "聊天内容的字体大小", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "聊天字体", | ||||
|       SubTitle: "聊天内容的字体,若置空则应用全局默认字体", | ||||
|       Placeholder: "字体名称", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "注入系统级提示信息", | ||||
|       SubTitle: "强制给每次请求的消息列表开头添加一个模拟 ChatGPT 的系统提示", | ||||
| @@ -312,21 +324,134 @@ const cn = { | ||||
|           SubTitle: "选择指定的部分版本", | ||||
|         }, | ||||
|       }, | ||||
|       Google: { | ||||
|       Anthropic: { | ||||
|         ApiKey: { | ||||
|           Title: "接口密钥", | ||||
|           SubTitle: "使用自定义 Google AI Studio API Key 绕过密码访问限制", | ||||
|           Placeholder: "Google AI Studio API Key", | ||||
|           SubTitle: "使用自定义 Anthropic Key 绕过密码访问限制", | ||||
|           Placeholder: "Anthropic API Key", | ||||
|         }, | ||||
|  | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "不包含请求路径,样例:", | ||||
|           SubTitle: "样例:", | ||||
|         }, | ||||
|  | ||||
|         ApiVerion: { | ||||
|           Title: "接口版本 (gemini-pro api version)", | ||||
|           SubTitle: "选择指定的部分版本", | ||||
|           Title: "接口版本 (claude api version)", | ||||
|           SubTitle: "选择一个特定的 API 版本输入", | ||||
|         }, | ||||
|       }, | ||||
|       Google: { | ||||
|         ApiKey: { | ||||
|           Title: "API 密钥", | ||||
|           SubTitle: "从 Google AI 获取您的 API 密钥", | ||||
|           Placeholder: "输入您的 Google AI Studio API 密钥", | ||||
|         }, | ||||
|  | ||||
|         Endpoint: { | ||||
|           Title: "终端地址", | ||||
|           SubTitle: "示例:", | ||||
|         }, | ||||
|  | ||||
|         ApiVersion: { | ||||
|           Title: "API 版本(仅适用于 gemini-pro)", | ||||
|           SubTitle: "选择一个特定的 API 版本", | ||||
|         }, | ||||
|         GoogleSafetySettings: { | ||||
|           Title: "Google 安全过滤级别", | ||||
|           SubTitle: "设置内容过滤级别", | ||||
|         }, | ||||
|       }, | ||||
|       Baidu: { | ||||
|         ApiKey: { | ||||
|           Title: "API Key", | ||||
|           SubTitle: "使用自定义 Baidu API Key", | ||||
|           Placeholder: "Baidu API Key", | ||||
|         }, | ||||
|         SecretKey: { | ||||
|           Title: "Secret Key", | ||||
|           SubTitle: "使用自定义 Baidu Secret Key", | ||||
|           Placeholder: "Baidu Secret Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "不支持自定义前往.env配置", | ||||
|         }, | ||||
|       }, | ||||
|       Tencent: { | ||||
|         ApiKey: { | ||||
|           Title: "API Key", | ||||
|           SubTitle: "使用自定义腾讯云API Key", | ||||
|           Placeholder: "Tencent API Key", | ||||
|         }, | ||||
|         SecretKey: { | ||||
|           Title: "Secret Key", | ||||
|           SubTitle: "使用自定义腾讯云Secret Key", | ||||
|           Placeholder: "Tencent Secret Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "不支持自定义前往.env配置", | ||||
|         }, | ||||
|       }, | ||||
|       ByteDance: { | ||||
|         ApiKey: { | ||||
|           Title: "接口密钥", | ||||
|           SubTitle: "使用自定义 ByteDance API Key", | ||||
|           Placeholder: "ByteDance API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "样例:", | ||||
|         }, | ||||
|       }, | ||||
|       Alibaba: { | ||||
|         ApiKey: { | ||||
|           Title: "接口密钥", | ||||
|           SubTitle: "使用自定义阿里云API Key", | ||||
|           Placeholder: "Alibaba Cloud API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "样例:", | ||||
|         }, | ||||
|       }, | ||||
|       Moonshot: { | ||||
|         ApiKey: { | ||||
|           Title: "接口密钥", | ||||
|           SubTitle: "使用自定义月之暗面API Key", | ||||
|           Placeholder: "Moonshot API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "样例:", | ||||
|         }, | ||||
|       }, | ||||
|       Stability: { | ||||
|         ApiKey: { | ||||
|           Title: "接口密钥", | ||||
|           SubTitle: "使用自定义 Stability API Key", | ||||
|           Placeholder: "Stability API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "样例:", | ||||
|         }, | ||||
|       }, | ||||
|       Iflytek: { | ||||
|         ApiKey: { | ||||
|           Title: "ApiKey", | ||||
|           SubTitle: "从讯飞星火控制台获取的 APIKey", | ||||
|           Placeholder: "APIKey", | ||||
|         }, | ||||
|         ApiSecret: { | ||||
|           Title: "ApiSecret", | ||||
|           SubTitle: "从讯飞星火控制台获取的 APISecret", | ||||
|           Placeholder: "APISecret", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "接口地址", | ||||
|           SubTitle: "样例:", | ||||
|         }, | ||||
|       }, | ||||
|       CustomModel: { | ||||
| @@ -386,6 +511,10 @@ const cn = { | ||||
|   }, | ||||
|   Plugin: { | ||||
|     Name: "插件", | ||||
|     Artifacts: "Artifacts", | ||||
|   }, | ||||
|   Discovery: { | ||||
|     Name: "发现", | ||||
|   }, | ||||
|   FineTuned: { | ||||
|     Sysmessage: "你是一个助手", | ||||
| @@ -466,6 +595,61 @@ const cn = { | ||||
|     Topic: "主题", | ||||
|     Time: "时间", | ||||
|   }, | ||||
|   SdPanel: { | ||||
|     Prompt: "画面提示", | ||||
|     NegativePrompt: "否定提示", | ||||
|     PleaseInput: (name: string) => `请输入${name}`, | ||||
|     AspectRatio: "横纵比", | ||||
|     ImageStyle: "图像风格", | ||||
|     OutFormat: "输出格式", | ||||
|     AIModel: "AI模型", | ||||
|     ModelVersion: "模型版本", | ||||
|     Submit: "提交生成", | ||||
|     ParamIsRequired: (name: string) => `${name}不能为空`, | ||||
|     Styles: { | ||||
|       D3Model: "3D模型", | ||||
|       AnalogFilm: "模拟电影", | ||||
|       Anime: "动漫", | ||||
|       Cinematic: "电影风格", | ||||
|       ComicBook: "漫画书", | ||||
|       DigitalArt: "数字艺术", | ||||
|       Enhance: "增强", | ||||
|       FantasyArt: "幻想艺术", | ||||
|       Isometric: "等角", | ||||
|       LineArt: "线描", | ||||
|       LowPoly: "低多边形", | ||||
|       ModelingCompound: "建模材料", | ||||
|       NeonPunk: "霓虹朋克", | ||||
|       Origami: "折纸", | ||||
|       Photographic: "摄影", | ||||
|       PixelArt: "像素艺术", | ||||
|       TileTexture: "贴图", | ||||
|     }, | ||||
|   }, | ||||
|   Sd: { | ||||
|     SubTitle: (count: number) => `共 ${count} 条绘画`, | ||||
|     Actions: { | ||||
|       Params: "查看参数", | ||||
|       Copy: "复制提示词", | ||||
|       Delete: "删除", | ||||
|       Retry: "重试", | ||||
|       ReturnHome: "返回首页", | ||||
|       History: "查看历史", | ||||
|     }, | ||||
|     EmptyRecord: "暂无绘画记录", | ||||
|     Status: { | ||||
|       Name: "状态", | ||||
|       Success: "成功", | ||||
|       Error: "失败", | ||||
|       Wait: "等待中", | ||||
|       Running: "运行中", | ||||
|     }, | ||||
|     Danger: { | ||||
|       Delete: "确认删除?", | ||||
|     }, | ||||
|     GenerateParams: "生成参数", | ||||
|     Detail: "详情", | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| type DeepPartial<T> = T extends object | ||||
|   | ||||
| @@ -71,6 +71,12 @@ const cs: PartialLocaleType = { | ||||
|       Title: "Velikost písma", | ||||
|       SubTitle: "Nastavení velikosti písma obsahu chatu", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "Chatové Písmo", | ||||
|       SubTitle: | ||||
|         "Písmo obsahu chatu, ponechejte prázdné pro použití globálního výchozího písma", | ||||
|       Placeholder: "Název Písma", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "Vložit systémové prompty", | ||||
|       SubTitle: | ||||
|   | ||||
| @@ -71,6 +71,12 @@ const de: PartialLocaleType = { | ||||
|       Title: "Schriftgröße", | ||||
|       SubTitle: "Schriftgröße des Chat-Inhalts anpassen", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "Chat-Schriftart", | ||||
|       SubTitle: | ||||
|         "Schriftart des Chat-Inhalts, leer lassen, um die globale Standardschriftart anzuwenden", | ||||
|       Placeholder: "Schriftartname", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "System-Prompts einfügen", | ||||
|       SubTitle: | ||||
|   | ||||
| @@ -44,6 +44,7 @@ const en: LocaleType = { | ||||
|       PinToastAction: "View", | ||||
|       Delete: "Delete", | ||||
|       Edit: "Edit", | ||||
|       FullScreen: "FullScreen", | ||||
|     }, | ||||
|     Commands: { | ||||
|       new: "Start a new chat", | ||||
| @@ -65,6 +66,7 @@ const en: LocaleType = { | ||||
|       Masks: "Masks", | ||||
|       Clear: "Clear Context", | ||||
|       Settings: "Settings", | ||||
|       UploadImage: "Upload Images", | ||||
|     }, | ||||
|     Rename: "Rename Chat", | ||||
|     Typing: "Typing…", | ||||
| @@ -105,6 +107,10 @@ const en: LocaleType = { | ||||
|       Toast: "Capturing Image...", | ||||
|       Modal: "Long press or right click to save image", | ||||
|     }, | ||||
|     Artifacts: { | ||||
|       Title: "Share Artifacts", | ||||
|       Error: "Share Error", | ||||
|     }, | ||||
|   }, | ||||
|   Select: { | ||||
|     Search: "Search", | ||||
| @@ -130,6 +136,7 @@ const en: LocaleType = { | ||||
|   Settings: { | ||||
|     Title: "Settings", | ||||
|     SubTitle: "All Settings", | ||||
|     ShowPassword: "ShowPassword", | ||||
|     Danger: { | ||||
|       Reset: { | ||||
|         Title: "Reset All Settings", | ||||
| @@ -153,6 +160,12 @@ const en: LocaleType = { | ||||
|       Title: "Font Size", | ||||
|       SubTitle: "Adjust font size of chat content", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "Chat Font Family", | ||||
|       SubTitle: | ||||
|         "Font Family of the chat content, leave empty to apply global default font", | ||||
|       Placeholder: "Font Family Name", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "Inject System Prompts", | ||||
|       SubTitle: "Inject a global system prompt for every request", | ||||
| @@ -295,7 +308,7 @@ const en: LocaleType = { | ||||
|  | ||||
|         Endpoint: { | ||||
|           Title: "OpenAI Endpoint", | ||||
|           SubTitle: "Must starts with http(s):// or use /api/openai as default", | ||||
|           SubTitle: "Must start with http(s):// or use /api/openai as default", | ||||
|         }, | ||||
|       }, | ||||
|       Azure: { | ||||
| @@ -315,6 +328,116 @@ const en: LocaleType = { | ||||
|           SubTitle: "Check your api version from azure console", | ||||
|         }, | ||||
|       }, | ||||
|       Anthropic: { | ||||
|         ApiKey: { | ||||
|           Title: "Anthropic API Key", | ||||
|           SubTitle: | ||||
|             "Use a custom Anthropic Key to bypass password access restrictions", | ||||
|           Placeholder: "Anthropic API Key", | ||||
|         }, | ||||
|  | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "Example: ", | ||||
|         }, | ||||
|  | ||||
|         ApiVerion: { | ||||
|           Title: "API Version (claude api version)", | ||||
|           SubTitle: "Select and input a specific API version", | ||||
|         }, | ||||
|       }, | ||||
|       Baidu: { | ||||
|         ApiKey: { | ||||
|           Title: "Baidu API Key", | ||||
|           SubTitle: "Use a custom Baidu API Key", | ||||
|           Placeholder: "Baidu API Key", | ||||
|         }, | ||||
|         SecretKey: { | ||||
|           Title: "Baidu Secret Key", | ||||
|           SubTitle: "Use a custom Baidu Secret Key", | ||||
|           Placeholder: "Baidu Secret Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "not supported, configure in .env", | ||||
|         }, | ||||
|       }, | ||||
|       Tencent: { | ||||
|         ApiKey: { | ||||
|           Title: "Tencent API Key", | ||||
|           SubTitle: "Use a custom Tencent API Key", | ||||
|           Placeholder: "Tencent API Key", | ||||
|         }, | ||||
|         SecretKey: { | ||||
|           Title: "Tencent Secret Key", | ||||
|           SubTitle: "Use a custom Tencent Secret Key", | ||||
|           Placeholder: "Tencent Secret Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "not supported, configure in .env", | ||||
|         }, | ||||
|       }, | ||||
|       ByteDance: { | ||||
|         ApiKey: { | ||||
|           Title: "ByteDance API Key", | ||||
|           SubTitle: "Use a custom ByteDance API Key", | ||||
|           Placeholder: "ByteDance API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "Example: ", | ||||
|         }, | ||||
|       }, | ||||
|       Alibaba: { | ||||
|         ApiKey: { | ||||
|           Title: "Alibaba API Key", | ||||
|           SubTitle: "Use a custom Alibaba Cloud API Key", | ||||
|           Placeholder: "Alibaba Cloud API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "Example: ", | ||||
|         }, | ||||
|       }, | ||||
|       Moonshot: { | ||||
|         ApiKey: { | ||||
|           Title: "Moonshot API Key", | ||||
|           SubTitle: "Use a custom Moonshot API Key", | ||||
|           Placeholder: "Moonshot API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "Example: ", | ||||
|         }, | ||||
|       }, | ||||
|       Stability: { | ||||
|         ApiKey: { | ||||
|           Title: "Stability API Key", | ||||
|           SubTitle: "Use a custom Stability API Key", | ||||
|           Placeholder: "Stability API Key", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "Example: ", | ||||
|         }, | ||||
|       }, | ||||
|       Iflytek: { | ||||
|         ApiKey: { | ||||
|           Title: "Iflytek API Key", | ||||
|           SubTitle: "Use a Iflytek API Key", | ||||
|           Placeholder: "Iflytek API Key", | ||||
|         }, | ||||
|         ApiSecret: { | ||||
|           Title: "Iflytek API Secret", | ||||
|           SubTitle: "Use a Iflytek API Secret", | ||||
|           Placeholder: "Iflytek API Secret", | ||||
|         }, | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "Example: ", | ||||
|         }, | ||||
|       }, | ||||
|       CustomModel: { | ||||
|         Title: "Custom Models", | ||||
|         SubTitle: "Custom model options, seperated by comma", | ||||
| @@ -322,19 +445,22 @@ const en: LocaleType = { | ||||
|       Google: { | ||||
|         ApiKey: { | ||||
|           Title: "API Key", | ||||
|           SubTitle: | ||||
|             "Bypass password access restrictions using a custom Google AI Studio API Key", | ||||
|           Placeholder: "Google AI Studio API Key", | ||||
|           SubTitle: "Obtain your API Key from Google AI", | ||||
|           Placeholder: "Enter your Google AI Studio API Key", | ||||
|         }, | ||||
|  | ||||
|         Endpoint: { | ||||
|           Title: "Endpoint Address", | ||||
|           SubTitle: "Example:", | ||||
|           SubTitle: "Example: ", | ||||
|         }, | ||||
|  | ||||
|         ApiVerion: { | ||||
|           Title: "API Version (gemini-pro api version)", | ||||
|           SubTitle: "Select a specific part version", | ||||
|         ApiVersion: { | ||||
|           Title: "API Version (specific to gemini-pro)", | ||||
|           SubTitle: "Select a specific API version", | ||||
|         }, | ||||
|         GoogleSafetySettings: { | ||||
|           Title: "Google Safety Settings", | ||||
|           SubTitle: "Select a safety filtering level", | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
| @@ -393,6 +519,10 @@ const en: LocaleType = { | ||||
|   }, | ||||
|   Plugin: { | ||||
|     Name: "Plugin", | ||||
|     Artifacts: "Artifacts", | ||||
|   }, | ||||
|   Discovery: { | ||||
|     Name: "Discovery", | ||||
|   }, | ||||
|   FineTuned: { | ||||
|     Sysmessage: "You are an assistant that", | ||||
| @@ -468,11 +598,65 @@ const en: LocaleType = { | ||||
|     Topic: "Topic", | ||||
|     Time: "Time", | ||||
|   }, | ||||
|  | ||||
|   URLCommand: { | ||||
|     Code: "Detected access code from url, confirm to apply? ", | ||||
|     Settings: "Detected settings from url, confirm to apply?", | ||||
|   }, | ||||
|   SdPanel: { | ||||
|     Prompt: "Prompt", | ||||
|     NegativePrompt: "Negative Prompt", | ||||
|     PleaseInput: (name: string) => `Please input ${name}`, | ||||
|     AspectRatio: "Aspect Ratio", | ||||
|     ImageStyle: "Image Style", | ||||
|     OutFormat: "Output Format", | ||||
|     AIModel: "AI Model", | ||||
|     ModelVersion: "Model Version", | ||||
|     Submit: "Submit", | ||||
|     ParamIsRequired: (name: string) => `${name} is required`, | ||||
|     Styles: { | ||||
|       D3Model: "3d-model", | ||||
|       AnalogFilm: "analog-film", | ||||
|       Anime: "anime", | ||||
|       Cinematic: "cinematic", | ||||
|       ComicBook: "comic-book", | ||||
|       DigitalArt: "digital-art", | ||||
|       Enhance: "enhance", | ||||
|       FantasyArt: "fantasy-art", | ||||
|       Isometric: "isometric", | ||||
|       LineArt: "line-art", | ||||
|       LowPoly: "low-poly", | ||||
|       ModelingCompound: "modeling-compound", | ||||
|       NeonPunk: "neon-punk", | ||||
|       Origami: "origami", | ||||
|       Photographic: "photographic", | ||||
|       PixelArt: "pixel-art", | ||||
|       TileTexture: "tile-texture", | ||||
|     }, | ||||
|   }, | ||||
|   Sd: { | ||||
|     SubTitle: (count: number) => `${count} images`, | ||||
|     Actions: { | ||||
|       Params: "See Params", | ||||
|       Copy: "Copy Prompt", | ||||
|       Delete: "Delete", | ||||
|       Retry: "Retry", | ||||
|       ReturnHome: "Return Home", | ||||
|       History: "History", | ||||
|     }, | ||||
|     EmptyRecord: "No images yet", | ||||
|     Status: { | ||||
|       Name: "Status", | ||||
|       Success: "Success", | ||||
|       Error: "Error", | ||||
|       Wait: "Waiting", | ||||
|       Running: "Running", | ||||
|     }, | ||||
|     Danger: { | ||||
|       Delete: "Confirm to delete?", | ||||
|     }, | ||||
|     GenerateParams: "Generate Params", | ||||
|     Detail: "Detail", | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default en; | ||||
|   | ||||
| @@ -71,6 +71,12 @@ const es: PartialLocaleType = { | ||||
|       Title: "Tamaño de fuente", | ||||
|       SubTitle: "Ajustar el tamaño de fuente del contenido del chat", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "Fuente del Chat", | ||||
|       SubTitle: | ||||
|         "Fuente del contenido del chat, dejar vacío para aplicar la fuente predeterminada global", | ||||
|       Placeholder: "Nombre de la Fuente", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "Inyectar Prompts del Sistema", | ||||
|       SubTitle: | ||||
|   | ||||
| @@ -111,6 +111,12 @@ const fr: PartialLocaleType = { | ||||
|       Title: "Taille des polices", | ||||
|       SubTitle: "Ajuste la taille de police du contenu de la conversation", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "Police de Chat", | ||||
|       SubTitle: | ||||
|         "Police du contenu du chat, laissez vide pour appliquer la police par défaut globale", | ||||
|       Placeholder: "Nom de la Police", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "Injecter des invites système", | ||||
|       SubTitle: | ||||
|   | ||||
| @@ -140,6 +140,12 @@ const id: PartialLocaleType = { | ||||
|       Title: "Ukuran Font", | ||||
|       SubTitle: "Ubah ukuran font konten chat", | ||||
|     }, | ||||
|     FontFamily: { | ||||
|       Title: "Font Obrolan", | ||||
|       SubTitle: | ||||
|         "Font dari konten obrolan, biarkan kosong untuk menerapkan font default global", | ||||
|       Placeholder: "Nama Font", | ||||
|     }, | ||||
|     InjectSystemPrompts: { | ||||
|       Title: "Suntikkan Petunjuk Sistem", | ||||
|       SubTitle: | ||||
| @@ -369,8 +375,8 @@ const id: PartialLocaleType = { | ||||
|   }, | ||||
|   Exporter: { | ||||
|     Description: { | ||||
|       Title: "Hanya pesan setelah menghapus konteks yang akan ditampilkan" | ||||
|     },   | ||||
|       Title: "Hanya pesan setelah menghapus konteks yang akan ditampilkan", | ||||
|     }, | ||||
|     Model: "Model", | ||||
|     Messages: "Pesan", | ||||
|     Topic: "Topik", | ||||
|   | ||||