Compare commits
1677 Commits
server/45-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a8f2dda3 | ||
|
|
731aa9725a | ||
|
|
1dd489100d | ||
|
|
3f2b18d18d | ||
|
|
398b2ec83c | ||
|
|
bbd0795fdc | ||
|
|
7d67bd1059 | ||
|
|
aebc7edecb | ||
|
|
bcff82aeca | ||
|
|
2fee4a4a8c | ||
|
|
e225e06f0b | ||
|
|
74ea0ca9fc | ||
|
|
ab306e298b | ||
|
|
65e9b26ad2 | ||
|
|
6b2c2c7b42 | ||
|
|
0a17a2842c | ||
|
|
0a1026c237 | ||
|
|
9438cf2f36 | ||
|
|
e1e7e0ccd4 | ||
|
|
8fb4873eac | ||
|
|
5decd202a1 | ||
|
|
2ad794d6e1 | ||
|
|
27736f0a5d | ||
|
|
96c4b76250 | ||
|
|
578f97b248 | ||
|
|
c962fff811 | ||
|
|
4c6ce023eb | ||
|
|
658c3345f3 | ||
|
|
4f43c7a8c7 | ||
|
|
93158e0b7b | ||
|
|
a326cf6935 | ||
|
|
2676d9a96f | ||
|
|
82088274be | ||
|
|
f9ded76a38 | ||
|
|
4a1536515e | ||
|
|
725a93331a | ||
|
|
f414150e73 | ||
|
|
311d4a0ec3 | ||
|
|
30b7ae168f | ||
|
|
54cd02d09d | ||
|
|
f1b769490a | ||
|
|
7708b66e8d | ||
|
|
2f00645cd4 | ||
|
|
a68f9832ed | ||
|
|
c2fcf11280 | ||
|
|
ee5b5d146d | ||
|
|
c6c7c703b5 | ||
|
|
233c7b3308 | ||
|
|
d467694aa7 | ||
|
|
98c8dd20ee | ||
|
|
31567af44e | ||
|
|
d3a6497672 | ||
|
|
e3180c23ac | ||
|
|
fb8d8232dc | ||
|
|
21ba23c4ab | ||
|
|
07aef767b9 | ||
|
|
a2c9404a3d | ||
|
|
0397bf3a88 | ||
|
|
8e89bb179e | ||
|
|
016076015e | ||
|
|
43fd3d258a | ||
|
|
1a9888d825 | ||
|
|
2c81486862 | ||
|
|
b5b5398b30 | ||
|
|
cc113155dd | ||
|
|
5565829c65 | ||
|
|
d84f3dc7d8 | ||
|
|
e40c351f80 | ||
|
|
dc7f58d940 | ||
|
|
46f1d97593 | ||
|
|
3b3f9e71ed | ||
|
|
9c9e462682 | ||
|
|
4c21fd4844 | ||
|
|
623ab0f8b5 | ||
|
|
bbe27bd555 | ||
|
|
d93b2e8ea7 | ||
|
|
7435e7b0f0 | ||
|
|
08d2503e11 | ||
|
|
240352362a | ||
|
|
f65b9e0472 | ||
|
|
11deb3bba6 | ||
|
|
de8b7670b2 | ||
|
|
80d05e177a | ||
|
|
c889bfc381 | ||
|
|
62c686a26b | ||
|
|
3096eb6752 | ||
|
|
974a4b1b59 | ||
|
|
6ccf04a786 | ||
|
|
1a91b8ada9 | ||
|
|
75cecdfcad | ||
|
|
758be188cf | ||
|
|
fd47e0ae61 | ||
|
|
e8d1aa25aa | ||
|
|
31cc75823c | ||
|
|
3c1b84ccd0 | ||
|
|
abc6a7266a | ||
|
|
1fd9be49c7 | ||
|
|
452effecbc | ||
|
|
f52ab780f1 | ||
|
|
80e9771790 | ||
|
|
1b70c1d678 | ||
|
|
5a7121dceb | ||
|
|
ede6f080a2 | ||
|
|
5a16072a3d | ||
|
|
8e88f02214 | ||
|
|
a9390f0abc | ||
|
|
43837e5048 | ||
|
|
8119219c15 | ||
|
|
752d1f99d2 | ||
|
|
08d8a1d4af | ||
|
|
beb6ff4f04 | ||
|
|
42bd2827a6 | ||
|
|
2484b5e5a4 | ||
|
|
c6cabe5296 | ||
|
|
59c57e7cf8 | ||
|
|
6ca4239f16 | ||
|
|
ea27d83ea9 | ||
|
|
00cbfce56d | ||
|
|
f29433a91b | ||
|
|
392c5b2a00 | ||
|
|
46e5135592 | ||
|
|
ae76c45e27 | ||
|
|
e308142afd | ||
|
|
96f2999d66 | ||
|
|
663cb7c67a | ||
|
|
9cf84c324e | ||
|
|
07ae16d0ed | ||
|
|
62a6a4a7f0 | ||
|
|
47067bf278 | ||
|
|
cb47ebf568 | ||
|
|
aedf590e1d | ||
|
|
7ccbc87afd | ||
|
|
f832e08293 | ||
|
|
603163d6ec | ||
|
|
e820df7eb6 | ||
|
|
64dec38ff3 | ||
|
|
cf1091fc2c | ||
|
|
4f5dbb4589 | ||
|
|
cc6c4b547c | ||
|
|
16bfd0bd38 | ||
|
|
ab657330ea | ||
|
|
3d35858c05 | ||
|
|
4c1b97f210 | ||
|
|
98dddbcc2e | ||
|
|
28c817429a | ||
|
|
9efb9e8237 | ||
|
|
5260502507 | ||
|
|
40ff3ca851 | ||
|
|
c3faeba1c8 | ||
|
|
4bf6ec2b38 | ||
|
|
279121615b | ||
|
|
73e00b4d6a | ||
|
|
22069ada66 | ||
|
|
7bb1f06b11 | ||
|
|
af3d17f4a2 | ||
|
|
12ae15ed3a | ||
|
|
5636b2789e | ||
|
|
2ce64dd49b | ||
|
|
363bd1d2fe | ||
|
|
748273d917 | ||
|
|
103ca18031 | ||
|
|
78f14edbfc | ||
|
|
6c83826e1d | ||
|
|
b50d3eb295 | ||
|
|
0d67e2935c | ||
|
|
988e1fefed | ||
|
|
c044ee2c40 | ||
|
|
04c08ff256 | ||
|
|
eca1c70edb | ||
|
|
48134a1bbe | ||
|
|
3252cbd2c8 | ||
|
|
c52cf9aa90 | ||
|
|
adbbe4442a | ||
|
|
65f5a13f13 | ||
|
|
8151e57661 | ||
|
|
a5995ca0c4 | ||
|
|
72b1f8f1cf | ||
|
|
c9ed662809 | ||
|
|
22f314f6ad | ||
|
|
2c318360b3 | ||
|
|
accaaad8f1 | ||
|
|
2398930ecb | ||
|
|
ea3ee3a5d7 | ||
|
|
e1f514536b | ||
|
|
b49519ec56 | ||
|
|
9575873efb | ||
|
|
81a0b29633 | ||
|
|
6fe69de8de | ||
|
|
2b875847dd | ||
|
|
3aa031a4e4 | ||
|
|
d00eb6d0e4 | ||
|
|
ff71e3935c | ||
|
|
7ed0f08269 | ||
|
|
05b21707c1 | ||
|
|
7ac4a25a5f | ||
|
|
8acf1f60ec | ||
|
|
66cdf6d3d1 | ||
|
|
91ce0ff5f8 | ||
|
|
607491fc9e | ||
|
|
fef8947e73 | ||
|
|
fd5b483bad | ||
|
|
778fc1c0b8 | ||
|
|
2bbdd0aa61 | ||
|
|
d48e26fe9a | ||
|
|
77531aa7b5 | ||
|
|
599abc5021 | ||
|
|
e0c277c76a | ||
|
|
d69346de42 | ||
|
|
2cb6f51a63 | ||
|
|
8fad43c597 | ||
|
|
0add599427 | ||
|
|
e67aab87c5 | ||
|
|
67310e9ae5 | ||
|
|
7b73a9c7f5 | ||
|
|
17f5485522 | ||
|
|
30eef41dc4 | ||
|
|
0405113d52 | ||
|
|
134411f7f8 | ||
|
|
6b25171ce1 | ||
|
|
6c89df0f8b | ||
|
|
ebfc7589a9 | ||
|
|
6bc43ffc3f | ||
|
|
f692425607 | ||
|
|
a03ea2cb47 | ||
|
|
16f23b3f7c | ||
|
|
51283b81fb | ||
|
|
dab44f3825 | ||
|
|
e2e373ce08 | ||
|
|
574c259812 | ||
|
|
4ac4ac26cf | ||
|
|
654829c4fd | ||
|
|
5923149dd6 | ||
|
|
f7676716bb | ||
|
|
2e4cb0cf73 | ||
|
|
b17779e034 | ||
|
|
8c719c44ac | ||
|
|
c17de9def5 | ||
|
|
8c159a3828 | ||
|
|
6a2b928080 | ||
|
|
4b9f3a25c9 | ||
|
|
eab3f0372f | ||
|
|
f88ada378e | ||
|
|
8f679a100c | ||
|
|
610d2aed9b | ||
|
|
7e1fd15ebd | ||
|
|
15f9e1910a | ||
|
|
2d6289f0fa | ||
|
|
ee7d104734 | ||
|
|
1781977486 | ||
|
|
57a4840df2 | ||
|
|
690d04ec71 | ||
|
|
77174f4e21 | ||
|
|
819bb92488 | ||
|
|
d94f6dea8c | ||
|
|
aad838a8e0 | ||
|
|
00a6ac08df | ||
|
|
fb9f0541fa | ||
|
|
831ce10a5b | ||
|
|
c79da3e1d0 | ||
|
|
7640e6b5ca | ||
|
|
6694846c58 | ||
|
|
f7a5accb61 | ||
|
|
f6faf6bb44 | ||
|
|
26aa4fb12f | ||
|
|
deee79c5c2 | ||
|
|
fa5212a33e | ||
|
|
14e75197cf | ||
|
|
c3d8c5d56c | ||
|
|
c0f376c4cf | ||
|
|
28080924f8 | ||
|
|
0f27bf1b10 | ||
|
|
acdc197b7e | ||
|
|
20693db8b9 | ||
|
|
729ccec2f8 | ||
|
|
5262b085c2 | ||
|
|
f16c54c3a9 | ||
|
|
4ab60cc7a3 | ||
|
|
c9b6df55b0 | ||
|
|
a3a69fed43 | ||
|
|
dde159ea7c | ||
|
|
290f36b44e | ||
|
|
8e2c6fea1b | ||
|
|
4d4507489f | ||
|
|
d724a542bb | ||
|
|
2ae5ceb234 | ||
|
|
19c4367fb8 | ||
|
|
fa7d9d81ff | ||
|
|
970bd89638 | ||
|
|
739dc091ab | ||
|
|
50a59b82af | ||
|
|
9ee2d2b50b | ||
|
|
40fa7b1e26 | ||
|
|
1a60671255 | ||
|
|
28d73fd042 | ||
|
|
fb11a4becd | ||
|
|
fec71038c5 | ||
|
|
f483fab33a | ||
|
|
38961de760 | ||
|
|
56d319af66 | ||
|
|
77b25662b4 | ||
|
|
83e911f698 | ||
|
|
2a8b303508 | ||
|
|
2e167d38f0 | ||
|
|
254bd08345 | ||
|
|
87f85e78c5 | ||
|
|
04f440574b | ||
|
|
392a03a2ed | ||
|
|
ae176cba44 | ||
|
|
d8e2d65186 | ||
|
|
360cba0873 | ||
|
|
6baeb57cde | ||
|
|
48a0f2ee73 | ||
|
|
53be242e89 | ||
|
|
b9e1671777 | ||
|
|
b4db755c45 | ||
|
|
026164f86d | ||
|
|
bb78cb5d4a | ||
|
|
88878c3307 | ||
|
|
5ec43fa8a3 | ||
|
|
01e17361c8 | ||
|
|
0a591456c4 | ||
|
|
5a4e087b3d | ||
|
|
8ecece64e1 | ||
|
|
2875b02d2a | ||
|
|
27b0098bf9 | ||
|
|
ea52d65971 | ||
|
|
280c356ed2 | ||
|
|
ce4e056f20 | ||
|
|
4db7cc9ada | ||
|
|
d3cfb4a6b7 | ||
|
|
dc499d0d6f | ||
|
|
7e98f42462 | ||
|
|
d058ff32e6 | ||
|
|
84d843a8a4 | ||
|
|
cf85caeb5a | ||
|
|
39ee88bd12 | ||
|
|
97720a55e5 | ||
|
|
e0e57c6865 | ||
|
|
18035d4272 | ||
|
|
4f5a986162 | ||
|
|
273379b18d | ||
|
|
ec1bdb7396 | ||
|
|
c698901509 | ||
|
|
469ae7b02d | ||
|
|
b21b4cdea0 | ||
|
|
c789dd7a69 | ||
|
|
2ca0c54de2 | ||
|
|
8c0d4a0930 | ||
|
|
c4e4981a24 | ||
|
|
c38003a382 | ||
|
|
0bab5d1c8e | ||
|
|
da7fbb1284 | ||
|
|
0904fdf98a | ||
|
|
d1231365d0 | ||
|
|
71fd765ebc | ||
|
|
ed63eb77b7 | ||
|
|
90a9edcd56 | ||
|
|
89fca5d654 | ||
|
|
11e6aca724 | ||
|
|
56240d4a6d | ||
|
|
7c8ae2fe8b | ||
|
|
2f312a01df | ||
|
|
7152435a3d | ||
|
|
98e987e75b | ||
|
|
956f9e0fed | ||
|
|
1ae9b5a89b | ||
|
|
0515fd3e46 | ||
|
|
87d2960713 | ||
|
|
a8c5c14656 | ||
|
|
ee403bff11 | ||
|
|
2cccac34ff | ||
|
|
291597a69f | ||
|
|
f5a318beef | ||
|
|
b3b4fa8ed2 | ||
|
|
ffd13c0f31 | ||
|
|
6f0fb9fe4b | ||
|
|
22b007c2d9 | ||
|
|
2ad8bc0073 | ||
|
|
fb4ef202d8 | ||
|
|
354fa98477 | ||
|
|
183d47fba0 | ||
|
|
ba1bb165c3 | ||
|
|
cd19fe0b8d | ||
|
|
62e3bc4611 | ||
|
|
2241e0f27e | ||
|
|
08b439b7f6 | ||
|
|
bf8197d884 | ||
|
|
c0ddd420ba | ||
|
|
0ec99e8ffb | ||
|
|
d44a5d4060 | ||
|
|
8fd93bfd29 | ||
|
|
0c919e851a | ||
|
|
f5a409fdde | ||
|
|
69f2f7957d | ||
|
|
e7f3df1f5d | ||
|
|
6e5c608df8 | ||
|
|
e5ddc416e9 | ||
|
|
672139e35a | ||
|
|
91fe35e3b6 | ||
|
|
7aca7a764d | ||
|
|
b87b44c7b4 | ||
|
|
e7cd3d6e73 | ||
|
|
d001dba00c | ||
|
|
d9f63bd00c | ||
|
|
5f5c19f313 | ||
|
|
0a792c7e26 | ||
|
|
8a9fba7eab | ||
|
|
02772bc343 | ||
|
|
164d9f97e7 | ||
|
|
12c93e1154 | ||
|
|
16a6134c77 | ||
|
|
8ded477edd | ||
|
|
be0b398e5c | ||
|
|
d37cb6cffc | ||
|
|
36d575b825 | ||
|
|
471eb3f650 | ||
|
|
d519ac3dde | ||
|
|
3b00c37d30 | ||
|
|
619cc2bf5b | ||
|
|
1d0af8c2d8 | ||
|
|
e76c983e64 | ||
|
|
98eef59d97 | ||
|
|
e3f5b9720e | ||
|
|
d8af4bb48d | ||
|
|
80f6827375 | ||
|
|
045e36b242 | ||
|
|
4d6e218e98 | ||
|
|
95354eb78d | ||
|
|
b91c40f6df | ||
|
|
934bc56202 | ||
|
|
e02dff0b06 | ||
|
|
186e2cefc2 | ||
|
|
0100f58891 | ||
|
|
acaae5fd1b | ||
|
|
270c9fb61d | ||
|
|
948bed1737 | ||
|
|
28c5965c52 | ||
|
|
4de04234d0 | ||
|
|
754209ee53 | ||
|
|
6f8c939be2 | ||
|
|
f0e17dfffe | ||
|
|
c7e4553c45 | ||
|
|
d10d638119 | ||
|
|
cc80bd5dac | ||
|
|
cd90c8074c | ||
|
|
57f4d08c31 | ||
|
|
05387d08f3 | ||
|
|
41522cc481 | ||
|
|
1aad39b588 | ||
|
|
560491aabb | ||
|
|
91a855c041 | ||
|
|
07d081f838 | ||
|
|
5973359197 | ||
|
|
dcd005d369 | ||
|
|
ed2c64d2a6 | ||
|
|
9b94aae898 | ||
|
|
7a58cdd12b | ||
|
|
ddfe911812 | ||
|
|
1af8a29b58 | ||
|
|
fa10115043 | ||
|
|
d5fed4f2da | ||
|
|
fb4915e657 | ||
|
|
47f996d81a | ||
|
|
514dcbb21f | ||
|
|
cd8f23e4b3 | ||
|
|
16f734e9c5 | ||
|
|
deacac6e8f | ||
|
|
96827c760c | ||
|
|
4588850572 | ||
|
|
0c95829b1e | ||
|
|
0afa404b19 | ||
|
|
9151058d79 | ||
|
|
d4936a8ca4 | ||
|
|
3f8219f415 | ||
|
|
f2ed508cb4 | ||
|
|
8a2483ad7d | ||
|
|
67dbeeab1a | ||
|
|
cd0037dc47 | ||
|
|
d5ac630d01 | ||
|
|
64ba6a6f29 | ||
|
|
c82223f8f4 | ||
|
|
3524a4ea16 | ||
|
|
23b6855b4b | ||
|
|
eec59f51fc | ||
|
|
8c6bd2ad03 | ||
|
|
d548e31063 | ||
|
|
d434485987 | ||
|
|
43e01e7613 | ||
|
|
fae9d32ffb | ||
|
|
020a74a6a8 | ||
|
|
652bc75ed0 | ||
|
|
5d5e10c83e | ||
|
|
7c2e31cbe5 | ||
|
|
ac0456eb97 | ||
|
|
1669f4e2a4 | ||
|
|
f23fae7676 | ||
|
|
d9bb014742 | ||
|
|
a752737327 | ||
|
|
d93a0d06d5 | ||
|
|
89b4a82e62 | ||
|
|
8913daa2b0 | ||
|
|
76229dac7b | ||
|
|
3760f7ff44 | ||
|
|
4d65ba0b79 | ||
|
|
733d6338c2 | ||
|
|
6d66d126c8 | ||
|
|
6d5604a84b | ||
|
|
cb44b2237f | ||
|
|
2e386cb931 | ||
|
|
a1a095fe0e | ||
|
|
0f74cbd6ff | ||
|
|
c15a68385a | ||
|
|
97563450a6 | ||
|
|
d3c921a489 | ||
|
|
e0dc5a3d77 | ||
|
|
cf879c63b6 | ||
|
|
06834699be | ||
|
|
6e1d512021 | ||
|
|
b7ff4e68eb | ||
|
|
914e09af73 | ||
|
|
c033e75ba1 | ||
|
|
5648ad12be | ||
|
|
1d7c4816b0 | ||
|
|
9e6cf69b45 | ||
|
|
75cfa4c00f | ||
|
|
b78c77a790 | ||
|
|
3a2a7f2490 | ||
|
|
1e41335ebb | ||
|
|
38dba2b1bc | ||
|
|
e319b16f48 | ||
|
|
763696e8d0 | ||
|
|
ef0f9bc93d | ||
|
|
e73c2da84e | ||
|
|
38435f9401 | ||
|
|
50aa93d5f3 | ||
|
|
0bb134d3b8 | ||
|
|
8ee7eb1f0a | ||
|
|
c0550988e0 | ||
|
|
909f1c506c | ||
|
|
16ebbeb6ac | ||
|
|
de457b5dc3 | ||
|
|
ac16dc5e64 | ||
|
|
7f3ce72468 | ||
|
|
084a6225e7 | ||
|
|
183f07be25 | ||
|
|
dfb8971bcb | ||
|
|
41acb006cf | ||
|
|
2945528f44 | ||
|
|
ad6b09d1b2 | ||
|
|
5e82adb7eb | ||
|
|
3c7c122b2f | ||
|
|
bcea56c0d9 | ||
|
|
7d99330eb9 | ||
|
|
640e1b6aa6 | ||
|
|
6041442662 | ||
|
|
fa9f09e84e | ||
|
|
6d8aa557bf | ||
|
|
e1685b517a | ||
|
|
9bc887eee1 | ||
|
|
df0daf4d27 | ||
|
|
12b21d6f3b | ||
|
|
d64d3c9deb | ||
|
|
088d2c1b74 | ||
|
|
bf8410a6d4 | ||
|
|
aaf3e08dd8 | ||
|
|
47c7da3a1f | ||
|
|
d34e6c8344 | ||
|
|
c2ef33753a | ||
|
|
c528e22680 | ||
|
|
038730a24b | ||
|
|
2b96fdb3e7 | ||
|
|
f7d18f20db | ||
|
|
b032774e8e | ||
|
|
25df2d248b | ||
|
|
7fddbe68bc | ||
|
|
8519aa70e7 | ||
|
|
072fac14cc | ||
|
|
ef89ab1aeb | ||
|
|
39b4bd5983 | ||
|
|
9e8817f89e | ||
|
|
eb2153818d | ||
|
|
dd65ef46a7 | ||
|
|
5b2418701f | ||
|
|
e5bcf6f403 | ||
|
|
7959dd6b3a | ||
|
|
2fbcd45035 | ||
|
|
317593a72d | ||
|
|
e6d3b20308 | ||
|
|
1ad52fd3c3 | ||
|
|
618bc2bd97 | ||
|
|
eac5bb9296 | ||
|
|
4f06ff7151 | ||
|
|
52cd365925 | ||
|
|
0b1a3a715b | ||
|
|
c0e16a354f | ||
|
|
b6296d4022 | ||
|
|
1ddb7928c6 | ||
|
|
e22b6f11b9 | ||
|
|
baa924c8d4 | ||
|
|
43769c5b58 | ||
|
|
72e4b926d9 | ||
|
|
f29a26b43f | ||
|
|
07854d7c6a | ||
|
|
74ae88edbb | ||
|
|
af63ebba9e | ||
|
|
25af7e5526 | ||
|
|
9fae6c6a62 | ||
|
|
1f1f3fd276 | ||
|
|
8ec27aa225 | ||
|
|
c88cf6146b | ||
|
|
2807dcf9e3 | ||
|
|
553e5b5373 | ||
|
|
2f6d0498e2 | ||
|
|
c631f906b1 | ||
|
|
71dcd5026b | ||
|
|
65aac6c14d | ||
|
|
95d4bccceb | ||
|
|
ffed16d802 | ||
|
|
fdff0c2865 | ||
|
|
010d7d1be5 | ||
|
|
801d2c4644 | ||
|
|
fe18aa0000 | ||
|
|
5e0464c522 | ||
|
|
85930dc1a6 | ||
|
|
898ed98f1a | ||
|
|
85b28885a4 | ||
|
|
e5838f480a | ||
|
|
cf21c80d24 | ||
|
|
a420e4cf33 | ||
|
|
a939e000cf | ||
|
|
382faf6c6d | ||
|
|
4eac5cf0c0 | ||
|
|
a265a77095 | ||
|
|
748c468013 | ||
|
|
9e52e1844f | ||
|
|
686111628e | ||
|
|
1b79f788b5 | ||
|
|
d24eedc578 | ||
|
|
7963a916cb | ||
|
|
88afa0551c | ||
|
|
03aee55cb6 | ||
|
|
016d588559 | ||
|
|
edae2f83c2 | ||
|
|
1947aae685 | ||
|
|
36654a6fa3 | ||
|
|
1aaf904fd3 | ||
|
|
04972684c3 | ||
|
|
f80b3e8117 | ||
|
|
86c09ae375 | ||
|
|
4fe91d442e | ||
|
|
6523ea740c | ||
|
|
89b0eaaa3d | ||
|
|
29033b5757 | ||
|
|
f02d18702a | ||
|
|
6d7f869e56 | ||
|
|
482e1ef291 | ||
|
|
8db1afdbc0 | ||
|
|
79751743f2 | ||
|
|
aebf5e86cc | ||
|
|
23d284b994 | ||
|
|
eb15c7a33b | ||
|
|
aa6c1f72a6 | ||
|
|
4a3342c795 | ||
|
|
6b90b25d5c | ||
|
|
74adb68d16 | ||
|
|
09efe47f17 | ||
|
|
7c398cb4b1 | ||
|
|
cd817a6336 | ||
|
|
609700ab54 | ||
|
|
56e68ff5d0 | ||
|
|
37e48b7878 | ||
|
|
0ba1ea521f | ||
|
|
da9c8671fe | ||
|
|
3d7147d242 | ||
|
|
6a9beb418a | ||
|
|
5a04304ea9 | ||
|
|
663ff7b41c | ||
|
|
e2111add87 | ||
|
|
8943e2900b | ||
|
|
c795e00d63 | ||
|
|
f34344d53b | ||
|
|
1a194b5527 | ||
|
|
14dbe3efeb | ||
|
|
4a36630b57 | ||
|
|
8ca072d902 | ||
|
|
e358e0241c | ||
|
|
1598722ad0 | ||
|
|
6674c22cdd | ||
|
|
74f2d052ac | ||
|
|
c8073c6c46 | ||
|
|
faa70a315c | ||
|
|
9da001e3ee | ||
|
|
b12d61b371 | ||
|
|
800eb10ba4 | ||
|
|
37e2b66725 | ||
|
|
0dcc759c80 | ||
|
|
0808f34021 | ||
|
|
2ea6f2e800 | ||
|
|
7ac5bdf44f | ||
|
|
87f23a0ab6 | ||
|
|
fb240616e0 | ||
|
|
ce7c2bff35 | ||
|
|
a154a28a4a | ||
|
|
1eddc7e296 | ||
|
|
4e6f47d35f | ||
|
|
91b1d868d4 | ||
|
|
a17d2da133 | ||
|
|
e126355b26 | ||
|
|
1a97f56169 | ||
|
|
29930f3c14 | ||
|
|
5aab184a97 | ||
|
|
5e896b8e56 | ||
|
|
6f4a881238 | ||
|
|
19dc43049d | ||
|
|
0b9d41e7e2 | ||
|
|
3e2261b1c7 | ||
|
|
fa4b4183cf | ||
|
|
537a9d10c2 | ||
|
|
37f6ef7e8e | ||
|
|
ea970888f8 | ||
|
|
70cf96afaf | ||
|
|
51e8393f95 | ||
|
|
5a7f8bc1f1 | ||
|
|
f69e2d07a2 | ||
|
|
caf9eb7290 | ||
|
|
ea1bc4c78b | ||
|
|
61bb42a266 | ||
|
|
08323571aa | ||
|
|
a78fd8a0d4 | ||
|
|
87be5499d5 | ||
|
|
c247093ec9 | ||
|
|
cffed9ab50 | ||
|
|
c00b9e333d | ||
|
|
5461cff1db | ||
|
|
db1fc8c3df | ||
|
|
4d2093ef44 | ||
|
|
0baa13f7de | ||
|
|
5d7e8ce773 | ||
|
|
d82a47c035 | ||
|
|
e2cf152662 | ||
|
|
0a001fc5f4 | ||
|
|
720652b68a | ||
|
|
00ae2d4e59 | ||
|
|
a3c291a1e0 | ||
|
|
efc77160c2 | ||
|
|
e8796c74a6 | ||
|
|
531a23d15f | ||
|
|
291eb9b11c | ||
|
|
1c8a709670 | ||
|
|
25a1a005a4 | ||
|
|
903cf5459e | ||
|
|
57261dad71 | ||
|
|
b1dc367bcf | ||
|
|
afc41c5a42 | ||
|
|
40190a6034 | ||
|
|
1c7d005088 | ||
|
|
de91bd396f | ||
|
|
163497630d | ||
|
|
fec4b9d5aa | ||
|
|
f31db01b8e | ||
|
|
9adb55c6a7 | ||
|
|
e160d7f217 | ||
|
|
ba46f97f3d | ||
|
|
180ee864d9 | ||
|
|
0861a0d87e | ||
|
|
1abbd740a3 | ||
|
|
1307095960 | ||
|
|
aea1d84c88 | ||
|
|
155539693c | ||
|
|
fbfe3256c8 | ||
|
|
de70a23ff6 | ||
|
|
ffccdecc90 | ||
|
|
7fd3feb77c | ||
|
|
aa63415b1c | ||
|
|
3b7eb4a539 | ||
|
|
fc04267f74 | ||
|
|
033b3c298d | ||
|
|
1487531b8c | ||
|
|
1cf4cf3ac0 | ||
|
|
edcbddb0fa | ||
|
|
a15a26ba99 | ||
|
|
4f1e0bc60a | ||
|
|
dfd55a136a | ||
|
|
5ccd392b75 | ||
|
|
38cc12da8e | ||
|
|
4d86b15781 | ||
|
|
33698bbc9c | ||
|
|
2eba3d8b5e | ||
|
|
4941f09dfa | ||
|
|
d2e282030b | ||
|
|
85981acb8d | ||
|
|
4a976bb749 | ||
|
|
befbc8e205 | ||
|
|
29ef366709 | ||
|
|
7bcafa50a0 | ||
|
|
76bdaa1d1a | ||
|
|
e653eaae0f | ||
|
|
f569261359 | ||
|
|
a223d931a6 | ||
|
|
44660bf7e3 | ||
|
|
4463bf1fc7 | ||
|
|
58f6f5f249 | ||
|
|
f4d272c7ff | ||
|
|
37ed4b83b2 | ||
|
|
8e91c00c74 | ||
|
|
36f3de8321 | ||
|
|
0768f84802 | ||
|
|
574e4e4c70 | ||
|
|
7859fa08d4 | ||
|
|
543a99bf4a | ||
|
|
a2ed0467d4 | ||
|
|
ced001ade2 | ||
|
|
503e2d1d54 | ||
|
|
d9d9760405 | ||
|
|
3c1fc15039 | ||
|
|
06e85f8b0f | ||
|
|
8e9669c748 | ||
|
|
499fceb689 | ||
|
|
c7966298dc | ||
|
|
ceb8a1f8cb | ||
|
|
2c5a70c3b3 | ||
|
|
8b8c39a495 | ||
|
|
a3d7ea91b0 | ||
|
|
e2bdecbbbf | ||
|
|
005b9210c8 | ||
|
|
f528d33728 | ||
|
|
ad9985c36f | ||
|
|
0692d5722c | ||
|
|
8a50d2ef3e | ||
|
|
f9ce7f713b | ||
|
|
d54fbef442 | ||
|
|
cb3de4f92f | ||
|
|
f6d908f050 | ||
|
|
869d8b2ce8 | ||
|
|
15a08e6d65 | ||
|
|
e211e41c25 | ||
|
|
9328efa701 | ||
|
|
f4214bf3a9 | ||
|
|
c24ccebb18 | ||
|
|
c9d3a3964f | ||
|
|
9c450a3d07 | ||
|
|
ccf316630b | ||
|
|
9e8aa5a543 | ||
|
|
a1d59e0db8 | ||
|
|
e1e53713b5 | ||
|
|
303535b22a | ||
|
|
e5224ff089 | ||
|
|
593b7e3df0 | ||
|
|
9821832364 | ||
|
|
6a19c43094 | ||
|
|
d05eabe258 | ||
|
|
31cccf87a1 | ||
|
|
63bafd278b | ||
|
|
db4d0943bf | ||
|
|
ef837f9985 | ||
|
|
0f058207bb | ||
|
|
a8f5dff6f8 | ||
|
|
36412dee83 | ||
|
|
2bcb975606 | ||
|
|
935f07d6ec | ||
|
|
0950e6827b | ||
|
|
f0a7cbe2ac | ||
|
|
cc52f414af | ||
|
|
f2d4f9e698 | ||
|
|
33bfcadf97 | ||
|
|
c42dc88ba1 | ||
|
|
fadcbbc206 | ||
|
|
13a6e3df83 | ||
|
|
91a98083a1 | ||
|
|
d89040e9f1 | ||
|
|
b30b093f54 | ||
|
|
309d8281ca | ||
|
|
c79724eefa | ||
|
|
022eee7b95 | ||
|
|
286acefe4a | ||
|
|
501f5cf0d8 | ||
|
|
104187e2fe | ||
|
|
bac7d6f196 | ||
|
|
1db4444c7b | ||
|
|
77ab711d41 | ||
|
|
1365a22d5b | ||
|
|
83d16bd6b6 | ||
|
|
b17010bb35 | ||
|
|
939138a02a | ||
|
|
09800c313f | ||
|
|
c20dc8d3a1 | ||
|
|
b40fe24b4a | ||
|
|
161126bd4c | ||
|
|
446b87443c | ||
|
|
026bfd0615 | ||
|
|
bfa072b5b8 | ||
|
|
f1ab6b6255 | ||
|
|
4cc0977c9f | ||
|
|
981a01a191 | ||
|
|
dd2b06f10f | ||
|
|
48f222998b | ||
|
|
6e6acea09e | ||
|
|
f42554e82a | ||
|
|
d9dea4eb75 | ||
|
|
9ebac8b079 | ||
|
|
87a1514a0f | ||
|
|
852498b6bb | ||
|
|
9ddae7423e | ||
|
|
15bc64984d | ||
|
|
b5c7eeac5a | ||
|
|
9cbbc7f065 | ||
|
|
862c0f2937 | ||
|
|
50890de38d | ||
|
|
c1b03278ca | ||
|
|
2070cae477 | ||
|
|
007c1a4138 | ||
|
|
6750e85ad4 | ||
|
|
adac18ba86 | ||
|
|
dce4e6c68a | ||
|
|
fc73057935 | ||
|
|
ccb57c095a | ||
|
|
d066a76f5a | ||
|
|
279903ebb9 | ||
|
|
cad16e70ba | ||
|
|
b58e0f945e | ||
|
|
4aead3018f | ||
|
|
a66c3107b9 | ||
|
|
1fc37c4a8f | ||
|
|
667ab76e3d | ||
|
|
566449becd | ||
|
|
5776c852ba | ||
|
|
2226ab34c7 | ||
|
|
61da591a5c | ||
|
|
ce283d287f | ||
|
|
4d83a2d001 | ||
|
|
68eab912d8 | ||
|
|
d6ab02eb45 | ||
|
|
a93daefe54 | ||
|
|
0f91db7661 | ||
|
|
65b3e2f191 | ||
|
|
31a697f491 | ||
|
|
06edd31b87 | ||
|
|
d72ca0b344 | ||
|
|
71837cc547 | ||
|
|
4c86e01956 | ||
|
|
e415de06d8 | ||
|
|
b1f2677f20 | ||
|
|
06153cc179 | ||
|
|
f87669c15a | ||
|
|
1f766e745c | ||
|
|
ffcfd08b70 | ||
|
|
576d4a6b9f | ||
|
|
d370c68b43 | ||
|
|
7421e158d1 | ||
|
|
1dd1251880 | ||
|
|
5239250465 | ||
|
|
1db3d108d4 | ||
|
|
7792d1a740 | ||
|
|
a5b3e35df9 | ||
|
|
c5510e4936 | ||
|
|
2979446337 | ||
|
|
2d59e3e3c4 | ||
|
|
ac70694ef3 | ||
|
|
317633085f | ||
|
|
cb8da8b512 | ||
|
|
7f309aad22 | ||
|
|
82876977aa | ||
|
|
0b5866bb94 | ||
|
|
5d8146ca11 | ||
|
|
0213047d2d | ||
|
|
aaf5d3cddb | ||
|
|
5b618679af | ||
|
|
54c6b4a74e | ||
|
|
bd98282bc5 | ||
|
|
33f25f4b21 | ||
|
|
57a1e25921 | ||
|
|
7a2bd3c71b | ||
|
|
cc320f6334 | ||
|
|
11c2f8f423 | ||
|
|
b4698be089 | ||
|
|
040a1f514f | ||
|
|
03cdefca34 | ||
|
|
c433b714cd | ||
|
|
0869eefee3 | ||
|
|
ce5daaea45 | ||
|
|
5ece717e31 | ||
|
|
39c8489e8f | ||
|
|
e81a022b6f | ||
|
|
f726c14397 | ||
|
|
e8a3212b35 | ||
|
|
0ac796b9ff | ||
|
|
a188ee963a | ||
|
|
fa2f26bf97 | ||
|
|
6a47359d8e | ||
|
|
d6a19ad171 | ||
|
|
5387d6f1d2 | ||
|
|
e51de15aac | ||
|
|
4e49e5e5b5 | ||
|
|
edc24cb956 | ||
|
|
ca7b709de0 | ||
|
|
591f462136 | ||
|
|
90e56b7dde | ||
|
|
132af95df9 | ||
|
|
8fd5eae96a | ||
|
|
769862cee9 | ||
|
|
359b964033 | ||
|
|
96d9551e54 | ||
|
|
5f90dbde67 | ||
|
|
a3a41ef6e6 | ||
|
|
5e7809687c | ||
|
|
25d331fa51 | ||
|
|
95572bd476 | ||
|
|
1c935bf7ae | ||
|
|
147f9394c8 | ||
|
|
000ac13da4 | ||
|
|
5864e9f4cf | ||
|
|
2f5c2fca50 | ||
|
|
dee8019efd | ||
|
|
89ec01cfb8 | ||
|
|
86b1ee9eeb | ||
|
|
1b1ff65de8 | ||
|
|
d0bb8b7cf7 | ||
|
|
da6cae9103 | ||
|
|
f0cb1a4f59 | ||
|
|
40e7ecc7da | ||
|
|
a0e303abcb | ||
|
|
4db458a69a | ||
|
|
44d4a8065e | ||
|
|
2cf2162fba | ||
|
|
dd4426ded7 | ||
|
|
3ebe7db37d | ||
|
|
1ed3184000 | ||
|
|
40848ffdb5 | ||
|
|
510e3dc323 | ||
|
|
3a4aece308 | ||
|
|
f99ca7e49d | ||
|
|
d73836c365 | ||
|
|
e6d4c31120 | ||
|
|
19970915fd | ||
|
|
11a2babb77 | ||
|
|
12671a1e36 | ||
|
|
1991e5c4cc | ||
|
|
e157480ec2 | ||
|
|
b82bace466 | ||
|
|
21a2254428 | ||
|
|
46b8c23975 | ||
|
|
f7e6958315 | ||
|
|
2babdd6a47 | ||
|
|
2f52589469 | ||
|
|
1f8e19598c | ||
|
|
b78a2701bb | ||
|
|
1912bcd25b | ||
|
|
e3948f4518 | ||
|
|
434d4d0d8d | ||
|
|
b64a512dfb | ||
|
|
c4c0bd8f64 | ||
|
|
2814669881 | ||
|
|
965d1f98f3 | ||
|
|
4ce8562fd8 | ||
|
|
66fd760b9a | ||
|
|
b8e8b45b6e | ||
|
|
2561c66cb8 | ||
|
|
c843661da3 | ||
|
|
d58b706799 | ||
|
|
38c10d616b | ||
|
|
94a22f0cc6 | ||
|
|
e7cf10bb28 | ||
|
|
9073c3a389 | ||
|
|
30a541559c | ||
|
|
eec475287d | ||
|
|
2d76ec1629 | ||
|
|
57b7292269 | ||
|
|
987ab3f6a0 | ||
|
|
40ca6d5eb8 | ||
|
|
48c6cd0f87 | ||
|
|
fec1151f70 | ||
|
|
b630267f33 | ||
|
|
3249b19f56 | ||
|
|
77a32a4d60 | ||
|
|
dedc62cb79 | ||
|
|
d62822bc03 | ||
|
|
aabd492300 | ||
|
|
f7dba9cb41 | ||
|
|
a7b9533d72 | ||
|
|
a6083ac409 | ||
|
|
e1e4c205e1 | ||
|
|
166cc184d1 | ||
|
|
979c960359 | ||
|
|
93cc86aa54 | ||
|
|
9837ff0b05 | ||
|
|
769609ba26 | ||
|
|
dc5ec5f87a | ||
|
|
44dee6314a | ||
|
|
ce03c85811 | ||
|
|
fca2552412 | ||
|
|
ab35298e23 | ||
|
|
81cd4d1754 | ||
|
|
ac58f28612 | ||
|
|
bfb492babd | ||
|
|
7d51b980b9 | ||
|
|
7bf3363211 | ||
|
|
1f1ffd2211 | ||
|
|
8b9d9d42e0 | ||
|
|
83c274e8d4 | ||
|
|
35c4c02579 | ||
|
|
dfec8d7ee3 | ||
|
|
5fb1b5e652 | ||
|
|
f83dc0aff3 | ||
|
|
ce6873f8fe | ||
|
|
00c4e067bf | ||
|
|
47a0299090 | ||
|
|
807aba1e85 | ||
|
|
55bed5087c | ||
|
|
7b69e7dcee | ||
|
|
3f1e4b24c6 | ||
|
|
ba896cb132 | ||
|
|
7e0f482bad | ||
|
|
2108d06715 | ||
|
|
b8a59fe2a4 | ||
|
|
9f17020582 | ||
|
|
432d315bc6 | ||
|
|
1870f87beb | ||
|
|
4eaab49776 | ||
|
|
9ad5d69bec | ||
|
|
40af82a807 | ||
|
|
c5c2d6eb93 | ||
|
|
2cad27cdcd | ||
|
|
7f796a5bfd | ||
|
|
d622b18ddf | ||
|
|
1a74df664c | ||
|
|
00a6f70990 | ||
|
|
0b3f1e7c15 | ||
|
|
0e12d4209c | ||
|
|
653525cfda | ||
|
|
7222c5d6dd | ||
|
|
63433120e6 | ||
|
|
c7de64b3f6 | ||
|
|
314884dc77 | ||
|
|
b4529c860c | ||
|
|
f946d9c1f0 | ||
|
|
f9b6cfdc0d | ||
|
|
5b7d6a6e89 | ||
|
|
560a1999a0 | ||
|
|
d6d8bf021f | ||
|
|
0808d1963b | ||
|
|
e58d37015e | ||
|
|
85c0f2c096 | ||
|
|
3101f5966d | ||
|
|
ccb62d6f7d | ||
|
|
40ec8c412e | ||
|
|
b438977a68 | ||
|
|
28a2531082 | ||
|
|
885708b2ec | ||
|
|
aaba40b2d6 | ||
|
|
1103ff957f | ||
|
|
396bbf9d9b | ||
|
|
7a2169576d | ||
|
|
8a722ed849 | ||
|
|
82dda143eb | ||
|
|
edc3ae59b1 | ||
|
|
e92e0b24f6 | ||
|
|
1cda4fa23e | ||
|
|
6628a6b2d0 | ||
|
|
3775485bdf | ||
|
|
7164304451 | ||
|
|
90b6cd442c | ||
|
|
076e4f55e6 | ||
|
|
9422c5de73 | ||
|
|
af74e2197b | ||
|
|
79fffd88f4 | ||
|
|
7ee5dbc264 | ||
|
|
7a1a478eaf | ||
|
|
f3869f5e63 | ||
|
|
e21f99eaca | ||
|
|
8a3f0bea15 | ||
|
|
3a4604286f | ||
|
|
193b03d677 | ||
|
|
37b7e58f2a | ||
|
|
a4c1fbca39 | ||
|
|
3f1923701b | ||
|
|
df9a28d752 | ||
|
|
4acf95490d | ||
|
|
78c7395e75 | ||
|
|
4838bbe73e | ||
|
|
d8517449ac | ||
|
|
0c12132bb4 | ||
|
|
7bc4e768b6 | ||
|
|
3278776e7f | ||
|
|
5ba8900438 | ||
|
|
3d091a665b | ||
|
|
d2199975a5 | ||
|
|
46cd4a5899 | ||
|
|
d348be844b | ||
|
|
6f64164109 | ||
|
|
8784432f23 | ||
|
|
e04288bc88 | ||
|
|
cfd48b6b3b | ||
|
|
c3b4e35997 | ||
|
|
d7f50984b4 | ||
|
|
e6c8c140d8 | ||
|
|
d04f43c464 | ||
|
|
b24acff972 | ||
|
|
db657b3ea5 | ||
|
|
4551eee9d1 | ||
|
|
ce98b117b7 | ||
|
|
aebdb72564 | ||
|
|
fd95b9547e | ||
|
|
799dd3007b | ||
|
|
0edd9872ea | ||
|
|
0b1062a31f | ||
|
|
8c8d2c07f5 | ||
|
|
c5a4b4add2 | ||
|
|
c63f163b0e | ||
|
|
169bf2fe4c | ||
|
|
2a793dcbba | ||
|
|
71c537fdb7 | ||
|
|
71997086e1 | ||
|
|
bc8c3084fe | ||
|
|
8e51697800 | ||
|
|
f75f77105d | ||
|
|
f112a63c75 | ||
|
|
672632ffe0 | ||
|
|
7d5aabe803 | ||
|
|
dfc5f704b4 | ||
|
|
1b239eb2c7 | ||
|
|
9a562db15a | ||
|
|
3f4d850815 | ||
|
|
3c45bdfef9 | ||
|
|
0870739a7d | ||
|
|
3ad76b5c54 | ||
|
|
e054dcabcc | ||
|
|
d88c28356a | ||
|
|
36f7eea13b | ||
|
|
8757f76b1f | ||
|
|
fe5d571190 | ||
|
|
f69cca1da6 | ||
|
|
99c2edb1d4 | ||
|
|
0d268a0e31 | ||
|
|
3569582b43 | ||
|
|
d6149bd05d | ||
|
|
167f6d4aff | ||
|
|
6cdf3d013e | ||
|
|
0d3d5a9507 | ||
|
|
c9c78f20d4 | ||
|
|
649825072b | ||
|
|
6648f43a20 | ||
|
|
411bb4a5eb | ||
|
|
03f07564e0 | ||
|
|
df6694de5d | ||
|
|
10dc87eaf7 | ||
|
|
e84bfd94ec | ||
|
|
8c8b1794e4 | ||
|
|
db46934025 | ||
|
|
e74586c8a0 | ||
|
|
ebbab98a41 | ||
|
|
d450bec1d7 | ||
|
|
9b745e5591 | ||
|
|
aa885a3d48 | ||
|
|
ded97bef00 | ||
|
|
0464306cc0 | ||
|
|
0e95d9ec29 | ||
|
|
25132540bc | ||
|
|
477f215259 | ||
|
|
02f13a045a | ||
|
|
b6e1a6c657 | ||
|
|
240b367acd | ||
|
|
27d44b3b77 | ||
|
|
1a71072497 | ||
|
|
59b28461f9 | ||
|
|
a26e76638e | ||
|
|
aede89654f | ||
|
|
ed23cd3f4a | ||
|
|
01d3f2f23e | ||
|
|
6d07b1a915 | ||
|
|
c74dffdcd6 | ||
|
|
be8cb630f9 | ||
|
|
ca0fbc5fd4 | ||
|
|
5763ed1c94 | ||
|
|
b542a80f89 | ||
|
|
cd54fcf792 | ||
|
|
a17a035689 | ||
|
|
8fec20b3be | ||
|
|
e8279ead52 | ||
|
|
47085819f6 | ||
|
|
00464f518f | ||
|
|
0414983f64 | ||
|
|
394a9761f1 | ||
|
|
12bc8b1668 | ||
|
|
9d222f0d42 | ||
|
|
6c7f02c142 | ||
|
|
7eba6d2fb8 | ||
|
|
9f5dc2e666 | ||
|
|
f42ade947c | ||
|
|
450e998d3f | ||
|
|
9d3ba9b353 | ||
|
|
b353bc8e48 | ||
|
|
3de40dc959 | ||
|
|
413b5ffac1 | ||
|
|
276da822a6 | ||
|
|
14009f869e | ||
|
|
58b4fc49b9 | ||
|
|
e21e3896a2 | ||
|
|
cd1256d816 | ||
|
|
b6b14d739f | ||
|
|
94478e469f | ||
|
|
97f3bf2c20 | ||
|
|
5363a62e0e | ||
|
|
9281df46fe | ||
|
|
62b5b75ec9 | ||
|
|
0d5a69e543 | ||
|
|
b3b49b4fe2 | ||
|
|
c4aafe9413 | ||
|
|
078a115cfd | ||
|
|
df1b1a017e | ||
|
|
ed827b629a | ||
|
|
b0245c5366 | ||
|
|
3100d10dc0 | ||
|
|
9c8228b697 | ||
|
|
5c2ece9fb6 | ||
|
|
607fd3bc3e | ||
|
|
39324778f5 | ||
|
|
b6e0a59d5b | ||
|
|
b3d9b70ed4 | ||
|
|
07bf4a293c | ||
|
|
021f497970 | ||
|
|
20d0529365 | ||
|
|
3876a0c403 | ||
|
|
d0c26e9fab | ||
|
|
584579e987 | ||
|
|
e60177c177 | ||
|
|
a787a97221 | ||
|
|
b62c97dcde | ||
|
|
f6b9a6f73b | ||
|
|
be5b9aef12 | ||
|
|
d34e66da25 | ||
|
|
3a087d3392 | ||
|
|
2a322feea9 | ||
|
|
033c4f2fa4 | ||
|
|
67602b0af7 | ||
|
|
670a50c4d0 | ||
|
|
d76b31952f | ||
|
|
9b0f762654 | ||
|
|
2eb8d7ad67 | ||
|
|
9362b25593 | ||
|
|
3ee9701a50 | ||
|
|
366e0e3e5c | ||
|
|
90458511c1 | ||
|
|
f6d8a4b8ea | ||
|
|
5037a1a1eb | ||
|
|
a197a21337 | ||
|
|
d7d33ebc4a | ||
|
|
1cf1ba6bb6 | ||
|
|
3af9990311 | ||
|
|
bcb0006a9e | ||
|
|
1963c84d7c | ||
|
|
427e99b51e | ||
|
|
b26f46cc80 | ||
|
|
d53308126d | ||
|
|
cc53eee037 | ||
|
|
83c31b600c | ||
|
|
5ce1e48392 | ||
|
|
ba539ce236 | ||
|
|
0f1548fdc9 | ||
|
|
11ab9a8e03 | ||
|
|
f4d488d104 | ||
|
|
a90e28d115 | ||
|
|
ab0dc8bce3 | ||
|
|
ea6a9a9d76 | ||
|
|
7eda4720d3 | ||
|
|
f0086e26da | ||
|
|
5e51cd8ca4 | ||
|
|
a63db2af6b | ||
|
|
8c0ce434dc | ||
|
|
305dd21dd5 | ||
|
|
e7cc0c9555 | ||
|
|
01748fe501 | ||
|
|
3e935fae37 | ||
|
|
117621b8cb | ||
|
|
bc432ffb44 | ||
|
|
fb6b2170bd | ||
|
|
494a3c6d48 | ||
|
|
8853bfae77 | ||
|
|
d02416d1b2 | ||
|
|
b095ed40b8 | ||
|
|
c202dd48a8 | ||
|
|
ae254d7bc5 | ||
|
|
e6b6cbf0c7 | ||
|
|
b188352fda | ||
|
|
80db2de86a | ||
|
|
89c9d1455a | ||
|
|
a2bd22c4e1 | ||
|
|
d4033cc201 | ||
|
|
046fff7f2e | ||
|
|
49b54c2387 | ||
|
|
89dfc39284 | ||
|
|
dceebf0652 | ||
|
|
dc04c1d91b | ||
|
|
d1ee239fd6 | ||
|
|
43083ff90c | ||
|
|
c8ac5584c3 | ||
|
|
59e26064e5 | ||
|
|
cc01b539bc | ||
|
|
14cd997a43 | ||
|
|
baad844495 | ||
|
|
cd91094b0f | ||
|
|
ade5344163 | ||
|
|
9a1c7a8f3a | ||
|
|
a8e5252241 | ||
|
|
6d281e950a | ||
|
|
80db6cd48d | ||
|
|
1a82296133 | ||
|
|
f8900a0d72 | ||
|
|
37b22b583f | ||
|
|
9fc4790722 | ||
|
|
c2527027d6 | ||
|
|
d393b5f138 | ||
|
|
2b2b2bd228 | ||
|
|
449a631db4 | ||
|
|
eb6a0bf7dd | ||
|
|
9965ead31a | ||
|
|
510b0c13a6 | ||
|
|
e562e48c13 | ||
|
|
e23912d48f | ||
|
|
ed7534a1ff | ||
|
|
bfbad743c4 | ||
|
|
93e2c5d892 | ||
|
|
30b0d972e9 | ||
|
|
e2698a7a49 | ||
|
|
305e1e5161 | ||
|
|
954d708e30 | ||
|
|
a219397344 | ||
|
|
1f864294c9 | ||
|
|
25b20e1730 | ||
|
|
d133af4cbe | ||
|
|
17dcfcd55d | ||
|
|
21d3eb0769 | ||
|
|
97d695b476 | ||
|
|
f32146b3f3 | ||
|
|
6c1583a2fa | ||
|
|
b088d65a66 | ||
|
|
399abb1ed3 | ||
|
|
a215ec1cbb | ||
|
|
bd1dbc4406 | ||
|
|
c16862bd90 | ||
|
|
243e12cc0e | ||
|
|
cd44deb8c4 | ||
|
|
1721f53248 | ||
|
|
19c76950db | ||
|
|
478562c312 | ||
|
|
5f54531f75 | ||
|
|
04428bf6b2 | ||
|
|
1768413eca | ||
|
|
177f7ec3df | ||
|
|
8df3173cb2 | ||
|
|
519239bcd3 | ||
|
|
80d862258b | ||
|
|
54a4e15cf2 | ||
|
|
2e87ab91d3 | ||
|
|
785177bed4 | ||
|
|
fb2f3461c2 | ||
|
|
23933cfb41 | ||
|
|
cb0c9ebf95 | ||
|
|
5407ad751a | ||
|
|
de142d0be7 | ||
|
|
b62926cf6e | ||
|
|
c823910185 | ||
|
|
f97e96c5f9 | ||
|
|
01e124ebb1 | ||
|
|
3864cf7ff8 | ||
|
|
23c326b1fb | ||
|
|
b08b98102a | ||
|
|
1269e5f562 | ||
|
|
50d487dbcb | ||
|
|
bbde945bb8 | ||
|
|
f580a43efd | ||
|
|
e5ef86f089 | ||
|
|
66d9031d89 | ||
|
|
63b77b5507 | ||
|
|
e5a07fa022 | ||
|
|
5568325a0d | ||
|
|
900a1c9e38 | ||
|
|
83102c559f | ||
|
|
0a33d99c28 | ||
|
|
817309ade9 | ||
|
|
cfb4a0e329 | ||
|
|
f18f9733df | ||
|
|
406bc6418e | ||
|
|
bd0928c135 | ||
|
|
d73f6d6a29 | ||
|
|
ae93dfcfda | ||
|
|
5b30c2616b | ||
|
|
bb86d1fb1a | ||
|
|
33fcb34900 | ||
|
|
8af70192f8 | ||
|
|
3e7e5d1177 | ||
|
|
0b01f6620b | ||
|
|
0f32383d7d | ||
|
|
d2b1fd22d0 | ||
|
|
60c9c57263 | ||
|
|
15c57afdfa | ||
|
|
365eabead1 | ||
|
|
f1ffeed9f2 | ||
|
|
14b6b3ec10 | ||
|
|
328f1f4915 | ||
|
|
b40df45daa | ||
|
|
4afced3b8d | ||
|
|
ed6ebbf80a | ||
|
|
d2aa27b1c2 | ||
|
|
53f8cd7052 | ||
|
|
1eb05c980d | ||
|
|
63a01e6a5a | ||
|
|
1c1f7026d5 | ||
|
|
583145c97c | ||
|
|
ccfaef9df9 | ||
|
|
01cce4ac55 | ||
|
|
8aa0595741 | ||
|
|
cb4cf50337 | ||
|
|
467de8ff1e | ||
|
|
dc1665d7ba | ||
|
|
8e225e9ae1 | ||
|
|
47df3b644c | ||
|
|
4d1d973437 | ||
|
|
6e493cf589 | ||
|
|
1826cbf6b8 | ||
|
|
3449a1d82b | ||
|
|
b98196b96e | ||
|
|
29f94248f4 | ||
|
|
5fc5a5e47c | ||
|
|
9276e6c35c | ||
|
|
05cf3ae54e | ||
|
|
ac02644d3f | ||
|
|
409770d386 | ||
|
|
139b14b3c6 | ||
|
|
be58859906 | ||
|
|
f40489d4d7 | ||
|
|
54f02be0f5 | ||
|
|
2f1eff956a | ||
|
|
a1b6496976 | ||
|
|
d8aa1d1661 | ||
|
|
da3f4fad64 | ||
|
|
8d602bf543 | ||
|
|
a2de2c6d6d | ||
|
|
aa9abff5af | ||
|
|
b99eb6cf7a | ||
|
|
b702440347 | ||
|
|
c0d4375daa | ||
|
|
656aacbd01 | ||
|
|
9a96824e2b | ||
|
|
043bbab84d | ||
|
|
0fab83251c | ||
|
|
10e1096c83 | ||
|
|
f082c20141 | ||
|
|
de947dd5aa | ||
|
|
3e4594273b | ||
|
|
8bd15f25c6 | ||
|
|
c608882f9f | ||
|
|
2ae376bdfd | ||
|
|
5c10380666 | ||
|
|
ea06999769 | ||
|
|
52add30784 | ||
|
|
e2f4d5a3db | ||
|
|
21a915cb13 | ||
|
|
9631c10812 | ||
|
|
44b2148bef | ||
|
|
ff4f48bffc | ||
|
|
f83230d7e9 | ||
|
|
fe8a8564e7 | ||
|
|
ccc2e45b12 | ||
|
|
dbfc8e97d2 | ||
|
|
fc88bbb3d0 | ||
|
|
9727da4072 | ||
|
|
404e50a4d6 | ||
|
|
1eb23a015f | ||
|
|
7e7685410f | ||
|
|
b22c4c5c2f | ||
|
|
dae5581c76 | ||
|
|
7e7c7f3a1a | ||
|
|
947af6d26f | ||
|
|
f09d8f1d25 | ||
|
|
f5a360010c | ||
|
|
c6b250e0d6 | ||
|
|
c754383ffc | ||
|
|
8d2066a3bf | ||
|
|
af7e98a4dc | ||
|
|
337fd15471 | ||
|
|
ae5f1309b1 | ||
|
|
5d1d2eb653 | ||
|
|
87a6e6561d | ||
|
|
98fe210b64 | ||
|
|
5be4d0e9df | ||
|
|
d6a06e0cd0 | ||
|
|
7cd29fe2b5 | ||
|
|
f2f856eec4 | ||
|
|
7ebfcfd4eb | ||
|
|
a4abda497c | ||
|
|
1a6a1a8d6e | ||
|
|
0fa2c8b40e | ||
|
|
2a2d4f46b3 | ||
|
|
8a34cd4570 | ||
|
|
c22d9019bf | ||
|
|
b6db6a671c | ||
|
|
eb56b7967d | ||
|
|
4f5d999ade | ||
|
|
89ac6d69db | ||
|
|
30d6e5e183 | ||
|
|
69751f1ea1 | ||
|
|
370a0055b7 | ||
|
|
c0a8934c30 | ||
|
|
3b072fb44f | ||
|
|
55774ef43e | ||
|
|
d6411076fa | ||
|
|
281593ff29 | ||
|
|
0abded401b | ||
|
|
1f078d5b37 | ||
|
|
f764767ba4 | ||
|
|
2e21d1b274 | ||
|
|
ea5a189ae0 | ||
|
|
57b784f542 | ||
|
|
67da32512a | ||
|
|
dae5f59417 | ||
|
|
afc9e187b6 | ||
|
|
4533af1495 | ||
|
|
50a2d84d2c | ||
|
|
3443dc01d3 | ||
|
|
7ce4d4cdc2 | ||
|
|
b977242dc7 | ||
|
|
590edd49f0 | ||
|
|
38f1151099 | ||
|
|
5f81f596b8 | ||
|
|
64cb485dfa | ||
|
|
fbbc63f865 | ||
|
|
0b6a794097 | ||
|
|
4f1745b865 | ||
|
|
d4e6fb3fc8 | ||
|
|
77f1401333 | ||
|
|
0a105b5c94 | ||
|
|
3bd022fc15 | ||
|
|
176fc94293 | ||
|
|
31f8c004b2 | ||
|
|
6532cf6ebe | ||
|
|
9ecd91376d | ||
|
|
ea561418c4 | ||
|
|
5d3c7c1cdc | ||
|
|
d1bcb7ce16 | ||
|
|
161c04e23e | ||
|
|
810b530eb0 | ||
|
|
5a4e9fd582 | ||
|
|
159137b3d7 | ||
|
|
e7812060b8 | ||
|
|
67d834f0dd | ||
|
|
ec988be30a | ||
|
|
37db7e2d3a | ||
|
|
6f0e49bfb9 | ||
|
|
b75dfa9295 | ||
|
|
232f634cc6 | ||
|
|
c2a0568148 | ||
|
|
6936a2c203 | ||
|
|
6ba0f89593 | ||
|
|
33048b7602 | ||
|
|
c9808d4f5b | ||
|
|
e300153a01 | ||
|
|
9c6abd93da | ||
|
|
0a2b8b3045 | ||
|
|
82233b5f3a | ||
|
|
5aabe83da7 | ||
|
|
d27d25e6ff | ||
|
|
bec7b32a76 | ||
|
|
3afb31870b | ||
|
|
b1afb74724 | ||
|
|
4b820f8ff4 | ||
|
|
1fa564ad90 | ||
|
|
3347bb0d87 | ||
|
|
6de541c1fb | ||
|
|
c05eca89f2 | ||
|
|
1e3e81ed0b | ||
|
|
7ff6af2065 | ||
|
|
a458baaca9 | ||
|
|
f1dd0e9da9 | ||
|
|
641827031f | ||
|
|
54a971e3b1 | ||
|
|
d62de1d239 | ||
|
|
45aabb9999 |
237
.env.example
237
.env.example
@ -1,45 +1,218 @@
|
||||
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
||||
AUTH_SECRET=****
|
||||
# yedir-ai environment template
|
||||
# Copy this file to .env.local and fill real values locally or in your hosting platform.
|
||||
# Never commit real secrets.
|
||||
|
||||
# The following keys below are automatically created and
|
||||
# added to your environment when you deploy on Vercel
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core / Auth
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Instructions to create an AI Gateway API key here: https://vercel.com/ai-gateway
|
||||
# API key required for non-Vercel deployments
|
||||
# For Vercel deployments, OIDC tokens are used automatically
|
||||
# https://vercel.com/ai-gateway
|
||||
AI_GATEWAY_API_KEY=****
|
||||
# Generate with: openssl rand -base64 32
|
||||
AUTH_SECRET=replace-with-random-secret
|
||||
|
||||
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
|
||||
ACCESS_KEY_PEPPER=replace-with-random-secret
|
||||
|
||||
# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob
|
||||
BLOB_READ_WRITE_TOKEN=****
|
||||
# Authorizes trusted third-party services to provision user-scoped MCP keys.
|
||||
# Must contain at least 32 characters. Never expose it to end users or browsers.
|
||||
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
|
||||
|
||||
# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres
|
||||
POSTGRES_URL=****
|
||||
# Public application URL. Required for production and self-hosted Auth.js.
|
||||
AUTH_URL=https://your-domain.com
|
||||
NEXTAUTH_URL=https://your-domain.com
|
||||
NEXT_PUBLIC_APP_URL=https://your-domain.com
|
||||
|
||||
# Only enable behind a trusted reverse proxy with validated Host headers.
|
||||
AUTH_TRUST_HOST=false
|
||||
|
||||
# Instructions to create a Redis store here:
|
||||
# https://vercel.com/docs/redis
|
||||
REDIS_URL=****
|
||||
# Optional OAuth providers.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# DashScope / 通义千问配置
|
||||
# 请求地址(阿里云 DashScope OpenAI 兼容接口)
|
||||
# Optional initial admin provisioning.
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Optional email verification.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=
|
||||
EMAIL_VERIFICATION_SECRET=
|
||||
|
||||
# Optional scheduled job protection.
|
||||
CRON_SECRET=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
POSTGRES_URL=postgres://user:password@localhost:5432/yedir_ai
|
||||
|
||||
# Production-safe defaults used by lib/db/index.ts.
|
||||
POSTGRES_MAX_CONNECTIONS=10
|
||||
POSTGRES_IDLE_TIMEOUT_SECONDS=20
|
||||
POSTGRES_CONNECT_TIMEOUT_SECONDS=5
|
||||
POSTGRES_MIGRATION_TIMEOUT_SECONDS=120
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Object Storage
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Vercel Blob fallback.
|
||||
BLOB_READ_WRITE_TOKEN=
|
||||
|
||||
# S3-compatible storage fallback. Database-configured storage takes precedence.
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=auto
|
||||
S3_BUCKET=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_PUBLIC_URL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Providers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
AI_GATEWAY_API_KEY=
|
||||
NEXT_PUBLIC_DEFAULT_CHAT_MODEL=
|
||||
|
||||
# DashScope / Qwen OpenAI-compatible endpoint.
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
DASHSCOPE_API_KEY=
|
||||
|
||||
# API Key(请在本地 .env.local 和 服务器环境中填写真实值,不要把真实 Key 提交到代码仓库)
|
||||
DASHSCOPE_API_KEY=your-dashscope-api-key
|
||||
|
||||
# 第三方 GPT5 提供方配置(OpenAI 兼容接口)
|
||||
# API 基础地址(例如:https://api.huandutech.com)
|
||||
GTP5_API_URL=https://api.huandutech.com
|
||||
|
||||
# API Key(请在本地 .env.local 和 服务器环境中填写真实值,不要把真实 Key 提交到代码仓库)
|
||||
GTP5_API_KEY=your-gtp5-api-key
|
||||
|
||||
# 模型名称(例如:gpt-5-nano)
|
||||
# Third-party GPT-compatible provider.
|
||||
GTP5_API_URL=
|
||||
GTP5_API_KEY=
|
||||
GTP5_MODEL=gpt-5-nano
|
||||
|
||||
# Tripo3D(可选)
|
||||
# 优先使用系统设置-本地AI里的 `tripo-3d` 提供商;若未配置则回退到环境变量
|
||||
TRIPO_API_KEY=your-tripo-api-key
|
||||
# FAL image/video/realtime providers.
|
||||
FAL_KEY=
|
||||
FAL_API_KEY=
|
||||
FAL_BASE_URL=https://fal.run
|
||||
FAL_MODEL=
|
||||
FAL_REALTIME_MODEL=
|
||||
|
||||
# Realtime Canvas fallback providers.
|
||||
REPLICATE_API_TOKEN=
|
||||
BFL_API_KEY=
|
||||
BFL_KEY=
|
||||
BFL_BASE_URL=
|
||||
BFL_MODEL=
|
||||
RUNWARE_MODEL=
|
||||
|
||||
# 3D / CAD providers.
|
||||
TRIPO_API_KEY=
|
||||
TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi
|
||||
WORLDLABS_API_KEY=
|
||||
WORLDLABS_BASE_URL=
|
||||
WORLDLABS_IMPORT_TOKEN=
|
||||
WORLDLABS_KEY_POOL_IMPORT_TOKEN=
|
||||
WORLDLABS_API_TIMEOUT_MS=180000
|
||||
WORLDLABS_TASK_CREATE_TIMEOUT_MS=180000
|
||||
WORLDLABS_TASK_STATUS_TIMEOUT_MS=180000
|
||||
KITTYCAD_API_TOKEN=
|
||||
KITTYCAD_API_KEY=
|
||||
KITTYCAD_BASE_URL=https://api.zoo.dev
|
||||
ZOO_API_TOKEN=
|
||||
ZOO_BASE_URL=https://api.zoo.dev
|
||||
|
||||
# Pascal Editor self-hosted workspace. In local development, leaving this blank
|
||||
# makes /pascal-editor auto-detect http://localhost:3002. For production or a
|
||||
# fixed deployment, point this to your self-hosted Pascal service or static
|
||||
# /vendor/pascal-editor/index.html bundle.
|
||||
NEXT_PUBLIC_PASCAL_EDITOR_URL=
|
||||
PASCAL_EDITOR_URL=
|
||||
# Shared only by the main app, Nginx, and the private Pascal service. Generate a
|
||||
# long random value; never expose it through NEXT_PUBLIC_* variables.
|
||||
PASCAL_PROXY_SECRET=
|
||||
|
||||
# RUXA / video callback.
|
||||
RUXA_CALLBACK_URL=
|
||||
RUXA_VIDEO_CALLBACK_URL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Product Planning Integrations
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
PRODUCT_PLANNING_DETAIL_PLUS_BASE_URL=
|
||||
PRODUCT_PLANNING_DETAIL_PLUS_INTERNAL_BASE_URL=
|
||||
PRODUCT_PLANNING_DETAIL_PLUS_API_KEY=
|
||||
PRODUCT_PLANNING_QA_TOKEN=
|
||||
TAOBAO_API_KEY=
|
||||
TAOBAO_API_SECRET=
|
||||
TAOBAO_SALE_API_TOKEN=
|
||||
|
||||
PRODUCT_PLANNING_DB_HOST=
|
||||
PRODUCT_PLANNING_DB_PORT=3306
|
||||
PRODUCT_PLANNING_DB_DATABASE=
|
||||
PRODUCT_PLANNING_DB_NAME=
|
||||
PRODUCT_PLANNING_DB_USER=
|
||||
PRODUCT_PLANNING_DB_PASSWORD=
|
||||
PRODUCT_PLANNING_DB_CHARSET=utf8mb4
|
||||
PRODUCT_PLANNING_DB_SSL=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Home Performance / Queues
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
CANVAS_IMAGE_TASK_CONCURRENCY=3
|
||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
|
||||
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
||||
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
||||
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
||||
|
||||
# Browser direct uploads use signed OSS/S3 URLs. Configure CORS and lifecycle
|
||||
# with the scripts below after changing the default storage bucket.
|
||||
DIRECT_UPLOAD_CORS_ORIGINS=https://ai.yydd.ai,https://yydd.ai
|
||||
DIRECT_UPLOAD_ABORT_INCOMPLETE_DAYS=2
|
||||
DIRECT_UPLOAD_LIFECYCLE_PREFIX=uploads/direct/
|
||||
DIRECT_UPLOAD_LIFECYCLE_RULE_ID=abort-incomplete-direct-upload-parts
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Agent / PSD Runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
AGENT_FORCE_LLM_PLANNER=false
|
||||
AGENT_FORCE_QWEN3VL_FALLBACK=false
|
||||
AGENT_IMAGE_GEN_LLM_OPTIMIZER_ENABLED=true
|
||||
AGENT_LLM_PROMPT_OPTIMIZER_ENABLED=true
|
||||
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT=2
|
||||
AGENT_PSD_LOCAL_CACHE_DIR=
|
||||
AGENT_PSD_REMOTE_ASSET_CACHE_DISABLED=false
|
||||
AGENT_PSD_REMOTE_ASSET_CACHE_MAX_BYTES=
|
||||
AGENT_PSD_REMOTE_ASSET_CACHE_MAX_ENTRY_BYTES=
|
||||
AGENT_PSD_REMOTE_ASSET_CACHE_TTL_MS=
|
||||
AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL=6
|
||||
AGENT_PSD_RENDER_TASK_CONCURRENCY=2
|
||||
NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR=true
|
||||
NEXT_PUBLIC_AGENT_STYLE_PERF_SAMPLE_RATE=0.01
|
||||
NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER=true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging / Observability
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# debug | info | warn | error | silent
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Stack traces remain disabled in production unless explicitly enabled.
|
||||
LOG_INCLUDE_STACK=false
|
||||
|
||||
# Client logs are silent in production unless this is true.
|
||||
NEXT_PUBLIC_ENABLE_CLIENT_LOGS=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Optional Jobs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
VIDEO_ARCHIVE_DELAY_HOURS=24
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test / Local Dev
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
PORT=3001
|
||||
PLAYWRIGHT=false
|
||||
CI_PLAYWRIGHT=false
|
||||
PLAYWRIGHT_TEST_BASE_URL=http://127.0.0.1:3001
|
||||
SMOKE_BASE_URL=http://127.0.0.1:3001
|
||||
SMOKE_TIMEOUT_MS=20000
|
||||
DISABLE_PERF_GUARD_AUTO_INSTALL=false
|
||||
|
||||
35
.github/CODEOWNERS
vendored
Normal file
35
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
# Default owner for all changes.
|
||||
* @lanmu1818-ship-it
|
||||
|
||||
# Repository governance and release safety.
|
||||
/.github/ @lanmu1818-ship-it
|
||||
/SECURITY.md @lanmu1818-ship-it
|
||||
/CONTRIBUTING.md @lanmu1818-ship-it
|
||||
/README.md @lanmu1818-ship-it
|
||||
/docs/release-readiness.md @lanmu1818-ship-it
|
||||
/docs/operations.md @lanmu1818-ship-it
|
||||
/docs/deployment.md @lanmu1818-ship-it
|
||||
|
||||
# Core application surfaces.
|
||||
/app/(admin)/settings/ @lanmu1818-ship-it
|
||||
/app/(chat)/api/chat/ @lanmu1818-ship-it
|
||||
/app/api/canvas/ @lanmu1818-ship-it
|
||||
/components/canvas/ @lanmu1818-ship-it
|
||||
/components/home/ @lanmu1818-ship-it
|
||||
|
||||
# Infrastructure, security, and data boundaries.
|
||||
/lib/observability/ @lanmu1818-ship-it
|
||||
/lib/security/ @lanmu1818-ship-it
|
||||
/lib/storage/ @lanmu1818-ship-it
|
||||
/lib/db/ @lanmu1818-ship-it
|
||||
/lib/ai/ @lanmu1818-ship-it
|
||||
/scripts/check-*.mjs @lanmu1818-ship-it
|
||||
/scripts/check-*.ts @lanmu1818-ship-it
|
||||
/scripts/security/ @lanmu1818-ship-it
|
||||
|
||||
# Quality gates and dependency manifests.
|
||||
/package.json @lanmu1818-ship-it
|
||||
/pnpm-lock.yaml @lanmu1818-ship-it
|
||||
/tsconfig.json @lanmu1818-ship-it
|
||||
/next.config.ts @lanmu1818-ship-it
|
||||
/biome.jsonc @lanmu1818-ship-it
|
||||
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible product, API, or operations bug.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping improve yedir-ai.
|
||||
|
||||
Do not include secrets, cookies, Authorization headers, OAuth codes, signed URLs, `.env.local` values, private prompts, base64 data, or private user content. If this is a security issue, do not open a public issue. Follow `SECURITY.md` instead.
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What happened, and what did you expect instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: Provide the smallest reliable reproduction.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: Who is affected and how severe is it?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Browser, OS, deployment target, Node/pnpm versions, or route/API involved.
|
||||
placeholder: |
|
||||
Browser:
|
||||
OS:
|
||||
Node:
|
||||
pnpm:
|
||||
Route/API:
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Sanitized logs or screenshots
|
||||
description: Include only sanitized output. Remove prompts, URLs, tokens, cookies, headers, base64, and private content.
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Pre-submit checklist
|
||||
options:
|
||||
- label: I removed secrets, tokens, signed URLs, cookies, private prompts, and private user content.
|
||||
required: true
|
||||
- label: I searched existing issues and docs before filing this.
|
||||
required: true
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/lanmu1818-ship-it/yedir-ai/blob/main/SECURITY.md
|
||||
about: Do not open public issues for vulnerabilities. Follow SECURITY.md and report privately to the project maintainer.
|
||||
- name: Release readiness and contribution rules
|
||||
url: https://github.com/lanmu1818-ship-it/yedir-ai/blob/main/CONTRIBUTING.md
|
||||
about: Read docs/release-readiness.md and CONTRIBUTING.md before opening broad engineering requests.
|
||||
- name: Support and operations help
|
||||
url: https://github.com/lanmu1818-ship-it/yedir-ai/blob/main/SUPPORT.md
|
||||
about: Read SUPPORT.md for bug, feature, deployment, operations, and security support boundaries.
|
||||
68
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
68
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,68 @@
|
||||
name: Feature request
|
||||
description: Propose a product, workflow, integration, or engineering improvement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please keep proposals focused and avoid sharing private customer data, prompts, screenshots with secrets, or provider credentials.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or opportunity
|
||||
description: What user, operator, or developer problem does this solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the behavior, API, UI, or documentation change.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- Lovart home
|
||||
- Canvas / Agent
|
||||
- Image or video generation
|
||||
- 3D / CAD
|
||||
- Settings / admin
|
||||
- API / backend
|
||||
- Performance
|
||||
- Security / observability
|
||||
- Documentation / CI
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: What other approaches did you consider?
|
||||
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: How should reviewers verify that this is done?
|
||||
placeholder: |
|
||||
- ...
|
||||
- ...
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Pre-submit checklist
|
||||
options:
|
||||
- label: This request does not include real secrets, private prompts, signed URLs, or private user data.
|
||||
required: true
|
||||
- label: I checked the README, release readiness notes, and existing issues first.
|
||||
required: true
|
||||
42
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
42
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
## Summary
|
||||
|
||||
<!-- What changed, and why? Keep this focused on user/business impact. -->
|
||||
|
||||
## Scope
|
||||
|
||||
- [ ] UI / product behavior
|
||||
- [ ] API / backend logic
|
||||
- [ ] Settings / admin configuration
|
||||
- [ ] Performance / caching
|
||||
- [ ] Logging / security
|
||||
- [ ] Documentation / CI / repository hygiene
|
||||
|
||||
## Required Verification
|
||||
|
||||
- [ ] `pnpm check:branch`
|
||||
- [ ] `pnpm check:quality`
|
||||
- [ ] `pnpm build`
|
||||
- [ ] `AUTH_TRUST_HOST=true PORT=3001 pnpm dev`
|
||||
- [ ] `SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes`
|
||||
|
||||
If any required check was not run, explain why:
|
||||
|
||||
```text
|
||||
|
||||
```
|
||||
|
||||
## Release Readiness
|
||||
|
||||
- [ ] No real secrets, OAuth codes, cookies, Authorization headers, signed URLs, or `.env.local` values are included.
|
||||
- [ ] No tracked local artifacts such as `.DS_Store`, swap files, `.claude/`, migration backups, or `tsconfig.tsbuildinfo`.
|
||||
- [ ] No new public UI links point to `/classic` or `/chat/[id]`.
|
||||
- [ ] `/api/chat` remains treated as an internal Canvas/Agent generation API unless this PR explicitly migrates callers.
|
||||
- [ ] New or modified high-risk logging uses `createLogger` / `createClientLogger` and is covered by `pnpm check:logs` when applicable.
|
||||
|
||||
## Screenshots / Evidence
|
||||
|
||||
<!-- Add screenshots, route smoke output, CI links, or notes for reviewers. -->
|
||||
|
||||
## Follow-ups
|
||||
|
||||
<!-- List intentional non-blocking follow-ups, if any. -->
|
||||
37
.github/dependabot.yml
vendored
Normal file
37
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "needs-triage"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:30"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
- "needs-triage"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
45
.github/workflows/build.yml
vendored
Normal file
45
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
name: Production Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
AUTH_SECRET: ci-build-placeholder-secret
|
||||
AUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||
POSTGRES_URL: postgres://user:password@localhost:5432/yedir_ai
|
||||
DISABLE_PERF_GUARD_AUTO_INSTALL: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run production build
|
||||
run: pnpm build
|
||||
38
.github/workflows/dependency-audit.yml
vendored
Normal file
38
.github/workflows/dependency-audit.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
name: Dependency Audit
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dependency-audit-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Audit high and critical dependency advisories
|
||||
run: pnpm audit:security
|
||||
25
.github/workflows/lint.yml
vendored
25
.github/workflows/lint.yml
vendored
@ -1,25 +1,38 @@
|
||||
name: Lint
|
||||
name: Quality Gates
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: quality-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
quality:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Run lint
|
||||
run: pnpm lint
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run quality gate
|
||||
run: pnpm check:quality
|
||||
|
||||
41
.github/workflows/playwright.yml
vendored
41
.github/workflows/playwright.yml
vendored
@ -1,14 +1,18 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: playwright-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
|
||||
@ -22,37 +26,20 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
version: 9.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@ -11,12 +11,18 @@ coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
.next-build/
|
||||
.next-previous/
|
||||
out/
|
||||
build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.pem
|
||||
*.log
|
||||
*.har
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@ -37,6 +43,7 @@ yarn-error.log*
|
||||
.env
|
||||
.vercel
|
||||
.env*.local
|
||||
.claude/
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
@ -52,9 +59,17 @@ migration-backup-*/
|
||||
migration-backup-complete/
|
||||
*.dump
|
||||
*.sql
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
migration-*.log
|
||||
migration-*.txt
|
||||
|
||||
# 本地抓包/竞品参考产物,不能提交
|
||||
artifacts/liblib-*/
|
||||
analysis/qiaodou-extract/
|
||||
|
||||
# 模型文件
|
||||
*.model
|
||||
*.pth
|
||||
@ -74,5 +89,14 @@ checkpoints/
|
||||
# 第三方参考源码(不提交到git)
|
||||
reference-sources/
|
||||
|
||||
# Legacy external Pascal Editor checkout (the managed source is under services/).
|
||||
.pascal-editor/
|
||||
|
||||
# Local self-hosted Zoo Design Studio checkout.
|
||||
.zoo-modeling-app/
|
||||
|
||||
# Generated official Zoo Design Studio web bundle.
|
||||
public/vendor/zoo-modeling-app/
|
||||
|
||||
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||
!lib/db/migrations/*.sql
|
||||
|
||||
332
AI生图平台桶缓存落地对接清单.md
Normal file
332
AI生图平台桶缓存落地对接清单.md
Normal file
@ -0,0 +1,332 @@
|
||||
# AI 生图平台桶缓存落地对接清单
|
||||
|
||||
> 交付对象:AI 生图平台负责人
|
||||
> 当前主接口域名:`https://sc.jiansun.vip`
|
||||
> 当前资源主域名:`https://s3.jiansun.vip`
|
||||
> 备用资源域名:`https://s3.yydd.ai`
|
||||
> 文档时间:`2026-03-18`
|
||||
|
||||
## 1. 这次要做的事情
|
||||
|
||||
计划把新入口服务器 `180.97.215.57` 上独立数据盘中的 `180G` 空间,用作 AI 生图平台相关资源的**读取缓存**。
|
||||
|
||||
这件事的目标是:
|
||||
|
||||
- 提升 AI 生图平台读取图片资源时的稳定性
|
||||
- 降低反复回源到生产机的压力
|
||||
- 让热点资源第二次及之后访问更快
|
||||
|
||||
## 2. 这次不会变的东西
|
||||
|
||||
下面这些默认都**不变**:
|
||||
|
||||
- 桶本体位置不变,真实存储仍在生产机 `192.168.58.20:9000`
|
||||
- AI 生图平台的接口入口仍然是 `https://sc.jiansun.vip`
|
||||
- 对外资源主域名仍然是 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 先保留为备用域名,不作为默认对接地址
|
||||
- 这次做的是“读缓存”,不是改上传链路,也不是迁桶
|
||||
|
||||
一句话说清楚:
|
||||
|
||||
**缓存只是在入口服务器上多放一份“最近常用内容的副本”,不是把桶搬走。**
|
||||
|
||||
## 3. 当前已经确认的事实
|
||||
|
||||
当前资源入口链路是:
|
||||
|
||||
```text
|
||||
s3.jiansun.vip
|
||||
-> 180.97.215.57
|
||||
-> nginx 磁盘缓存
|
||||
-> 127.0.0.1:29000
|
||||
-> FRP
|
||||
-> 192.168.58.20:9000
|
||||
```
|
||||
|
||||
当前缓存盘配置已经到位:
|
||||
|
||||
- 缓存目录:`/data/nginx-cache/s3`
|
||||
- 缓存上限:`180g`
|
||||
- 当前入口机资源盘:`/data`,总容量约 `200G`
|
||||
|
||||
`2026-03-18` 实测真实桶链路时,使用了一个约 `145MB` 的真实桶对象做下载测试:
|
||||
|
||||
- 直回源 `127.0.0.1:29000`:约 `23.2 MB/s`
|
||||
- 走 `https://s3.jiansun.vip` 第一次请求:`X-Cache-Status: MISS`,约 `22.7 MB/s`
|
||||
- 同一个 URL 第二次请求:`X-Cache-Status: HIT`,约 `63.2 MB/s`
|
||||
|
||||
这说明:
|
||||
|
||||
- 首次访问主要受回源链路影响
|
||||
- 命中缓存后,速度会明显提升
|
||||
|
||||
基于当前已验证结果,可以先明确 3 个结论:
|
||||
|
||||
- 这套 `180G` 读缓存方案本身是可行的
|
||||
- 它适合承接 AI 生图平台里“会被重复访问”的热点资源
|
||||
- 在不改业务入口、不迁桶的前提下,就可以先做灰度验证
|
||||
|
||||
## 4. 我还需要 AI 生图平台这边提供什么
|
||||
|
||||
为了把这 `180G` 真正用于 AI 生图平台的热点流量,而不是“缓存了一堆不重要的文件”,还需要下面这些信息。
|
||||
|
||||
### 4.1 需要确认的资源范围
|
||||
|
||||
请提供:
|
||||
|
||||
- AI 生图平台当前实际会读取的资源类型
|
||||
- 这些资源对应的 URL 样例
|
||||
- 最常访问的路径前缀
|
||||
|
||||
最好直接给:
|
||||
|
||||
- 近 3 天最常访问的 20 个资源 URL 样例
|
||||
- 主要路径前缀清单
|
||||
|
||||
例如是否主要集中在:
|
||||
|
||||
- 生图输入参考图
|
||||
- 生图结果图
|
||||
- 放大图、变体图、蒙版图、压缩包等固定路径前缀
|
||||
|
||||
### 4.2 需要确认是否只使用一个主资源域名
|
||||
|
||||
这个点非常关键。
|
||||
|
||||
请确认:
|
||||
|
||||
- AI 生图平台后续是否统一只读 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 是否只作为备用 / 回切,不参与日常默认调用
|
||||
|
||||
推荐口径:
|
||||
|
||||
- 主用:`https://s3.jiansun.vip`
|
||||
- 备用:`https://s3.yydd.ai`
|
||||
|
||||
如果两个域名同时长期对外使用,那么**同一份内容就可能同时出现两个公网 URL**。
|
||||
|
||||
### 4.3 需要确认文件是否“同 key 覆盖”
|
||||
|
||||
这个点决定缓存能不能放心加大。
|
||||
|
||||
请确认:
|
||||
|
||||
- 同一张图后续会不会“覆盖原来的对象 key”
|
||||
- 还是每次生成新图都使用新文件名 / 新路径
|
||||
|
||||
最推荐的方式是:
|
||||
|
||||
- **新内容永远用新 key**
|
||||
|
||||
比如:
|
||||
|
||||
- `image-20260318-001.png`
|
||||
- `image-20260318-002.png`
|
||||
|
||||
不推荐的方式是:
|
||||
|
||||
- 总是写回同一个 URL
|
||||
|
||||
比如始终覆盖:
|
||||
|
||||
- `latest.png`
|
||||
|
||||
原因很简单:
|
||||
|
||||
- 如果同一个 URL 对应的文件内容被覆盖了,缓存里可能暂时还是旧版本
|
||||
- 这样就会出现“URL 没变,但内容短时间还是旧图”的情况
|
||||
|
||||
### 4.4 需要确认时效要求
|
||||
|
||||
请确认:
|
||||
|
||||
- 图片更新后,允许多久内出现旧图
|
||||
- 删除后,是否允许短时间还能访问
|
||||
- 是否存在“必须秒级生效”的路径
|
||||
|
||||
如果某些资源要求非常高的实时性,就不能和普通静态资源用完全同一套缓存策略。
|
||||
|
||||
### 4.5 需要确认流量规模
|
||||
|
||||
请提供一个大致量级即可,不需要特别精确。
|
||||
|
||||
需要知道:
|
||||
|
||||
- 峰值并发大概多少
|
||||
- 每分钟 / 每小时大概多少请求
|
||||
- 热门图片是否会被大量重复读取
|
||||
- 单张图通常多大
|
||||
- 是否有明显的突发高峰时段
|
||||
|
||||
### 4.6 需要一批联调样本
|
||||
|
||||
请提供:
|
||||
|
||||
- 5 到 10 个真实可访问的 AI 生图平台资源 URL
|
||||
- 最好覆盖“结果图 / 参考图 / 较大文件”几类
|
||||
|
||||
这样我可以直接按真实资源做灰度验证,而不是拿不相关对象代替。
|
||||
|
||||
### 4.7 这次不需要 AI 生图平台配合改的内容
|
||||
|
||||
为了避免沟通误差,这里也明确一下当前**不要求**你们先改的东西:
|
||||
|
||||
- 不要求先改接口域名,接口仍然走 `https://sc.jiansun.vip`
|
||||
- 不要求先迁桶,桶本体仍然在 `192.168.58.20:9000`
|
||||
- 不要求先切到 `s3.yydd.ai`
|
||||
- 不要求先改上传链路
|
||||
|
||||
这次优先要拿到的是“真实读流量信息”,不是让业务先大改。
|
||||
|
||||
## 5. 对正常使用有没有影响
|
||||
|
||||
正常情况下,**对用户是无感的**。
|
||||
|
||||
前提是满足下面两个条件:
|
||||
|
||||
- 对外主资源域名统一使用 `https://s3.jiansun.vip`
|
||||
- 资源尽量使用“新内容新 key”的方式,不要反复覆盖同一个 URL
|
||||
|
||||
只要做到这两点,缓存的效果就是:
|
||||
|
||||
- 第一次访问走回源
|
||||
- 后续重复访问直接命中入口缓存
|
||||
- 用户看到的还是同一个公网 URL
|
||||
|
||||
## 6. 会不会出现“同一张图两个 URL”
|
||||
|
||||
### 情况 1:缓存本身
|
||||
|
||||
**不会。**
|
||||
|
||||
缓存不会凭空再生成一个给用户使用的新 URL。
|
||||
|
||||
用户访问的仍然是原来的公网地址,比如:
|
||||
|
||||
```text
|
||||
https://s3.jiansun.vip/xxx.png
|
||||
```
|
||||
|
||||
缓存只是入口服务器内部把这个 URL 对应的内容存到磁盘里。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 对用户看到的 URL 不变
|
||||
- 对程序调用的 URL 不变
|
||||
- 只是入口机内部多了一份缓存文件
|
||||
|
||||
### 情况 2:两个域名同时都在用
|
||||
|
||||
**会。**
|
||||
|
||||
如果下面两个地址同时长期对外使用:
|
||||
|
||||
- `https://s3.jiansun.vip/xxx.png`
|
||||
- `https://s3.yydd.ai/xxx.png`
|
||||
|
||||
那么从“公网 URL”角度看,就是两条 URL 指向了同一份内容。
|
||||
|
||||
所以当前建议非常明确:
|
||||
|
||||
- `s3.jiansun.vip` 主用
|
||||
- `s3.yydd.ai` 备用
|
||||
|
||||
不要两个一起作为默认入口使用。
|
||||
|
||||
## 7. 会不会出现“缓存里一张图,桶里又是另一个 URL”
|
||||
|
||||
从用户角度看,**不会多出一个可见 URL**。
|
||||
|
||||
但有一个需要特别注意的现实问题:
|
||||
|
||||
### 如果同一个 URL 被反复覆盖内容
|
||||
|
||||
那么就可能出现:
|
||||
|
||||
- 桶里已经是新图
|
||||
- 缓存里短时间还是旧图
|
||||
|
||||
这时候不是“两个 URL”,而是:
|
||||
|
||||
- **同一个 URL,在不同时间点可能先返回旧内容,再返回新内容**
|
||||
|
||||
这也是为什么我强烈建议:
|
||||
|
||||
- 新图使用新 key
|
||||
- 不要反复覆盖同一个对象地址
|
||||
|
||||
如果业务上确实必须覆盖同一个 URL,那就必须额外做:
|
||||
|
||||
- 清缓存机制
|
||||
- 或单独降低这类路径的缓存时间
|
||||
|
||||
## 8. 推荐的最终口径
|
||||
|
||||
建议 AI 生图平台后续按下面这套规则执行:
|
||||
|
||||
### 域名规则
|
||||
|
||||
- 主用资源域名:`https://s3.jiansun.vip`
|
||||
- 备用资源域名:`https://s3.yydd.ai`
|
||||
|
||||
### URL 规则
|
||||
|
||||
- 对外只认主域名,不混用两个资源域名
|
||||
- 新内容尽量使用新 key,不覆盖老 key
|
||||
|
||||
### 缓存规则
|
||||
|
||||
- 把 AI 生图平台的热点读流量优先收敛到 `s3.jiansun.vip`
|
||||
- 入口机 `180G` 只做读缓存,不改桶本体位置
|
||||
|
||||
## 9. 现在就可以推进的下一步
|
||||
|
||||
如果 AI 生图平台负责人能补齐下面 4 项,我就可以继续往下收口:
|
||||
|
||||
1. 确认主用资源域名是否统一为 `s3.jiansun.vip`
|
||||
2. 提供 AI 生图平台真实会读取的路径前缀或 URL 样例
|
||||
3. 明确是否存在“同 key 覆盖”
|
||||
4. 提供一组真实联调 URL 用于灰度测试
|
||||
|
||||
拿到这些信息后,我这边会继续做:
|
||||
|
||||
1. 按真实热点路径确认是否需要“分路径缓存策略”
|
||||
2. 区分哪些资源适合长缓存,哪些资源要短缓存
|
||||
3. 如果存在“同 key 覆盖”,评估是否需要补清缓存机制
|
||||
4. 用真实 URL 做灰度验证,再决定是否正式放量
|
||||
|
||||
## 10. 建议负责人直接按下面模板回复
|
||||
|
||||
为了节省来回沟通时间,建议 AI 生图平台负责人直接按这个格式回:
|
||||
|
||||
```text
|
||||
1. 主用资源域名:
|
||||
例如:统一使用 https://s3.jiansun.vip
|
||||
|
||||
2. 是否保留备用域名:
|
||||
例如:保留 https://s3.yydd.ai,仅用于备用,不作为默认入口
|
||||
|
||||
3. 主要读取的资源类型:
|
||||
例如:生图结果图、参考图、放大图、变体图、压缩包
|
||||
|
||||
4. 主要路径前缀或 URL 样例:
|
||||
例如:/gongxiangpan/ai-output/... 或 /gongxiangpan/ai-input/...
|
||||
|
||||
5. 是否存在同 key 覆盖:
|
||||
例如:有 / 没有
|
||||
|
||||
6. 时效要求:
|
||||
例如:更新后允许 5 分钟内旧图;某些路径要求秒级生效
|
||||
|
||||
7. 流量规模:
|
||||
例如:高峰每分钟约多少次读取,热门图片是否会被重复访问
|
||||
|
||||
8. 联调样本:
|
||||
例如:提供 5 到 10 个真实可访问 URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
如果只要一句最短版本,可以直接转给对方:
|
||||
|
||||
**这次不是迁桶,只是在 `180.97.215.57` 上给 `s3.jiansun.vip` 做 180G 读缓存。要继续推进,需要 AI 生图平台确认主用域名、真实热点路径、是否会覆盖同一 URL、以及给一组联调样本。只要统一用一个主域名,并尽量做到新内容新 key,缓存对用户基本无感,也不会凭空多出第二个 URL。**
|
||||
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to yedir-ai will be documented in this file.
|
||||
|
||||
This project follows a pragmatic changelog format inspired by Keep a Changelog. Dates use ISO format.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Lovart home release smoke script via `pnpm smoke:routes`.
|
||||
- Unified local and CI quality gate via `pnpm check:quality`.
|
||||
- Release readiness checklist, deployment guide, operations guide, architecture overview, security policy, contribution guide, support policy, code of conduct, PR template, issue templates, CODEOWNERS, and Dependabot configuration.
|
||||
- Scoped log hygiene gate for high-risk server/client files.
|
||||
- Route-level helper tests for Canvas chat helper behavior.
|
||||
|
||||
### Changed
|
||||
|
||||
- `/` home now prioritizes a lightweight shell and progressively loads personalized data.
|
||||
- `/classic` and `/chat/[id]` legacy UI routes redirect to `/`.
|
||||
- `/api/chat` is documented and treated as an internal Canvas/Agent generation API.
|
||||
- Admin settings local AI sections were modularized into focused components with shared section shells and types.
|
||||
- Lovart home and Canvas/ImageNode presentation surfaces were split into more focused modules.
|
||||
- Chat API internals were partially extracted into focused `_lib` helpers.
|
||||
- CI now reuses the same quality gate command as local development.
|
||||
|
||||
### Security
|
||||
|
||||
- Production logging now uses structured redaction for storage/upload, auth, AI provider, Canvas, scene fusion, Pick Object, Tripo 3D, World Labs, and internal generation paths.
|
||||
- Sensitive values such as tokens, cookies, Authorization headers, signed URLs, base64 payloads, prompts, provider responses, and private user content are documented as non-loggable/non-shareable.
|
||||
- Tracked local artifact checks prevent `.env.local`, `.DS_Store`, swap files, `.claude/`, migration backups, and `tsconfig.tsbuildinfo` from entering Git.
|
||||
- Security disclosure guidance is documented in `SECURITY.md`.
|
||||
|
||||
### Verification
|
||||
|
||||
- Current local release checks:
|
||||
- `pnpm check:quality`
|
||||
- `AUTH_TRUST_HOST=true PORT=3001 pnpm dev`
|
||||
- `SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes`
|
||||
|
||||
### Known Follow-ups
|
||||
|
||||
- Confirm remote GitHub Actions on a pushed PR.
|
||||
- Continue optional log hygiene coverage for remaining historical image enhancement interfaces.
|
||||
- Continue optional modularization of large Canvas/Agent historical modules.
|
||||
31
CODE_OF_CONDUCT.md
Normal file
31
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Code of Conduct
|
||||
|
||||
yedir-ai contributors, maintainers, users, and reviewers are expected to keep collaboration respectful, professional, and focused on improving the project.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
- Be respectful and constructive in issues, pull requests, reviews, and discussions.
|
||||
- Assume good intent, but ask for clarification when something is unclear.
|
||||
- Give actionable feedback with context and concrete examples.
|
||||
- Respect privacy and confidentiality. Do not post secrets, private user data, private prompts, signed URLs, screenshots with credentials, or sensitive business information.
|
||||
- Keep disagreements technical and evidence-based.
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
- Harassment, intimidation, discrimination, personal attacks, or abusive language.
|
||||
- Publishing private information without explicit permission.
|
||||
- Posting real credentials, tokens, cookies, OAuth codes, Authorization headers, signed URLs, or private user content.
|
||||
- Deliberately disrupting discussions, reviews, CI, or project operations.
|
||||
- Ignoring maintainer requests to move security-sensitive details to a private channel.
|
||||
|
||||
## Security-Sensitive Reports
|
||||
|
||||
Do not report vulnerabilities in public issues. Follow `SECURITY.md` and contact the project maintainer privately.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Maintainers may edit or remove comments, close issues, block users, or restrict participation when behavior violates this code of conduct. Enforcement should be proportional, documented when appropriate, and focused on protecting contributors and users.
|
||||
|
||||
## Scope
|
||||
|
||||
This code applies to project spaces including GitHub issues, pull requests, discussions, documentation, community chat, and any other collaboration channel used for yedir-ai.
|
||||
69
CONTRIBUTING.md
Normal file
69
CONTRIBUTING.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Contributing to yedir-ai
|
||||
|
||||
Thank you for contributing. yedir-ai is maintained as a production-oriented AI design workspace, so changes should be easy to review, safe to roll back, and supported by clear verification evidence.
|
||||
|
||||
## Development Setup
|
||||
|
||||
Use Node.js 20 and pnpm 9.12.3.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
pnpm db:migrate
|
||||
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
|
||||
```
|
||||
|
||||
Never commit `.env.local` or real credentials.
|
||||
|
||||
## Branches and Commits
|
||||
|
||||
- Use focused branches for a single theme, such as `refactor/settings-local-ai` or `chore/log-hygiene-canvas`.
|
||||
- Keep commits small enough to review and revert independently.
|
||||
- Do not mix broad formatting changes with functional changes.
|
||||
- Do not push generated local artifacts, worktrees, swap files, migration backups, or `tsconfig.tsbuildinfo`.
|
||||
|
||||
## Change Scope
|
||||
|
||||
Prefer narrow, explicit scopes:
|
||||
|
||||
- UI changes should preserve existing product behavior unless the PR states otherwise.
|
||||
- API changes must describe compatibility impact and response-shape changes.
|
||||
- Settings changes must preserve existing setting keys, SWR keys, and save semantics unless a migration is included.
|
||||
- Logging/security changes must avoid exposing prompts, URLs, base64 data, provider payloads, user content, tokens, cookies, or signed URLs.
|
||||
|
||||
`/api/chat` is currently an internal Canvas/Agent generation API. Do not treat it as a public legacy Chat UI contract unless the PR explicitly migrates callers.
|
||||
|
||||
## Required Checks
|
||||
|
||||
Before opening or updating a PR, run:
|
||||
|
||||
```bash
|
||||
pnpm check:quality
|
||||
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
|
||||
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes
|
||||
```
|
||||
|
||||
`pnpm check:quality` covers lint, scoped log hygiene, typecheck, diff hygiene, and tracked artifact checks.
|
||||
|
||||
If a route or workflow cannot be tested locally, explain the gap in the PR.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Use the PR template and include:
|
||||
|
||||
- What changed and why.
|
||||
- The user or operational impact.
|
||||
- Verification output or screenshots when relevant.
|
||||
- Any intentional follow-up work.
|
||||
|
||||
CODEOWNERS is configured for governance, security, CI, settings, Canvas, API, and infrastructure paths. If your PR touches those areas, expect owner review before merge.
|
||||
|
||||
For frontend changes, include screenshots or a short visual verification note. For route/API changes, include status codes, response-shape notes, or targeted test evidence.
|
||||
|
||||
## Security
|
||||
|
||||
Follow `SECURITY.md` for vulnerability reporting and sensitive-data handling. Report suspected vulnerabilities privately; do not open public issues for secrets, auth bypasses, signed URL leaks, or data exposure.
|
||||
|
||||
## Conduct
|
||||
|
||||
Follow `CODE_OF_CONDUCT.md` in issues, pull requests, reviews, and community spaces.
|
||||
197
README.md
197
README.md
@ -1,71 +1,152 @@
|
||||
<a href="https://chat.vercel.ai/">
|
||||
<img alt="Next.js 14 and App Router-ready AI chatbot." src="app/(chat)/opengraph-image.png">
|
||||
<h1 align="center">Chat SDK</h1>
|
||||
</a>
|
||||
# yedir-ai
|
||||
|
||||
<p align="center">
|
||||
Chat SDK is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications.
|
||||
</p>
|
||||
yedir-ai 是一个面向商业化交付的 AI 设计工作台。它把 Lovart 风格的新首页、无限画布、Agent 工作流、图像生成与编辑、3D/CAD、本地 AI 配置中心和后台治理能力整合到同一个 Next.js 应用中。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://chat-sdk.dev"><strong>Read Docs</strong></a> ·
|
||||
<a href="#features"><strong>Features</strong></a> ·
|
||||
<a href="#model-providers"><strong>Model Providers</strong></a> ·
|
||||
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
|
||||
<a href="#running-locally"><strong>Running locally</strong></a>
|
||||
</p>
|
||||
<br/>
|
||||
这个仓库已经不再定位为通用 Chat 模板。旧 Chat 页面入口已移除,`/api/chat` 目前保留为 Canvas/Agent 等内部生成链路使用。
|
||||
|
||||
## Features
|
||||
## 当前能力
|
||||
|
||||
- [Next.js](https://nextjs.org) App Router
|
||||
- Advanced routing for seamless navigation and performance
|
||||
- React Server Components (RSCs) and Server Actions for server-side rendering and increased performance
|
||||
- [AI SDK](https://ai-sdk.dev/docs/introduction)
|
||||
- Unified API for generating text, structured objects, and tool calls with LLMs
|
||||
- Hooks for building dynamic chat and generative user interfaces
|
||||
- Supports xAI (default), OpenAI, Fireworks, and other model providers
|
||||
- [shadcn/ui](https://ui.shadcn.com)
|
||||
- Styling with [Tailwind CSS](https://tailwindcss.com)
|
||||
- Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility
|
||||
- Data Persistence
|
||||
- [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data
|
||||
- [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage
|
||||
- [Auth.js](https://authjs.dev)
|
||||
- Simple and secure authentication
|
||||
- **Lovart 风格首页**:静态轻量壳优先渲染,站点配置、精选图、项目、教程和会员信息通过客户端渐进加载。
|
||||
- **Canvas/Agent 工作台**:支持多模态生成、图片编辑、素材编排、Agent 模式和项目化创作流程。
|
||||
- **图像与视频生成链路**:支持 Gemini、Volcano、Qwen、FAL、RUXA、Krea、Runway、Midjourney 等可配置供应商。
|
||||
- **3D / CAD 能力**:集成 Tripo3D、World Labs、KittyCAD/Zoo,并支持后台模型别名和 Key 池配置。
|
||||
- **后台设置中心**:本地 AI 设置已拆分为领域组件,支持折叠持久化、状态摘要、快捷目录和统一 section shell。
|
||||
- **生产治理**:包含类型检查、lint、日志脱敏、首页公开接口缓存/降级、仓库污染物检查和 CI 质量门禁。
|
||||
|
||||
## Model Providers
|
||||
## 技术栈
|
||||
|
||||
This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. The default configuration includes [xAI](https://x.ai) models (`grok-2-vision-1212`, `grok-3-mini`) routed through the gateway.
|
||||
- Next.js 16 App Router
|
||||
- React 19 RC
|
||||
- TypeScript
|
||||
- Tailwind CSS + Radix UI
|
||||
- Drizzle ORM + Postgres
|
||||
- Auth.js
|
||||
- Vercel Blob / S3-compatible object storage
|
||||
- Playwright
|
||||
- Ultracite / Biome
|
||||
|
||||
### AI Gateway Authentication
|
||||
## 快速开始
|
||||
|
||||
**For Vercel deployments**: Authentication is handled automatically via OIDC tokens.
|
||||
|
||||
**For non-Vercel deployments**: You need to provide an AI Gateway API key by setting the `AI_GATEWAY_API_KEY` environment variable in your `.env.local` file.
|
||||
|
||||
With the [AI SDK](https://ai-sdk.dev/docs/introduction), you can also switch to direct LLM providers like [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://ai-sdk.dev/providers/ai-sdk-providers) with just a few lines of code.
|
||||
|
||||
## Deploy Your Own
|
||||
|
||||
You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
|
||||
|
||||
[](https://vercel.com/templates/next.js/nextjs-ai-chatbot)
|
||||
|
||||
## Running locally
|
||||
|
||||
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary.
|
||||
|
||||
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various AI and authentication provider accounts.
|
||||
|
||||
1. Install Vercel CLI: `npm i -g vercel`
|
||||
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
||||
3. Download your environment variables: `vercel env pull`
|
||||
准备 Node.js 20、pnpm 9.12.3 和 Postgres。
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm db:migrate # Setup database or apply latest database changes
|
||||
pnpm dev
|
||||
cp .env.example .env.local
|
||||
pnpm db:migrate
|
||||
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
|
||||
```
|
||||
|
||||
Your app template should now be running on [localhost:3000](http://localhost:3000).
|
||||
默认访问地址:
|
||||
|
||||
```text
|
||||
http://localhost:3001/
|
||||
```
|
||||
|
||||
后台设置页:
|
||||
|
||||
```text
|
||||
http://localhost:3001/settings
|
||||
```
|
||||
|
||||
未登录访问后台会重定向到 `/login?callbackUrl=%2Fsettings`。
|
||||
|
||||
## 环境变量
|
||||
|
||||
先从 `.env.example` 复制到 `.env.local`,再按部署环境填写真实值。不要提交 `.env.local` 或任何真实密钥。
|
||||
|
||||
最小本地运行通常需要:
|
||||
|
||||
- `AUTH_SECRET`
|
||||
- `AUTH_URL` 或本地开发使用 `AUTH_TRUST_HOST=true`
|
||||
- `POSTGRES_URL`
|
||||
- `BLOB_READ_WRITE_TOKEN` 或一组 S3 配置
|
||||
- 至少一个可用 AI Provider Key,或在后台设置中配置供应商
|
||||
|
||||
更多分组说明见 [docs/deployment.md](docs/deployment.md)。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
pnpm lint
|
||||
pnpm check:logs
|
||||
pnpm check:brand
|
||||
pnpm check:ci
|
||||
pnpm check:branch
|
||||
pnpm check:env
|
||||
pnpm check:secrets
|
||||
pnpm check:metadata
|
||||
pnpm check:tests
|
||||
pnpm test:helpers
|
||||
pnpm check:artifacts
|
||||
pnpm check:quality
|
||||
pnpm clean:artifacts
|
||||
pnpm audit:security
|
||||
pnpm verify:release
|
||||
pnpm exec tsc --noEmit --pretty false
|
||||
pnpm test
|
||||
pnpm build
|
||||
pnpm build:with-migrate
|
||||
pnpm db:migrate
|
||||
pnpm db:generate
|
||||
```
|
||||
|
||||
推荐提交前至少运行:
|
||||
|
||||
```bash
|
||||
git fetch github main --prune
|
||||
pnpm check:branch
|
||||
pnpm check:quality
|
||||
pnpm audit:security
|
||||
pnpm build
|
||||
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
|
||||
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes
|
||||
```
|
||||
|
||||
发布或创建 PR 前可使用一条命令串起质量、安全、生产构建和路由烟测:
|
||||
|
||||
```bash
|
||||
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
app/ Next.js routes, route handlers, admin pages
|
||||
components/ UI, Canvas, home, editor and product modules
|
||||
components/home/lovart/ Lovart home focused render sections
|
||||
lib/ DB, AI providers, storage, observability, domain logic
|
||||
docs/ Deployment, operations, architecture and feature notes
|
||||
tests/ Playwright e2e and route-level tests
|
||||
.github/workflows/ Manual quality gates, dependency audit and Playwright CI
|
||||
```
|
||||
|
||||
## 部署与运维
|
||||
|
||||
- 生产部署建议使用受控反向代理,并只在 Host 头可信时设置 `AUTH_TRUST_HOST=true`。
|
||||
- 首页公开接口应保持快速返回,慢数据库或外部服务异常时返回缓存或 fallback。
|
||||
- 生产日志默认通过 `LOG_LEVEL=info` 控制,敏感字段会被 `lib/observability` 脱敏。
|
||||
- GitHub Actions workflow 默认只允许手动触发,避免私有仓库免费额度被 push 自动消耗。
|
||||
- Dependency Audit workflow 独立守住 high/critical 依赖审计基线,避免安全回归只靠人工发现。
|
||||
- Playwright workflow 独立于基础质量门禁,避免把密钥依赖和 e2e 成本混入每次 lint/typecheck。
|
||||
|
||||
详细说明:
|
||||
|
||||
- [部署指南](docs/deployment.md)
|
||||
- [运维与质量门禁](docs/operations.md)
|
||||
- [系统架构说明](docs/architecture.md)
|
||||
- [发布就绪检查清单](docs/release-readiness.md)
|
||||
- [交付就绪审查摘要](docs/delivery-readiness-audit.md)
|
||||
- [依赖风险登记](docs/dependency-risk-register.md)
|
||||
- [安全策略](SECURITY.md)
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [行为准则](CODE_OF_CONDUCT.md)
|
||||
- [支持说明](SUPPORT.md)
|
||||
- [更新日志](CHANGELOG.md)
|
||||
|
||||
## 开发边界
|
||||
|
||||
- `/classic` 和 `/chat/[id]` 旧 Chat UI 已重定向到 `/`。
|
||||
- `/api/chat` 暂时是内部生成 API,不作为公开旧 Chat 页面入口。
|
||||
- Canvas、Agent、图像处理和后台设置仍有大型历史模块,后续应按领域继续拆分。
|
||||
- License 以仓库根目录 [LICENSE](LICENSE) 为准;本阶段未修改许可证和版权主体。
|
||||
|
||||
@ -1,902 +0,0 @@
|
||||
# Real-ESRGAN API 接口文档
|
||||
|
||||
## 服务信息
|
||||
|
||||
- **服务地址**: http://156.225.27.52:5233(通过frpc穿透)
|
||||
- **本地端口**: 8002(仅服务器内部访问)
|
||||
- **远程端口**: 5233(外部访问端口)
|
||||
- **API版本**: 1.0.0
|
||||
- **模型**: Real-ESRGAN_x4plus(图像超分辨率模型)
|
||||
- **框架**: FastAPI
|
||||
- **API文档**: http://156.225.27.52:5233/docs
|
||||
- **OpenAPI规范**: http://156.225.27.52:5233/openapi.json
|
||||
- **API密钥**: 所有核心接口需要API密钥认证(详见下方认证说明)
|
||||
|
||||
---
|
||||
|
||||
## 认证方式
|
||||
|
||||
所有核心接口(`/upscale/file`、`/upscale/base64` 和 `/upscale/url`)需要在请求头中添加API密钥:
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw
|
||||
```
|
||||
|
||||
**注意**: 以下端点无需API密钥:
|
||||
- `GET /` - 根端点(服务状态检查)
|
||||
- `GET /health` - 健康检查
|
||||
- `GET /api-key-info` - API密钥信息
|
||||
- `GET /models` - 支持的模型列表
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 根端点 - 服务状态检查
|
||||
|
||||
**接口地址**: `GET /`
|
||||
|
||||
**描述**: 检查服务是否正常运行
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl http://156.225.27.52:5233/
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "Real-ESRGAN API 服务运行正常",
|
||||
"version": "1.0.0",
|
||||
"model": "RealESRGAN_x4plus",
|
||||
"scale": 4
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 健康检查
|
||||
|
||||
**接口地址**: `GET /health`
|
||||
|
||||
**描述**: 检查服务健康状态和模型加载状态(无需API密钥)
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl http://156.225.27.52:5233/health
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"model_loaded": true,
|
||||
"model": "RealESRGAN_x4plus",
|
||||
"scale": 4,
|
||||
"device": "cuda"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. API密钥信息
|
||||
|
||||
**接口地址**: `GET /api-key-info`
|
||||
|
||||
**描述**: 获取API密钥使用说明(无需API密钥)
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl http://156.225.27.52:5233/api-key-info
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"api_key_name": "X-API-Key",
|
||||
"description": "请在请求头中添加API密钥"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 支持的模型列表
|
||||
|
||||
**接口地址**: `GET /models`
|
||||
|
||||
**描述**: 获取所有支持的模型列表(无需API密钥)
|
||||
|
||||
**请求示例**:
|
||||
```bash
|
||||
curl http://156.225.27.52:5233/models
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "RealESRGAN_x2plus",
|
||||
"scale": 2,
|
||||
"description": "2倍放大模型"
|
||||
},
|
||||
{
|
||||
"name": "RealESRGAN_x4plus",
|
||||
"scale": 4,
|
||||
"description": "4倍放大模型(推荐)"
|
||||
},
|
||||
{
|
||||
"name": "RealESRGAN_x8plus",
|
||||
"scale": 8,
|
||||
"description": "8倍放大模型"
|
||||
},
|
||||
{
|
||||
"name": "RealESRGAN_animevideov3",
|
||||
"scale": 4,
|
||||
"description": "动漫视频模型"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 文件上传方式放大图像
|
||||
|
||||
**接口地址**: `POST /upscale/file`
|
||||
|
||||
**描述**: 通过文件上传方式对图片进行超分辨率处理(需要API密钥)
|
||||
|
||||
**Content-Type**: `multipart/form-data`
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| file | File | 是 | - | 图片文件(支持常见图片格式) |
|
||||
| scale | int | 否 | 4 | 放大倍数(2, 4, 8) |
|
||||
| model_name | string | 否 | RealESRGAN_x4plus | 模型名称 |
|
||||
| tile | int | 否 | 0 | 分块大小(0=不使用分块,可根据显存调整) |
|
||||
| half | bool | 否 | false | 是否使用FP16(降低显存需求) |
|
||||
|
||||
**请求示例 (cURL)**:
|
||||
```bash
|
||||
curl -X POST http://156.225.27.52:5233/upscale/file \
|
||||
-H "X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw" \
|
||||
-F "file=@/path/to/your/image.jpg" \
|
||||
-F "scale=4" \
|
||||
-F "model_name=RealESRGAN_x4plus"
|
||||
```
|
||||
|
||||
**请求示例 (Python)**:
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://156.225.27.52:5233/upscale/file"
|
||||
headers = {
|
||||
"X-API-Key": "realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw"
|
||||
}
|
||||
files = {"file": open("image.jpg", "rb")}
|
||||
params = {
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus",
|
||||
"tile": 0,
|
||||
"half": False
|
||||
}
|
||||
response = requests.post(url, files=files, headers=headers, params=params)
|
||||
|
||||
# 保存结果
|
||||
with open("result.png", "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
print(f"处理时间: {response.headers.get('X-Processing-Time')} 秒")
|
||||
print(f"放大倍数: {response.headers.get('X-Scale')}x")
|
||||
print(f"使用模型: {response.headers.get('X-Model')}")
|
||||
```
|
||||
|
||||
**请求示例 (JavaScript/Node.js)**:
|
||||
```javascript
|
||||
const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
const axios = require('axios');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', fs.createReadStream('image.jpg'));
|
||||
form.append('scale', '4');
|
||||
form.append('model_name', 'RealESRGAN_x4plus');
|
||||
|
||||
axios.post('http://156.225.27.52:5233/upscale/file', form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
'X-API-Key': 'realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw'
|
||||
},
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
.then(response => {
|
||||
fs.writeFileSync('result.png', response.data);
|
||||
console.log('处理时间:', response.headers['x-processing-time'], '秒');
|
||||
console.log('放大倍数:', response.headers['x-scale'], 'x');
|
||||
});
|
||||
```
|
||||
|
||||
**响应**:
|
||||
- **Content-Type**: `image/png`
|
||||
- **Body**: PNG格式的图片二进制数据(超分辨率处理后的图片)
|
||||
- **Headers**:
|
||||
- `X-Processing-Time`: 处理时间(秒)
|
||||
- `X-Scale`: 放大倍数
|
||||
- `X-Model`: 使用的模型名称
|
||||
|
||||
**错误响应**:
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid API key"
|
||||
}
|
||||
```
|
||||
(当API密钥无效或缺失时)
|
||||
|
||||
---
|
||||
|
||||
### 6. Base64方式放大图像
|
||||
|
||||
**接口地址**: `POST /upscale/base64`
|
||||
|
||||
**描述**: 通过Base64编码方式对图片进行超分辨率处理(需要API密钥)
|
||||
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"image_base64": "base64编码的图片字符串",
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| image_base64 | string | 是 | - | Base64编码的图片数据 |
|
||||
| scale | int | 否 | 4 | 放大倍数(2, 4, 8) |
|
||||
| model_name | string | 否 | RealESRGAN_x4plus | 模型名称 |
|
||||
| tile | int | 否 | 0 | 分块大小(Query参数) |
|
||||
| half | bool | 否 | false | 是否使用FP16(Query参数) |
|
||||
|
||||
**请求示例 (cURL)**:
|
||||
```bash
|
||||
curl -X POST http://156.225.27.52:5233/upscale/base64 \
|
||||
-H "X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}'
|
||||
```
|
||||
|
||||
**请求示例 (Python)**:
|
||||
```python
|
||||
import requests
|
||||
import base64
|
||||
|
||||
# 读取图片并转换为base64
|
||||
with open("image.jpg", "rb") as f:
|
||||
image_base64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
url = "http://156.225.27.52:5233/upscale/base64"
|
||||
headers = {
|
||||
"X-API-Key": "realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"image_base64": image_base64,
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}
|
||||
params = {
|
||||
"tile": 0,
|
||||
"half": False
|
||||
}
|
||||
response = requests.post(url, json=data, headers=headers, params=params)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
# 保存结果
|
||||
result_image = base64.b64decode(result["image_base64"])
|
||||
with open("result.png", "wb") as f:
|
||||
f.write(result_image)
|
||||
print(f"✅ 成功! 处理时间: {result['processing_time']:.2f}秒")
|
||||
```
|
||||
|
||||
**请求示例 (JavaScript/Node.js)**:
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
const axios = require('axios');
|
||||
|
||||
// 读取图片并转换为base64
|
||||
const imageBuffer = fs.readFileSync('image.jpg');
|
||||
const imageBase64 = imageBuffer.toString('base64');
|
||||
|
||||
axios.post('http://156.225.27.52:5233/upscale/base64', {
|
||||
image_base64: imageBase64,
|
||||
scale: 4,
|
||||
model_name: 'RealESRGAN_x4plus'
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw'
|
||||
},
|
||||
params: {
|
||||
tile: 0,
|
||||
half: false
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
const resultImage = Buffer.from(response.data.image_base64, 'base64');
|
||||
fs.writeFileSync('result.png', resultImage);
|
||||
console.log('处理时间:', response.data.processing_time, '秒');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
|
||||
"processing_time": 2.345,
|
||||
"scale": 4,
|
||||
"model": "RealESRGAN_x4plus"
|
||||
}
|
||||
```
|
||||
|
||||
**响应参数**:
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| success | boolean | 是否成功 |
|
||||
| image_base64 | string | Base64编码的结果图片(PNG格式) |
|
||||
| processing_time | number | 处理时间(秒) |
|
||||
| scale | int | 放大倍数 |
|
||||
| model | string | 使用的模型名称 |
|
||||
|
||||
---
|
||||
|
||||
### 7. URL方式放大图像(上传到S3)
|
||||
|
||||
**接口地址**: `POST /upscale/url`
|
||||
|
||||
**描述**: 通过图片URL对图片进行超分辨率处理,处理后的图片自动上传到S3存储桶并返回URL(需要API密钥)
|
||||
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| image_url | string | 是 | - | 图片的URL地址 |
|
||||
| scale | int | 否 | 4 | 放大倍数(2, 4, 8) |
|
||||
| model_name | string | 否 | RealESRGAN_x4plus | 模型名称 |
|
||||
| tile | int | 否 | 0 | 分块大小(Query参数) |
|
||||
| half | bool | 否 | false | 是否使用FP16(Query参数) |
|
||||
|
||||
**请求示例 (cURL)**:
|
||||
```bash
|
||||
curl -X POST http://156.225.27.52:5233/upscale/url \
|
||||
-H "X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}'
|
||||
```
|
||||
|
||||
**请求示例 (Python)**:
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://156.225.27.52:5233/upscale/url"
|
||||
headers = {
|
||||
"X-API-Key": "realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}
|
||||
params = {
|
||||
"tile": 0,
|
||||
"half": False
|
||||
}
|
||||
response = requests.post(url, json=data, headers=headers, params=params)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"✅ 成功!")
|
||||
print(f"处理后的图片URL: {result['image_url']}")
|
||||
print(f"下载时间: {result['download_time']:.2f}秒")
|
||||
print(f"处理时间: {result['processing_time']:.2f}秒")
|
||||
print(f"上传时间: {result['upload_time']:.2f}秒")
|
||||
print(f"总时间: {result['total_time']:.2f}秒")
|
||||
```
|
||||
|
||||
**请求示例 (JavaScript/Node.js)**:
|
||||
```javascript
|
||||
const axios = require('axios');
|
||||
|
||||
axios.post('http://156.225.27.52:5233/upscale/url', {
|
||||
image_url: 'https://example.com/image.jpg',
|
||||
scale: 4,
|
||||
model_name: 'RealESRGAN_x4plus'
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw'
|
||||
},
|
||||
params: {
|
||||
tile: 0,
|
||||
half: false
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
console.log('✅ 成功!');
|
||||
console.log('处理后的图片URL:', response.data.image_url);
|
||||
console.log('下载时间:', response.data.download_time, '秒');
|
||||
console.log('处理时间:', response.data.processing_time, '秒');
|
||||
console.log('上传时间:', response.data.upload_time, '秒');
|
||||
console.log('总时间:', response.data.total_time, '秒');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ 失败:', error.response?.data || error.message);
|
||||
});
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"image_url": "https://s3.1080p.hk/jsxiangche/realesrgan/20260104/abc123def456.png",
|
||||
"processing_time": 2.345,
|
||||
"download_time": 0.856,
|
||||
"upload_time": 0.234,
|
||||
"total_time": 3.435,
|
||||
"scale": 4,
|
||||
"model": "RealESRGAN_x4plus"
|
||||
}
|
||||
```
|
||||
|
||||
**响应参数**:
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| success | boolean | 是否成功 |
|
||||
| image_url | string | 处理后的图片在S3中的URL地址 |
|
||||
| processing_time | number | 图片处理时间(秒) |
|
||||
| download_time | number | 图片下载时间(秒) |
|
||||
| upload_time | number | 图片上传到S3的时间(秒) |
|
||||
| total_time | number | 总耗时(秒) |
|
||||
| scale | int | 放大倍数 |
|
||||
| model | string | 使用的模型名称 |
|
||||
|
||||
**注意事项**:
|
||||
- 此接口需要S3存储功能已启用(`USE_S3_STORAGE=true`)
|
||||
- 图片URL必须可公开访问
|
||||
- 支持的图片格式:JPG, PNG, JPEG等常见格式
|
||||
- 图片大小限制:最大50MB
|
||||
- 处理后的图片会自动上传到S3存储桶并返回可访问的URL
|
||||
|
||||
---
|
||||
|
||||
## 服务管理
|
||||
|
||||
Real-ESRGAN API服务通过systemd管理,支持自动启动和崩溃自动重启。
|
||||
|
||||
### 服务状态检查
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
systemctl status realesrgan-api.service
|
||||
|
||||
# 查看服务日志
|
||||
journalctl -u realesrgan-api.service -f
|
||||
|
||||
# 查看最近100行日志
|
||||
journalctl -u realesrgan-api.service -n 100
|
||||
```
|
||||
|
||||
### 服务控制命令
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
systemctl start realesrgan-api.service
|
||||
|
||||
# 停止服务
|
||||
systemctl stop realesrgan-api.service
|
||||
|
||||
# 重启服务
|
||||
systemctl restart realesrgan-api.service
|
||||
|
||||
# 重新加载配置(无需重启)
|
||||
systemctl reload realesrgan-api.service
|
||||
|
||||
# 查看服务状态
|
||||
systemctl status realesrgan-api.service
|
||||
```
|
||||
|
||||
### 自动启动配置
|
||||
|
||||
服务已配置为系统启动时自动启动,崩溃后自动重启:
|
||||
|
||||
- **自动启动**: 服务已启用(`systemctl enable realesrgan-api.service`)
|
||||
- **崩溃重启**: `Restart=always`,服务崩溃后自动重启
|
||||
- **重启延迟**: `RestartSec=10`,崩溃后等待10秒再重启
|
||||
- **重启策略**: 服务异常退出时会自动重启,无需手动干预
|
||||
|
||||
### 服务配置
|
||||
|
||||
服务配置文件位置:`/etc/systemd/system/realesrgan-api.service`
|
||||
|
||||
主要配置项:
|
||||
- **工作目录**: `/root/realesrgan-api`
|
||||
- **运行用户**: `root`
|
||||
- **GPU设备**: `CUDA_VISIBLE_DEVICES=1`(使用GPU1)
|
||||
- **S3存储**: 已启用,环境变量已配置
|
||||
- **日志输出**: 通过systemd journal管理
|
||||
|
||||
### 日志查看
|
||||
|
||||
```bash
|
||||
# 实时查看日志
|
||||
journalctl -u realesrgan-api.service -f
|
||||
|
||||
# 查看今天的日志
|
||||
journalctl -u realesrgan-api.service --since today
|
||||
|
||||
# 查看最近的错误日志
|
||||
journalctl -u realesrgan-api.service -p err -n 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| HTTP状态码 | 说明 |
|
||||
|-----------|------|
|
||||
| 200 | 请求成功 |
|
||||
| 400 | 请求参数错误(如文件格式不支持、图片URL无效等) |
|
||||
| 401 | 未授权(API密钥无效或缺失) |
|
||||
| 422 | 请求参数验证失败 |
|
||||
| 500 | 服务器内部错误(如处理失败、S3上传失败等) |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **服务地址**:
|
||||
- 外部访问地址:http://156.225.27.52:5233(通过frpc穿透)
|
||||
- 本地端口:8002(仅服务器内部访问)
|
||||
- 远程端口:5233(外部访问端口)
|
||||
|
||||
2. **API密钥认证**:
|
||||
- 所有核心接口(`/upscale/file`、`/upscale/base64` 和 `/upscale/url`)都需要在请求头中添加 `X-API-Key`
|
||||
- API密钥:`realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw`
|
||||
- 健康检查、模型列表、API密钥信息等端点无需密钥
|
||||
|
||||
3. **支持的图片格式**: 所有常见图片格式(JPG, PNG, JPEG等)
|
||||
|
||||
4. **输出格式**: 所有接口返回PNG格式的图片
|
||||
|
||||
5. **文件大小限制**:
|
||||
- 上传文件:建议不超过50MB
|
||||
- URL方式:图片下载大小限制50MB
|
||||
|
||||
6. **处理时间**: 处理时间取决于图片大小和服务器负载,通常在1-10秒之间
|
||||
|
||||
7. **放大倍数**: 支持2x、4x、8x放大,根据模型而定
|
||||
|
||||
8. **模型选择**:
|
||||
- `RealESRGAN_x4plus`:推荐,4倍放大,适合大多数场景
|
||||
- `RealESRGAN_x2plus`:2倍放大,处理速度快
|
||||
- `RealESRGAN_x8plus`:8倍放大,需要更多显存
|
||||
- `RealESRGAN_animevideov3`:动漫视频模型,4倍放大
|
||||
|
||||
9. **S3存储功能**:
|
||||
- `/upscale/url` 接口需要S3存储功能启用
|
||||
- S3配置可通过环境变量设置
|
||||
- 处理后的图片会自动上传到S3并返回可访问的URL
|
||||
|
||||
10. **CORS**: 服务已配置CORS,支持跨域请求
|
||||
|
||||
11. **服务管理**:
|
||||
- 服务通过systemd管理,服务名称:`realesrgan-api.service`
|
||||
- 系统重启后服务会自动启动(已启用)
|
||||
- 服务崩溃后会在10秒内自动重启(`Restart=always`)
|
||||
- 日志通过systemd journal管理,使用 `journalctl -u realesrgan-api.service` 查看
|
||||
- 服务控制命令:`systemctl start/stop/restart/status realesrgan-api.service`
|
||||
|
||||
---
|
||||
|
||||
## 完整使用示例
|
||||
|
||||
### Python完整示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
API_KEY = "realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw"
|
||||
BASE_URL = "http://156.225.27.52:5233"
|
||||
|
||||
# 方法1: 文件上传
|
||||
def upscale_file(image_path):
|
||||
url = f"{BASE_URL}/upscale/file"
|
||||
headers = {"X-API-Key": API_KEY}
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
files = {"file": f}
|
||||
params = {"scale": 4, "model_name": "RealESRGAN_x4plus"}
|
||||
response = requests.post(url, files=files, headers=headers, params=params)
|
||||
|
||||
if response.status_code == 200:
|
||||
output_path = Path(image_path).stem + "_upscaled.png"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
print(f"✅ 成功! 结果已保存到: {output_path}")
|
||||
return output_path
|
||||
else:
|
||||
print(f"❌ 失败: {response.json()}")
|
||||
return None
|
||||
|
||||
# 方法2: Base64
|
||||
def upscale_base64(image_path):
|
||||
url = f"{BASE_URL}/upscale/base64"
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
image_base64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
data = {
|
||||
"image_base64": image_base64,
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
result_image = base64.b64decode(result["image_base64"])
|
||||
output_path = Path(image_path).stem + "_upscaled.png"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(result_image)
|
||||
print(f"✅ 成功! 处理时间: {result['processing_time']:.2f}秒")
|
||||
return output_path
|
||||
else:
|
||||
print(f"❌ 失败: {response.json()}")
|
||||
return None
|
||||
|
||||
# 方法3: URL方式(上传到S3)
|
||||
def upscale_url(image_url):
|
||||
url = f"{BASE_URL}/upscale/url"
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
data = {
|
||||
"image_url": image_url,
|
||||
"scale": 4,
|
||||
"model_name": "RealESRGAN_x4plus"
|
||||
}
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"✅ 成功!")
|
||||
print(f"处理后的图片URL: {result['image_url']}")
|
||||
print(f"总时间: {result['total_time']:.2f}秒")
|
||||
return result["image_url"]
|
||||
else:
|
||||
print(f"❌ 失败: {response.json()}")
|
||||
return None
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
image_path = "test_image.jpg"
|
||||
|
||||
# 使用文件上传方式
|
||||
upscale_file(image_path)
|
||||
|
||||
# 或使用Base64方式
|
||||
# upscale_base64(image_path)
|
||||
|
||||
# 或使用URL方式
|
||||
# upscale_url("https://example.com/image.jpg")
|
||||
```
|
||||
|
||||
### JavaScript/Node.js完整示例
|
||||
|
||||
```javascript
|
||||
const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
const axios = require('axios');
|
||||
const path = require('path');
|
||||
|
||||
const API_KEY = 'realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw';
|
||||
const BASE_URL = 'http://156.225.27.52:5233';
|
||||
|
||||
// 方法1: 文件上传
|
||||
async function upscaleFile(imagePath) {
|
||||
const form = new FormData();
|
||||
form.append('file', fs.createReadStream(imagePath));
|
||||
form.append('scale', '4');
|
||||
form.append('model_name', 'RealESRGAN_x4plus');
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${BASE_URL}/upscale/file`, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
'X-API-Key': API_KEY
|
||||
},
|
||||
responseType: 'arraybuffer'
|
||||
});
|
||||
|
||||
const outputPath = path.parse(imagePath).name + '_upscaled.png';
|
||||
fs.writeFileSync(outputPath, response.data);
|
||||
console.log(`✅ 成功! 结果已保存到: ${outputPath}`);
|
||||
return outputPath;
|
||||
} catch (error) {
|
||||
console.error('❌ 失败:', error.response?.data || error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2: Base64
|
||||
async function upscaleBase64(imagePath) {
|
||||
const imageBuffer = fs.readFileSync(imagePath);
|
||||
const imageBase64 = imageBuffer.toString('base64');
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${BASE_URL}/upscale/base64`, {
|
||||
image_base64: imageBase64,
|
||||
scale: 4,
|
||||
model_name: 'RealESRGAN_x4plus'
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const outputPath = path.parse(imagePath).name + '_upscaled.png';
|
||||
const resultImage = Buffer.from(response.data.image_base64, 'base64');
|
||||
fs.writeFileSync(outputPath, resultImage);
|
||||
console.log(`✅ 成功! 处理时间: ${response.data.processing_time.toFixed(2)}秒`);
|
||||
return outputPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 失败:', error.response?.data || error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 方法3: URL方式(上传到S3)
|
||||
async function upscaleUrl(imageUrl) {
|
||||
try {
|
||||
const response = await axios.post(`${BASE_URL}/upscale/url`, {
|
||||
image_url: imageUrl,
|
||||
scale: 4,
|
||||
model_name: 'RealESRGAN_x4plus'
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 成功!');
|
||||
console.log('处理后的图片URL:', response.data.image_url);
|
||||
console.log('总时间:', response.data.total_time.toFixed(2), '秒');
|
||||
return response.data.image_url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 失败:', error.response?.data || error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
(async () => {
|
||||
const imagePath = 'test_image.jpg';
|
||||
|
||||
// 使用文件上传方式
|
||||
await upscaleFile(imagePath);
|
||||
|
||||
// 或使用Base64方式
|
||||
// await upscaleBase64(imagePath);
|
||||
|
||||
// 或使用URL方式
|
||||
// await upscaleUrl('https://example.com/image.jpg');
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 交互式API文档
|
||||
|
||||
访问 http://156.225.27.52:5233/docs 可以在浏览器中查看交互式API文档(Swagger UI),支持在线测试接口。
|
||||
|
||||
**注意**: 在Swagger UI中测试需要API密钥的接口时,需要先点击右上角的"Authorize"按钮(如果有),或者直接在请求头中添加 `X-API-Key`。
|
||||
|
||||
---
|
||||
|
||||
## API密钥
|
||||
|
||||
当前API密钥:
|
||||
```
|
||||
realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw
|
||||
```
|
||||
|
||||
**请求头格式**:
|
||||
```
|
||||
X-API-Key: realesrgan_sk_live_MAPF4kLW5QinFz5BO96w-oVk09WnmvBZSyNDG8AxUgo7HGlqssJ4y1XZ38SyQsG9QCsYYOLcp9df6Ku1Jw78Pw
|
||||
```
|
||||
|
||||
**安全提示**:
|
||||
- 请妥善保管API密钥,不要将其暴露在客户端代码中
|
||||
- 建议通过环境变量或配置文件管理API密钥
|
||||
|
||||
---
|
||||
|
||||
## S3存储配置
|
||||
|
||||
如果使用 `/upscale/url` 接口,需要配置S3存储。配置方式如下:
|
||||
|
||||
**环境变量**:
|
||||
```bash
|
||||
USE_S3_STORAGE=true
|
||||
MINIO_S3_ENDPOINT=s3.1080p.hk
|
||||
MINIO_S3_USE_SSL=true
|
||||
MINIO_S3_BUCKET=jsxiangche
|
||||
MINIO_S3_ACCESS_KEY=AEvT1p0wSNjow3Asu9NV
|
||||
MINIO_S3_SECRET_KEY=u8nzFl1S6dNoT5PQaQhkHz8Du6iX0hVnPswtvHhs
|
||||
MINIO_S3_REGION=us-east-1
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `USE_S3_STORAGE`: 必须设置为 `true` 才能启用S3上传功能
|
||||
- 处理后的图片会保存到S3存储桶中,路径格式:`realesrgan/YYYYMMDD/UUID.png`
|
||||
- 返回的URL可以直接访问和下载
|
||||
|
||||
72
SECURITY.md
Normal file
72
SECURITY.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Security Policy
|
||||
|
||||
Security is part of the release contract for yedir-ai. This project handles AI provider keys, OAuth credentials, object-storage URLs, user uploads, generated media, and internal generation workflows, so disclosures should be handled carefully and privately.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the active `main` branch and release branches maintained by the project owner. Older experimental branches are not guaranteed to receive patches.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Do not open a public issue for suspected vulnerabilities.
|
||||
|
||||
Report privately to the repository owner or project maintainer with:
|
||||
|
||||
- A short description of the issue and affected area.
|
||||
- Reproduction steps or a minimal proof of concept.
|
||||
- Potential impact, including whether secrets, user data, uploads, signed URLs, or internal APIs are exposed.
|
||||
- Relevant logs with secrets, cookies, Authorization headers, OAuth codes, signed URLs, prompts, base64 data, and private user content removed.
|
||||
|
||||
Expected handling:
|
||||
|
||||
- The maintainer will acknowledge the report when it is received.
|
||||
- Valid issues will be triaged by impact and exploitability.
|
||||
- Fixes should include regression coverage or a documented verification path where practical.
|
||||
- Public disclosure should wait until a fix or mitigation is available.
|
||||
|
||||
## Sensitive Data Rules
|
||||
|
||||
Never commit or paste:
|
||||
|
||||
- `.env.local` or real environment variables.
|
||||
- API keys, OAuth client secrets, OAuth codes, cookies, session tokens, Authorization headers, or passwords.
|
||||
- Signed object-storage URLs, raw upload URLs, private bucket paths, or third-party task payloads.
|
||||
- Full prompts, base64 image/video data, or private generated media unless explicitly sanitized.
|
||||
|
||||
Use `.env.example` for placeholders only.
|
||||
|
||||
## Logging Rules
|
||||
|
||||
High-risk server logs should use `createLogger` from `lib/observability/logger`. Browser logs should use `createClientLogger` and stay silent in production unless explicitly enabled.
|
||||
|
||||
Before merging security-sensitive changes, run:
|
||||
|
||||
```bash
|
||||
pnpm check:quality
|
||||
pnpm audit:security
|
||||
```
|
||||
|
||||
For route-level release smoke tests, also run:
|
||||
|
||||
```bash
|
||||
AUTH_TRUST_HOST=true PORT=3001 pnpm dev
|
||||
SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes
|
||||
```
|
||||
|
||||
If a file starts logging prompts, URLs, provider responses, upload details, OAuth data, or user content, add it to `scripts/check-log-hygiene.mjs` and ensure `pnpm check:logs` passes.
|
||||
|
||||
Dependency audit risk is tracked in `docs/dependency-risk-register.md`. Critical/high advisories in runtime dependencies should be remediated or explicitly accepted before production release.
|
||||
|
||||
## Deployment Security Notes
|
||||
|
||||
- Configure `AUTH_SECRET` and production `AUTH_URL`.
|
||||
- Use `AUTH_TRUST_HOST=true` only behind a trusted reverse proxy with validated Host headers.
|
||||
- Keep database, storage, OAuth, and AI provider credentials in the deployment secret store.
|
||||
- Prefer short database connect timeouts and bounded connection pools.
|
||||
- Verify `/settings` remains protected and redirects unauthenticated users to login.
|
||||
|
||||
See also:
|
||||
|
||||
- `docs/deployment.md`
|
||||
- `docs/operations.md`
|
||||
- `docs/release-readiness.md`
|
||||
228
SECURITY_IMPROVEMENTS_SUMMARY.md
Normal file
228
SECURITY_IMPROVEMENTS_SUMMARY.md
Normal file
@ -0,0 +1,228 @@
|
||||
# 安全改进总结
|
||||
|
||||
**日期**: 2026-05-05
|
||||
**改进范围**: Phase 1 (安全短板) + Phase 2 (部分工程基础)
|
||||
|
||||
---
|
||||
|
||||
## 已完成的改进
|
||||
|
||||
### Phase 1: 安全短板修复 ✅
|
||||
|
||||
#### 1. 修复硬编码管理员密码 (create-admin.ts)
|
||||
**风险等级**: 🟢 低风险
|
||||
**改动**:
|
||||
- 从环境变量 `ADMIN_EMAIL` / `ADMIN_PASSWORD` 读取凭据
|
||||
- 如未设置则自动生成安全随机密码
|
||||
- 不再将密码打印到 console(仅在自动生成时打印一次)
|
||||
|
||||
**向后兼容**: ✅ 是(未设置环境变量时使用默认邮箱 + 随机密码)
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 测试 1: 使用环境变量
|
||||
ADMIN_EMAIL=admin@test.com ADMIN_PASSWORD=SecurePass123! node create-admin.ts
|
||||
|
||||
# 测试 2: 自动生成密码
|
||||
node create-admin.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. 修复 trustHost 安全配置
|
||||
**风险等级**: 🟡 中等风险
|
||||
**改动**:
|
||||
- `app/(auth)/auth.config.ts`: `trustHost` 改为条件启用
|
||||
- Vercel 环境 (`VERCEL=1`) 自动开启
|
||||
- 其他环境需显式设置 `AUTH_TRUST_HOST=true`
|
||||
- `app/(auth)/auth.ts`: 移除重复的 `trustHost: true` 覆盖
|
||||
|
||||
**潜在影响**:
|
||||
- ⚠️ 非 Vercel 部署且未设置 `AUTH_TRUST_HOST=true` 时,NextAuth 会严格校验 Host 头
|
||||
- 如果反向代理未正确传递 Host 头,可能导致登录失败
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 开发环境测试登录流程
|
||||
pnpm dev
|
||||
# 访问 http://localhost:3000/login 测试登录
|
||||
|
||||
# 生产环境部署前检查
|
||||
echo $VERCEL # Vercel 环境应为 "1"
|
||||
echo $AUTH_TRUST_HOST # 非 Vercel 环境应设为 "true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. 添加 HTTP 安全头
|
||||
**风险等级**: 🟢 低风险
|
||||
**改动**: `next.config.ts` 新增 `headers()` 函数
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: SAMEORIGIN`
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||
- `Content-Security-Policy` (开发环境允许 unsafe-eval,生产环境更严格)
|
||||
|
||||
**潜在影响**:
|
||||
- CSP 可能阻止某些第三方脚本(如未在 `connect-src` 白名单中的 API)
|
||||
- 如有嵌入需求(iframe),需调整 `X-Frame-Options`
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 启动后检查响应头
|
||||
curl -I http://localhost:3000 | grep -E "X-Content-Type|X-Frame|CSP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. 创建 middleware.ts 接入 proxy.ts
|
||||
**风险等级**: 🔴 **高风险** - 需重点测试
|
||||
**改动**: 新建 `/middleware.ts`
|
||||
```typescript
|
||||
export { proxy as middleware, config } from "./proxy";
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- ✅ 限流功能**首次生效**(之前 proxy.ts 存在但未被 Next.js 加载)
|
||||
- ✅ IP 黑名单检查生效
|
||||
- ✅ 可疑请求检测生效
|
||||
- ⚠️ 所有 API 路由现在都会经过中间件检查
|
||||
|
||||
**潜在问题**:
|
||||
1. **限流过严**: 如果用户正常使用触发限流(如画布自动保存频繁),会返回 429
|
||||
2. **误判可疑请求**: `lib/security/request-monitor.ts` 的规则可能误报
|
||||
3. **性能影响**: 每个请求都要经过 IP 检查、限流检查、可疑模式匹配
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 1. 检查中间件是否加载
|
||||
pnpm build
|
||||
cat .next/server/middleware-manifest.json # 应有内容,不再为空
|
||||
|
||||
# 2. 测试限流
|
||||
# 快速刷新页面 100 次,应触发 429
|
||||
|
||||
# 3. 测试正常流程
|
||||
# 登录、创建画布、上传图片、聊天 - 确保都正常工作
|
||||
|
||||
# 4. 检查日志
|
||||
# 查看是否有 "[Security] Rate limit exceeded" 等日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. 补充 .env.example
|
||||
**风险等级**: 🟢 无风险(纯文档)
|
||||
**新增条目**:
|
||||
- `AUTH_URL` / `NEXTAUTH_URL`
|
||||
- `AUTH_TRUST_HOST`
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||
- `ADMIN_EMAIL` / `ADMIN_PASSWORD`
|
||||
- `FAL_KEY`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 工程基础改进 ✅
|
||||
|
||||
#### 6. 建立结构化日志系统
|
||||
**风险等级**: 🟢 低风险
|
||||
**新增文件**: `lib/logger.ts`
|
||||
- 提供 `log.info()`, `log.error()`, `log.warn()`, `log.debug()`
|
||||
- 支持模块化日志:`createLogger("ModuleName")`
|
||||
- 开发环境可读格式,生产环境 JSON 格式
|
||||
- 通过 `LOG_LEVEL` 环境变量控制输出级别
|
||||
|
||||
**使用示例**:
|
||||
```typescript
|
||||
import { createLogger } from "@/lib/logger";
|
||||
const log = createLogger("MyService");
|
||||
log.error("Operation failed", error, { userId: "123" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 7. 修复 service 层错误吞没
|
||||
**风险等级**: 🟡 中等风险
|
||||
**改动**:
|
||||
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
||||
|
||||
**潜在问题**:
|
||||
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
||||
|
||||
---
|
||||
|
||||
#### 8. 更新安全文档
|
||||
**风险等级**: 🟢 无风险(纯文档)
|
||||
**改动**: `lib/security/IMPLEMENTATION_GUIDE.md` 更新至实际状态
|
||||
|
||||
---
|
||||
|
||||
## 未完成的任务
|
||||
|
||||
### Phase 2 剩余
|
||||
- ❌ 为缺少验证的路由补充 Zod schema (enhance, rmbg, canvas/text-edit 等)
|
||||
- ❌ 迁移剩余 service 文件的错误处理(canvas 有 31 个 catch 块未完全迁移)
|
||||
|
||||
### Phase 3
|
||||
- ❌ 补充核心单元测试
|
||||
- ❌ 添加 Dockerfile 支持
|
||||
|
||||
---
|
||||
|
||||
## 回滚方案
|
||||
|
||||
如果部署后出现问题,可以快速回滚:
|
||||
|
||||
### 回滚 middleware(最高优先级)
|
||||
```bash
|
||||
# 删除 middleware.ts,限流和安全检查将停止工作
|
||||
rm middleware.ts
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 回滚 trustHost
|
||||
```bash
|
||||
# 在 auth.config.ts 中改回
|
||||
trustHost: true,
|
||||
```
|
||||
|
||||
### 回滚其他改动
|
||||
```bash
|
||||
git diff HEAD # 查看所有改动
|
||||
git checkout HEAD -- <file> # 回滚单个文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署前检查清单
|
||||
|
||||
- [ ] 在开发环境运行 `pnpm dev`,确保无编译错误
|
||||
- [ ] 测试登录/登出流程
|
||||
- [ ] 测试聊天、画布、图片生成等核心功能
|
||||
- [ ] 快速刷新页面,确认限流触发(应看到 429 错误)
|
||||
- [ ] 检查 `.next/server/middleware-manifest.json` 不为空
|
||||
- [ ] 设置生产环境变量:
|
||||
- `AUTH_TRUST_HOST=true`(如非 Vercel)
|
||||
- `LOG_LEVEL=warn`(生产环境)
|
||||
- `ADMIN_PASSWORD=<secure-password>`(如需创建管理员)
|
||||
- [ ] 运行 `pnpm build` 确保生产构建成功
|
||||
|
||||
---
|
||||
|
||||
## 监控建议
|
||||
|
||||
部署后前 24 小时重点监控:
|
||||
|
||||
1. **错误率**: 是否有大量 429 (限流) 或 403 (IP 黑名单) 错误
|
||||
2. **登录成功率**: trustHost 改动可能影响登录
|
||||
3. **日志输出**: 检查是否有 "[Security]" 前缀的警告
|
||||
4. **性能**: 中间件增加的延迟(应 <10ms)
|
||||
|
||||
---
|
||||
|
||||
## 联系与支持
|
||||
|
||||
如遇问题,优先检查:
|
||||
1. `.next/server/middleware-manifest.json` 是否正确生成
|
||||
2. 浏览器控制台是否有 CSP 违规警告
|
||||
3. 服务器日志中是否有 "[Security]" 相关错误
|
||||
247
SPU搜索获取图片接口文档.md
Normal file
247
SPU搜索获取图片接口文档.md
Normal file
@ -0,0 +1,247 @@
|
||||
# AI 生图平台白底图接口文档
|
||||
|
||||
> 用途:AI 生图平台输入 `SKU` 后,直接从素材库获取该 `SKU` 对应的白底图。
|
||||
> 当前主域名:`https://sc.jiansun.vip`
|
||||
> 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- **接口基地址**:`https://sc.jiansun.vip/api/external/`
|
||||
- **鉴权方式**:请求头 `X-API-Key`
|
||||
- **返回格式**:`application/json`
|
||||
- **适用范围**:仅适用于 `标准模型` 类型素材
|
||||
|
||||
## 2. 接口说明
|
||||
|
||||
### 2.1 按 SKU 查询白底图
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?sku={SKU}
|
||||
X-API-Key: 5073534d7a59babbc45397a8eac5943b
|
||||
```
|
||||
|
||||
**请求参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `sku` | string | 是 | SKU 名称。接口按大小写不敏感查询。 |
|
||||
|
||||
**请求头**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `X-API-Key` | string | 是 | 外部接口访问密钥 |
|
||||
|
||||
## 3. 成功响应
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"sku": "text001452",
|
||||
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
|
||||
"productModel": "TEXT0014",
|
||||
"whiteBackground": {
|
||||
"fixed": [
|
||||
{
|
||||
"key": "leftFront45",
|
||||
"label": "左前45°",
|
||||
"fileName": "左前45°.png",
|
||||
"fileSize": 68,
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx"
|
||||
},
|
||||
{
|
||||
"key": "rightFront45",
|
||||
"label": "右前45°",
|
||||
"fileName": "右前45°.png",
|
||||
"fileSize": 68,
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx"
|
||||
}
|
||||
],
|
||||
"other": [
|
||||
{
|
||||
"key": "other",
|
||||
"label": "其他图片",
|
||||
"fileName": "01-other-1.png",
|
||||
"fileSize": 68,
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx"
|
||||
}
|
||||
]
|
||||
},
|
||||
"downloadUrls": {
|
||||
"zip": "https://3d.yydd.ai/api/external/download?token=xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `data.sku` | string | 实际命中的 SKU 名称 |
|
||||
| `data.materialId` | string | 素材 ID |
|
||||
| `data.productModel` | string | 所属 SPU / 型号 |
|
||||
| `data.whiteBackgroundUploaded` | boolean | 是否已有白底图 |
|
||||
| `data.whiteBackgroundComplete` | boolean | 固定 8 张是否已补齐 |
|
||||
| `data.whiteBackgroundFixedCount` | number | 当前已上传的固定角度图数量 |
|
||||
| `data.whiteBackgroundOtherCount` | number | 当前已上传的 other 图片数量 |
|
||||
| `data.whiteBackgroundCount` | number | 当前白底图总数 |
|
||||
| `data.missingViews` | array | 当前仍缺少的固定角度图 |
|
||||
| `data.whiteBackground.fixed` | array | 当前已有的固定角度白底图,按固定顺序返回已存在项 |
|
||||
| `data.whiteBackground.other` | array | 其他图片,可为空数组 |
|
||||
| `data.downloadUrls.zip` | string | 当前 SKU 白底图整包 ZIP 下载链接 |
|
||||
|
||||
### 3.2 `fixed` 固定图顺序
|
||||
|
||||
固定图返回顺序固定为:
|
||||
|
||||
1. `leftFront45`:左前45°
|
||||
2. `rightFront45`:右前45°
|
||||
3. `leftBack45`:左后45°
|
||||
4. `rightBack45`:右后45°
|
||||
5. `front`:正视图
|
||||
6. `back`:后视图
|
||||
7. `left`:左视图
|
||||
8. `right`:右视图
|
||||
|
||||
### 3.3 单张图片对象字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `key` | string | 固定图为固定角度 key;其他图固定为 `other` |
|
||||
| `label` | string | 图片说明 |
|
||||
| `fileName` | string | 文件名 |
|
||||
| `fileSize` | number | 文件大小,单位字节 |
|
||||
| `downloadUrl` | string | 单张图片下载链接 |
|
||||
|
||||
## 4. 下载方式
|
||||
|
||||
### 4.1 下载单张白底图
|
||||
|
||||
直接请求响应里的 `downloadUrl` 即可:
|
||||
|
||||
```http
|
||||
GET https://3d.yydd.ai/api/external/download?token=xxx
|
||||
```
|
||||
|
||||
返回内容为对应图片文件,例如:
|
||||
|
||||
- `image/png`
|
||||
- `image/jpeg`
|
||||
|
||||
### 4.2 下载整包白底图 ZIP
|
||||
|
||||
直接请求:
|
||||
|
||||
```http
|
||||
GET https://3d.yydd.ai/api/external/download?token=xxx
|
||||
```
|
||||
|
||||
当 token 对应的是白底图 ZIP 时,返回:
|
||||
|
||||
- `Content-Type: application/zip`
|
||||
- 文件名:`{SKU}_白底图.zip`
|
||||
|
||||
## 5. 错误响应
|
||||
|
||||
### 5.1 SKU 不存在
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "SKU_NOT_FOUND",
|
||||
"message": "未找到对应的标准模型 SKU"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 SKU 数据重复
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "SKU_NOT_UNIQUE",
|
||||
"message": "该 SKU 存在重复数据,暂无法对外提供白底图"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 白底图不存在
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "WHITE_BACKGROUND_NOT_FOUND",
|
||||
"message": "该 SKU 暂无白底图"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 API Key 无效
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "INVALID_API_KEY",
|
||||
"message": "API Key 无效或缺失"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 接入建议
|
||||
|
||||
- **推荐主流程**:先查接口 → 拿到 `fixed` / `other` → 按需下载单张图
|
||||
- 如果业务需要一次性拉全量,也可以直接使用 `downloadUrls.zip`
|
||||
- `sku` 查询支持大小写不敏感,但建议调用方统一按大写传参,便于排查
|
||||
- 接口不再要求固定 8 张必须补齐;只要该 SKU 已有任意白底图,就会返回当前已有图片
|
||||
- 下载链接为带 token 的受控链接,建议由服务端调用,不建议直接暴露到前端页面
|
||||
|
||||
## 7. cURL 示例
|
||||
|
||||
### 7.1 查询白底图
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://3d.yydd.ai/api/external/standard-model-white-background?sku=TEXT001452" \
|
||||
-H "X-API-Key: 5073534d7a59babbc45397a8eac5943b"
|
||||
```
|
||||
|
||||
### 7.2 下载单张图片
|
||||
|
||||
```bash
|
||||
curl -L \
|
||||
"https://3d.yydd.ai/api/external/download?token=xxx" \
|
||||
-o left-front-45.png
|
||||
```
|
||||
|
||||
### 7.3 下载整包 ZIP
|
||||
|
||||
```bash
|
||||
curl -L \
|
||||
"https://3d.yydd.ai/api/external/download?token=xxx" \
|
||||
-o TEXT001452_白底图.zip
|
||||
```
|
||||
|
||||
## 8. 当前联调结论
|
||||
|
||||
已验证:
|
||||
|
||||
- 旧任务平台接口 `GET /api/external/product-assets` 正常
|
||||
- 旧下载接口 `GET /api/external/download?token=...` 正常
|
||||
- 新白底图接口 `GET /api/external/standard-model-white-background` 正常
|
||||
- 新白底图单图下载正常
|
||||
|
||||
如果后续还要继续开放更多素材接口,建议继续复用当前这套:
|
||||
|
||||
- `X-API-Key`
|
||||
- `/api/external/*`
|
||||
- token 下载链接
|
||||
504
SPU详情接口文档.md
Normal file
504
SPU详情接口文档.md
Normal file
@ -0,0 +1,504 @@
|
||||
# AI 生图平台素材接口文档
|
||||
|
||||
> 用途:AI 生图平台统一通过同一条接口,查询 `标准模型白底图` 或 `任务-详情整套预览下载链接`。
|
||||
> 当前主域名:`https://sc.jiansun.vip`
|
||||
> 当前资源公网域名:`https://s3.jiansun.vip`
|
||||
> 旧应用域名:`https://3d.yydd.ai`(仅保留兼容跳转)
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- 接口基地址:`https://sc.jiansun.vip/api/external/`
|
||||
- 鉴权方式:请求头 `X-API-Key`
|
||||
- 请求方式:`GET`
|
||||
- 返回格式:`application/json`
|
||||
- 主接口路径:`/api/external/standard-model-white-background`
|
||||
- 默认资源类型:`white-background`
|
||||
|
||||
返回统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
失败时:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "错误说明"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 通用请求规则
|
||||
|
||||
### 2.1 请求头
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `X-API-Key` | 是 | 外部接口访问密钥 |
|
||||
|
||||
### 2.2 查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `resourceType` | 否 | 资源类型。可选 `white-background` 或 `task-detail`。不传时默认 `white-background` |
|
||||
| `sku` | 条件必填 | `sku` 和 `spu` 二选一即可 |
|
||||
| `spu` | 条件必填 | `sku` 和 `spu` 二选一即可 |
|
||||
|
||||
补充说明:
|
||||
|
||||
- `sku`、`spu` 都按大小写不敏感查询,建议调用方统一传大写。
|
||||
- 如果 `sku` 和 `spu` 同时传入,接口优先按 `spu` 处理。
|
||||
- `shopName` 当前与 `categoryName` 含义一致,都是“店铺”。
|
||||
|
||||
## 3. 白底图查询
|
||||
|
||||
### 3.1 适用场景
|
||||
|
||||
- `resourceType=white-background`
|
||||
- 不传 `resourceType` 时也走这一分支,兼容老调用方
|
||||
- 支持两种查询方式:
|
||||
- `sku`:直接拿到该 SKU 的完整白底图信息
|
||||
- `spu`:先拿到该 SPU 下全部 SKU 清单,再让用户选 SKU
|
||||
|
||||
---
|
||||
|
||||
### 3.2 按 SKU 查询白底图
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=white-background&sku={SKU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
兼容老调用方式:
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?sku={SKU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "white-background",
|
||||
"sku": "TEXT001452",
|
||||
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
|
||||
"productModel": "TEXT0014",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"whiteBackgroundUploaded": true,
|
||||
"whiteBackgroundComplete": false,
|
||||
"whiteBackgroundFixedCount": 2,
|
||||
"whiteBackgroundOtherCount": 1,
|
||||
"whiteBackgroundCount": 3,
|
||||
"missingViews": [
|
||||
{
|
||||
"key": "leftBack45",
|
||||
"label": "左后45°"
|
||||
}
|
||||
],
|
||||
"whiteBackground": {
|
||||
"fixed": [
|
||||
{
|
||||
"key": "leftFront45",
|
||||
"label": "左前45°",
|
||||
"fileName": "左前45°.png",
|
||||
"fileSize": 123456,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
|
||||
},
|
||||
{
|
||||
"key": "rightFront45",
|
||||
"label": "右前45°",
|
||||
"fileName": "右前45°.png",
|
||||
"fileSize": 123456,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=yyy"
|
||||
}
|
||||
],
|
||||
"other": [
|
||||
{
|
||||
"key": "other-1",
|
||||
"label": "其他图片1",
|
||||
"fileName": "detail-1.png",
|
||||
"fileSize": 123456,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=zzz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"downloadUrls": {
|
||||
"zip": "https://sc.jiansun.vip/api/external/download?token=zip-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `resourceType` | 固定为 `white-background` |
|
||||
| `sku` | 实际命中的 SKU |
|
||||
| `materialId` | 标准模型素材 ID |
|
||||
| `productModel` | 所属 SPU |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `whiteBackgroundUploaded` | 是否已有白底图 |
|
||||
| `whiteBackgroundComplete` | 固定 8 个角度是否已补齐 |
|
||||
| `whiteBackgroundFixedCount` | 已有固定角度图数量 |
|
||||
| `whiteBackgroundOtherCount` | 已有 other 图数量 |
|
||||
| `whiteBackgroundCount` | 白底图总数 |
|
||||
| `missingViews` | 还缺少的固定角度图 |
|
||||
| `whiteBackground.fixed` | 固定角度白底图列表 |
|
||||
| `whiteBackground.other` | 其他白底图列表 |
|
||||
| `downloadUrls.zip` | 当前 SKU 白底图 ZIP 下载链接 |
|
||||
|
||||
固定角度顺序固定为:
|
||||
|
||||
1. `leftFront45`
|
||||
2. `rightFront45`
|
||||
3. `leftBack45`
|
||||
4. `rightBack45`
|
||||
5. `front`
|
||||
6. `back`
|
||||
7. `left`
|
||||
8. `right`
|
||||
|
||||
单张图片对象字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `key` | 固定图为固定角度 key,other 图为 `other-*` |
|
||||
| `label` | 图片说明 |
|
||||
| `fileName` | 文件名 |
|
||||
| `fileSize` | 文件大小,单位字节 |
|
||||
| `downloadUrl` | 单张白底图下载链接 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 按 SPU 查询白底图 SKU 清单
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=white-background&spu={SPU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "white-background",
|
||||
"spu": "TEXT0014",
|
||||
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"skuCount": 2,
|
||||
"skuList": [
|
||||
{
|
||||
"skuName": "TEXT001452",
|
||||
"skuId": "sku-id-1",
|
||||
"hasWhiteBackground": true,
|
||||
"whiteBackgroundCount": 2,
|
||||
"previewImage": {
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "右前45°.png",
|
||||
"fileSize": 123456
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuName": "TEXT001453",
|
||||
"skuId": "sku-id-2",
|
||||
"hasWhiteBackground": false,
|
||||
"whiteBackgroundCount": 0,
|
||||
"previewImage": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `resourceType` | 固定为 `white-background` |
|
||||
| `spu` | 实际命中的 SPU |
|
||||
| `materialId` | 标准模型素材 ID |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `skuCount` | 该 SPU 下 SKU 数量 |
|
||||
| `skuList[].skuName` | SKU 名称 |
|
||||
| `skuList[].skuId` | SKU 记录 ID |
|
||||
| `skuList[].hasWhiteBackground` | 是否已有任意白底图 |
|
||||
| `skuList[].whiteBackgroundCount` | 当前固定角度白底图数量 |
|
||||
| `skuList[].previewImage` | 右前 45° 预览图;如果没有该角度则为 `null` |
|
||||
|
||||
说明:
|
||||
|
||||
- 这个接口主要给 AI 平台先展示 SKU 列表用。
|
||||
- 选定 SKU 后,再调用“按 SKU 查询白底图”接口拿完整白底图信息。
|
||||
|
||||
## 4. 任务-详情整套查询
|
||||
|
||||
### 4.1 适用场景
|
||||
|
||||
- `resourceType=task-detail`
|
||||
- 只返回:
|
||||
- `materialType='任务-详情'`
|
||||
- `isFullSet=true`
|
||||
- `deletedAt=null`
|
||||
的候选素材
|
||||
- 返回的是**预览图直链数组**,不是 zip,也不是 token 下载链接
|
||||
- 返回顺序按 `uploadedAt` 倒序,也就是最新上传的排前面
|
||||
|
||||
---
|
||||
|
||||
### 4.2 按 SPU 查询任务-详情整套候选
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=task-detail&spu={SPU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "task-detail",
|
||||
"spu": "SPU001",
|
||||
"sku": null,
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"candidateCount": 2,
|
||||
"candidates": [
|
||||
{
|
||||
"materialId": "material-2",
|
||||
"taskNo": "TASK202601141713525624",
|
||||
"productModel": "SPU001",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"uploadedAt": "2026-03-18T10:00:00.000Z",
|
||||
"isFullSet": true,
|
||||
"downloadUrls": {
|
||||
"main": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
|
||||
],
|
||||
"sku": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
|
||||
],
|
||||
"detail": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
|
||||
],
|
||||
"all": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 按 SKU 查询任务-详情整套候选
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=task-detail&sku={SKU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**处理逻辑**
|
||||
|
||||
- 先通过 `SKU -> 标准模型 SPU`
|
||||
- 再按这个 SPU 查询所有整套 `任务-详情` 候选
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "task-detail",
|
||||
"spu": "SPU001",
|
||||
"sku": "SKU001",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"candidateCount": 1,
|
||||
"candidates": [
|
||||
{
|
||||
"materialId": "material-2",
|
||||
"taskNo": "TASK202601141713525624",
|
||||
"productModel": "SPU001",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"uploadedAt": "2026-03-18T10:00:00.000Z",
|
||||
"isFullSet": true,
|
||||
"downloadUrls": {
|
||||
"main": [],
|
||||
"sku": [],
|
||||
"detail": [],
|
||||
"all": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**候选素材字段说明**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `materialId` | 任务-详情素材 ID |
|
||||
| `taskNo` | 任务号 |
|
||||
| `productModel` | 对应 SPU |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `sceneName` | 场景 |
|
||||
| `uploadedAt` | 上传时间,ISO 8601 格式 |
|
||||
| `isFullSet` | 是否整套,返回结果中固定为 `true` |
|
||||
| `downloadUrls.main` | 主图预览直链数组 |
|
||||
| `downloadUrls.sku` | SKU 图预览直链数组 |
|
||||
| `downloadUrls.detail` | 详情图预览直链数组 |
|
||||
| `downloadUrls.all` | `main + sku + detail` 合并后的数组 |
|
||||
|
||||
说明:
|
||||
|
||||
- `task-detail` 分支返回的是**全部候选**,不是只返回一条。
|
||||
- 预览链接可以直接打开或下载。
|
||||
- 历史 `任务-详情` 如果没有标记 `isFullSet=true`,不会被返回。
|
||||
|
||||
## 5. 下载说明
|
||||
|
||||
### 5.1 白底图
|
||||
|
||||
白底图返回的是下载链接:
|
||||
|
||||
- 单张图:`whiteBackground.fixed[*].downloadUrl` / `whiteBackground.other[*].downloadUrl`
|
||||
- 整包 ZIP:`downloadUrls.zip`
|
||||
|
||||
调用方式:
|
||||
|
||||
```http
|
||||
GET https://sc.jiansun.vip/api/external/download?token=xxx
|
||||
```
|
||||
|
||||
### 5.2 任务-详情整套
|
||||
|
||||
任务-详情返回的是可直接访问的预览图直链数组:
|
||||
|
||||
- `downloadUrls.main`
|
||||
- `downloadUrls.sku`
|
||||
- `downloadUrls.detail`
|
||||
- `downloadUrls.all`
|
||||
|
||||
这一分支**不返回 zip**,也**不需要再调下载 token 接口**。
|
||||
|
||||
## 6. 错误码
|
||||
|
||||
### 6.1 通用错误
|
||||
|
||||
| HTTP 状态码 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 401 | `INVALID_API_KEY` | API Key 无效或缺失 |
|
||||
| 400 | `MISSING_PARAM` | 没有传 `sku` 或 `spu` |
|
||||
| 400 | `RESOURCE_TYPE_INVALID` | `resourceType` 不是 `white-background` 或 `task-detail` |
|
||||
| 500 | `SERVER_ERROR` | 服务器内部错误 |
|
||||
|
||||
### 6.2 白底图分支错误
|
||||
|
||||
| HTTP 状态码 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
|
||||
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供白底图 |
|
||||
| 404 | `SPU_NOT_FOUND` | 未找到对应的标准模型 SPU |
|
||||
| 404 | `NO_SKUS` | 该 SPU 下没有 SKU 数据 |
|
||||
| 404 | `WHITE_BACKGROUND_NOT_FOUND` | 该 SKU 暂无白底图 |
|
||||
|
||||
### 6.3 任务-详情分支错误
|
||||
|
||||
| HTTP 状态码 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 404 | `SPU_NOT_FOUND` | 未找到对应的标准模型 SPU |
|
||||
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
|
||||
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供任务-详情整套素材 |
|
||||
| 404 | `TASK_DETAIL_NOT_FOUND` | 该 SPU 或该 SKU 所属 SPU 下暂无任务-详情素材 |
|
||||
| 404 | `FULL_SET_NOT_FOUND` | 有任务-详情素材,但没有任何一条被标记为整套 |
|
||||
|
||||
## 7. cURL 示例
|
||||
|
||||
### 7.1 按 SKU 查询白底图
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?sku=TEXT001452" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 7.2 按 SPU 查询白底图 SKU 清单
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=white-background&spu=TEXT0014" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 7.3 按 SPU 查询任务-详情整套候选
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&spu=SPU001" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 7.4 按 SKU 查询任务-详情整套候选
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&sku=SKU001" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
## 8. 接入建议
|
||||
|
||||
- 老的白底图调用方可以不改,继续传 `sku` 即可。
|
||||
- 如果是 AI 生图平台新流程,建议:
|
||||
- 白底图:`spu -> sku -> 白底图详情`
|
||||
- 任务-详情:直接按 `spu` 或 `sku` 查整套候选
|
||||
- `task-detail` 返回多个候选时,建议调用方按 `uploadedAt` 再做一次前端展示排序或筛选。
|
||||
- `task-detail` 使用的是预览图直链,不适合再按源文件 zip 的思路处理。
|
||||
|
||||
## 9. 当前联调状态
|
||||
|
||||
截至 `2026-03-18`,本地代码已完成并验证通过的内容:
|
||||
|
||||
- `/api/external/standard-model-white-background` 已支持 `white-background` 和 `task-detail`
|
||||
- 不传 `resourceType` 时,白底图老调用方式保持兼容
|
||||
- `task-detail` 已支持 `spu` / `sku` 两种查询
|
||||
- 返回中已补充 `shopName`
|
||||
- `任务-详情` 整套字段 `isFullSet` 已接入接口返回逻辑
|
||||
- 本地构建 `npm run build` 已通过
|
||||
558
SPU详情接口文档2.0.md
Normal file
558
SPU详情接口文档2.0.md
Normal file
@ -0,0 +1,558 @@
|
||||
# AI 生图平台素材接口文档
|
||||
|
||||
> 用途:AI 生图平台统一通过同一条接口,查询 `标准模型白底图` 或 `任务-详情整套预览下载链接`
|
||||
> 当前主域名:`https://sc.jiansun.vip`
|
||||
> 当前资源主域名:`https://s3.jiansun.vip`
|
||||
> 备用资源域名:`https://s3.yydd.ai`(备用 / 回切用,非默认对接地址)
|
||||
> 旧应用域名:`https://3d.yydd.ai`(仅保留兼容跳转)
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- 接口基地址:`https://sc.jiansun.vip/api/external/`
|
||||
- 鉴权方式:请求头 `X-API-Key`
|
||||
- 请求方式:`GET`
|
||||
- 返回格式:`application/json`
|
||||
- 主接口路径:`/api/external/standard-model-white-background`
|
||||
- 默认资源类型:`white-background`
|
||||
|
||||
### 1.1 当前网络口径
|
||||
|
||||
- 接口查询、鉴权和下载 token 入口统一使用 `https://sc.jiansun.vip`
|
||||
- `task-detail` 分支返回的预览图直链,当前默认资源域名使用 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 当前只保留为备用资源域名;除非单独通知切换,否则调用方继续以 `https://s3.jiansun.vip` 为准
|
||||
|
||||
返回统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
失败时:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "错误说明"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 响应诊断头
|
||||
|
||||
为便于调用方排查“接口慢”到底是服务端慢还是链路慢,当前接口额外返回以下响应头:
|
||||
|
||||
| 响应头 | 说明 |
|
||||
|---|---|
|
||||
| `X-Trace-Id` | 本次请求追踪 ID,排查时可提供给素材平台定位服务端日志 |
|
||||
| `X-Server-Duration-Ms` | 服务端实际处理耗时,单位毫秒 |
|
||||
| `Server-Timing` | 标准服务端耗时头,内容与上面一致 |
|
||||
| `X-Resource-Type` | 本次实际走的资源分支 |
|
||||
| `X-Request-Sku` | 本次实际识别后的 SKU;未传时不返回 |
|
||||
| `X-Request-Spu` | 本次实际识别后的 SPU;未传时不返回 |
|
||||
|
||||
## 2. 通用请求规则
|
||||
|
||||
### 2.1 请求头
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `X-API-Key` | 是 | 外部接口访问密钥 |
|
||||
|
||||
### 2.2 查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `resourceType` | 否 | 资源类型。可选 `white-background` 或 `task-detail`。不传时默认 `white-background` |
|
||||
| `sku` | 条件必填 | `sku` 和 `spu` 二选一即可 |
|
||||
| `spu` | 条件必填 | `sku` 和 `spu` 二选一即可 |
|
||||
|
||||
补充说明:
|
||||
|
||||
- `sku`、`spu` 都按大小写不敏感查询,建议调用方统一传大写。
|
||||
- 当前服务端会先把 `sku`、`spu` 标准化为大写后再做精确匹配,建议调用方直接传大写,避免自身重复大小写转换。
|
||||
- 如果 `sku` 和 `spu` 同时传入,接口优先按 `spu` 处理。
|
||||
- `shopName` 当前与 `categoryName` 含义一致,都是“店铺”。
|
||||
|
||||
## 3. 白底图查询
|
||||
|
||||
### 3.1 适用场景
|
||||
|
||||
- `resourceType=white-background`
|
||||
- 不传 `resourceType` 时也走这一分支,兼容老调用方
|
||||
- 支持两种查询方式:
|
||||
- `sku`:直接拿到该 SKU 的完整白底图信息
|
||||
- `spu`:先拿到该 SPU 下全部 SKU 清单,再让用户选 SKU
|
||||
|
||||
---
|
||||
|
||||
### 3.2 按 SKU 查询白底图
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=white-background&sku={SKU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
兼容老调用方式:
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?sku={SKU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "white-background",
|
||||
"sku": "TEXT001452",
|
||||
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
|
||||
"productModel": "TEXT0014",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"whiteBackgroundUploaded": true,
|
||||
"whiteBackgroundComplete": false,
|
||||
"whiteBackgroundFixedCount": 2,
|
||||
"whiteBackgroundOtherCount": 1,
|
||||
"whiteBackgroundCount": 3,
|
||||
"missingViews": [
|
||||
{
|
||||
"key": "leftBack45",
|
||||
"label": "左后45°"
|
||||
}
|
||||
],
|
||||
"whiteBackground": {
|
||||
"fixed": [
|
||||
{
|
||||
"key": "leftFront45",
|
||||
"label": "左前45°",
|
||||
"fileName": "左前45°.png",
|
||||
"fileSize": 123456,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
|
||||
},
|
||||
{
|
||||
"key": "rightFront45",
|
||||
"label": "右前45°",
|
||||
"fileName": "右前45°.png",
|
||||
"fileSize": 123456,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=yyy"
|
||||
}
|
||||
],
|
||||
"other": [
|
||||
{
|
||||
"key": "other-1",
|
||||
"label": "其他图片1",
|
||||
"fileName": "detail-1.png",
|
||||
"fileSize": 123456,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=zzz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"downloadUrls": {
|
||||
"zip": "https://sc.jiansun.vip/api/external/download?token=zip-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `resourceType` | 固定为 `white-background` |
|
||||
| `sku` | 实际命中的 SKU |
|
||||
| `materialId` | 标准模型素材 ID |
|
||||
| `productModel` | 所属 SPU |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `whiteBackgroundUploaded` | 是否已有白底图 |
|
||||
| `whiteBackgroundComplete` | 固定 8 个角度是否已补齐 |
|
||||
| `whiteBackgroundFixedCount` | 已有固定角度图数量 |
|
||||
| `whiteBackgroundOtherCount` | 已有 other 图数量 |
|
||||
| `whiteBackgroundCount` | 白底图总数 |
|
||||
| `missingViews` | 还缺少的固定角度图 |
|
||||
| `whiteBackground.fixed` | 固定角度白底图列表 |
|
||||
| `whiteBackground.other` | 其他白底图列表 |
|
||||
| `downloadUrls.zip` | 当前 SKU 白底图 ZIP 下载链接 |
|
||||
|
||||
固定角度顺序固定为:
|
||||
|
||||
1. `leftFront45`
|
||||
2. `rightFront45`
|
||||
3. `leftBack45`
|
||||
4. `rightBack45`
|
||||
5. `front`
|
||||
6. `back`
|
||||
7. `left`
|
||||
8. `right`
|
||||
|
||||
单张图片对象字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `key` | 固定图为固定角度 key,other 图为 `other-*` |
|
||||
| `label` | 图片说明 |
|
||||
| `fileName` | 文件名 |
|
||||
| `fileSize` | 文件大小,单位字节 |
|
||||
| `downloadUrl` | 单张白底图下载链接 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 按 SPU 查询白底图 SKU 清单
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=white-background&spu={SPU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "white-background",
|
||||
"spu": "TEXT0014",
|
||||
"materialId": "484bd047-c956-48cf-a0f4-401f696aef8d",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"skuCount": 2,
|
||||
"skuList": [
|
||||
{
|
||||
"skuName": "TEXT001452",
|
||||
"skuId": "sku-id-1",
|
||||
"hasWhiteBackground": true,
|
||||
"whiteBackgroundCount": 2,
|
||||
"previewImage": {
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "右前45°.png",
|
||||
"fileSize": 123456
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuName": "TEXT001453",
|
||||
"skuId": "sku-id-2",
|
||||
"hasWhiteBackground": false,
|
||||
"whiteBackgroundCount": 0,
|
||||
"previewImage": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `resourceType` | 固定为 `white-background` |
|
||||
| `spu` | 实际命中的 SPU |
|
||||
| `materialId` | 标准模型素材 ID |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `skuCount` | 该 SPU 下 SKU 数量 |
|
||||
| `skuList[].skuName` | SKU 名称 |
|
||||
| `skuList[].skuId` | SKU 记录 ID |
|
||||
| `skuList[].hasWhiteBackground` | 是否已有任意白底图 |
|
||||
| `skuList[].whiteBackgroundCount` | 当前固定角度白底图数量 |
|
||||
| `skuList[].previewImage` | 右前 45° 预览图;如果没有该角度则为 `null` |
|
||||
|
||||
说明:
|
||||
|
||||
- 这个接口主要给 AI 平台先展示 SKU 列表用。
|
||||
- 选定 SKU 后,再调用“按 SKU 查询白底图”接口拿完整白底图信息。
|
||||
|
||||
## 4. 任务-详情整套查询
|
||||
|
||||
### 4.1 适用场景
|
||||
|
||||
- `resourceType=task-detail`
|
||||
- 按 `任务-详情` 素材类型直接查询,不再要求先存在 `标准模型`
|
||||
- 只返回:
|
||||
- `materialType='任务-详情'`
|
||||
- 同时拥有主图、SKU 图、详情图
|
||||
- `deletedAt=null`
|
||||
的候选素材
|
||||
- 返回的是**预览图直链数组**,不是 zip,也不是 token 下载链接
|
||||
- 返回顺序按 `uploadedAt` 倒序,也就是最新上传的排前面
|
||||
- 当前默认返回的资源直链域名是 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 保留备用说明,但不是当前默认示例地址
|
||||
|
||||
---
|
||||
|
||||
### 4.2 按 SPU 查询任务-详情整套候选
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=task-detail&spu={SPU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `task-detail` 响应顶层的 `categoryName` / `shopName` 是便捷字段,取本次返回候选列表中的第一条候选素材。
|
||||
- 如果 AI 平台要展示候选素材的实际店铺,请优先读取 `candidates[].categoryName` 或 `candidates[].shopName`。
|
||||
- 如果同一个 SPU 下有多条候选素材,且它们店铺不同,请以各自 `candidates[]` 内的字段为准。
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "task-detail",
|
||||
"spu": "SPU001",
|
||||
"sku": null,
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"candidateCount": 1,
|
||||
"candidates": [
|
||||
{
|
||||
"materialId": "material-2",
|
||||
"taskNo": "TASK202601141713525624",
|
||||
"productModel": "SPU001",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"uploadedAt": "2026-03-18T10:00:00.000Z",
|
||||
"isFullSet": true,
|
||||
"downloadUrls": {
|
||||
"main": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
|
||||
],
|
||||
"sku": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
|
||||
],
|
||||
"detail": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
|
||||
],
|
||||
"all": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 按 SKU 查询任务-详情整套候选
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/standard-model-white-background?resourceType=task-detail&sku={SKU}
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
**处理逻辑**
|
||||
|
||||
- 先通过 `SKU -> 标准模型 SPU`
|
||||
- 再按这个 SPU 查询所有整套 `任务-详情` 候选
|
||||
- 顶层 `categoryName` / `shopName` 是便捷字段,取本次返回候选列表中的第一条候选素材
|
||||
- 如果 AI 平台要展示候选素材的实际店铺,请优先读取 `candidates[].categoryName` 或 `candidates[].shopName`
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"resourceType": "task-detail",
|
||||
"spu": "SPU001",
|
||||
"sku": "SKU001",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"candidateCount": 1,
|
||||
"candidates": [
|
||||
{
|
||||
"materialId": "material-2",
|
||||
"taskNo": "TASK202601141713525624",
|
||||
"productModel": "SPU001",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"uploadedAt": "2026-03-18T10:00:00.000Z",
|
||||
"isFullSet": true,
|
||||
"downloadUrls": {
|
||||
"main": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg"
|
||||
],
|
||||
"sku": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg"
|
||||
],
|
||||
"detail": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
|
||||
],
|
||||
"all": [
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/main-1.jpg",
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/sku-1.jpg",
|
||||
"https://s3.jiansun.vip/gongxiangpan/preview/detail-1.jpg"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**候选素材字段说明**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `materialId` | 任务-详情素材 ID |
|
||||
| `taskNo` | 任务号 |
|
||||
| `productModel` | 对应 SPU |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `sceneName` | 场景 |
|
||||
| `uploadedAt` | 上传时间,ISO 8601 格式 |
|
||||
| `isFullSet` | 是否整套;当前规则为“同时拥有主图、SKU 图、详情图” |
|
||||
| `downloadUrls.main` | 主图预览直链数组 |
|
||||
| `downloadUrls.sku` | SKU 图预览直链数组 |
|
||||
| `downloadUrls.detail` | 详情图预览直链数组 |
|
||||
| `downloadUrls.all` | `main + sku + detail` 合并后的数组 |
|
||||
|
||||
说明:
|
||||
|
||||
- `task-detail` 分支返回的是**全部候选**,不是只返回一条。
|
||||
- 预览链接可以直接打开或下载。
|
||||
- 历史 `任务-详情` 会按同一套资产规则判断,不再依赖人工勾选。
|
||||
- 顶层 `categoryName` / `shopName` 是便捷字段;如果要给用户展示候选素材的真实店铺,请优先使用 `candidates[].shopName`。
|
||||
|
||||
## 5. 下载说明
|
||||
|
||||
### 5.1 白底图
|
||||
|
||||
白底图返回的是下载链接:
|
||||
|
||||
- 单张图:`whiteBackground.fixed[*].downloadUrl` / `whiteBackground.other[*].downloadUrl`
|
||||
- 整包 ZIP:`downloadUrls.zip`
|
||||
|
||||
调用方式:
|
||||
|
||||
```http
|
||||
GET https://sc.jiansun.vip/api/external/download?token=xxx
|
||||
```
|
||||
|
||||
### 5.2 任务-详情整套
|
||||
|
||||
任务-详情返回的是可直接访问的预览图直链数组:
|
||||
|
||||
- `downloadUrls.main`
|
||||
- `downloadUrls.sku`
|
||||
- `downloadUrls.detail`
|
||||
- `downloadUrls.all`
|
||||
|
||||
这一分支**不返回 zip**,也**不需要再调下载 token 接口**。
|
||||
|
||||
补充说明:
|
||||
|
||||
- 当前默认回包中的预览图资源域名为 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 仅保留为备用资源域名说明,不作为当前默认对接地址
|
||||
|
||||
## 6. 错误码
|
||||
|
||||
### 6.1 通用错误
|
||||
|
||||
| HTTP 状态码 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 401 | `INVALID_API_KEY` | API Key 无效或缺失 |
|
||||
| 400 | `MISSING_PARAM` | 没有传 `sku` 或 `spu` |
|
||||
| 400 | `RESOURCE_TYPE_INVALID` | `resourceType` 不是 `white-background` 或 `task-detail` |
|
||||
| 500 | `SERVER_ERROR` | 服务器内部错误 |
|
||||
|
||||
### 6.2 白底图分支错误
|
||||
|
||||
| HTTP 状态码 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
|
||||
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供白底图 |
|
||||
| 404 | `SPU_NOT_FOUND` | 未找到对应的标准模型 SPU |
|
||||
| 404 | `NO_SKUS` | 该 SPU 下没有 SKU 数据 |
|
||||
| 404 | `WHITE_BACKGROUND_NOT_FOUND` | 该 SKU 暂无白底图 |
|
||||
|
||||
### 6.3 任务-详情分支错误
|
||||
|
||||
| HTTP 状态码 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 404 | `SKU_NOT_FOUND` | 未找到对应的标准模型 SKU |
|
||||
| 409 | `SKU_NOT_UNIQUE` | SKU 重复,暂无法对外提供任务-详情整套素材 |
|
||||
| 404 | `TASK_DETAIL_NOT_FOUND` | 该 SPU 下,或该 SKU 所属 SPU 下暂无任务-详情素材 |
|
||||
| 404 | `FULL_SET_NOT_FOUND` | 有任务-详情素材,但没有任何一条同时拥有主图、SKU 图、详情图 |
|
||||
|
||||
## 7. cURL 示例
|
||||
|
||||
### 7.1 按 SKU 查询白底图
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?sku=TEXT001452" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 7.2 按 SPU 查询白底图 SKU 清单
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=white-background&spu=TEXT0014" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 7.3 按 SPU 查询任务-详情整套候选
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&spu=SPU001" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### 7.4 按 SKU 查询任务-详情整套候选
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"https://sc.jiansun.vip/api/external/standard-model-white-background?resourceType=task-detail&sku=SKU001" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
## 8. 接入建议
|
||||
|
||||
- 老的白底图调用方可以不改,继续传 `sku` 即可。
|
||||
- 如果是 AI 生图平台新流程,建议:
|
||||
- 白底图:`spu -> sku -> 白底图详情`
|
||||
- 任务-详情:直接按 `spu` 或 `sku` 查整套候选
|
||||
- `task-detail` 返回多个候选时,建议调用方按 `uploadedAt` 再做一次前端展示排序或筛选。
|
||||
- `task-detail` 使用的是预览图直链,不适合再按源文件 zip 的思路处理。
|
||||
|
||||
## 9. 当前联调状态
|
||||
|
||||
截至 `2026-03-21`,正式环境已部署并验证通过的内容:
|
||||
|
||||
- `/api/external/standard-model-white-background` 已支持 `white-background` 和 `task-detail`
|
||||
- 不传 `resourceType` 时,白底图老调用方式保持兼容
|
||||
- `task-detail` 已支持 `spu` / `sku` 两种查询
|
||||
- 返回中已补充 `shopName`
|
||||
- `任务-详情` 整套字段 `isFullSet` 已改为按主图 / SKU 图 / 详情图自动判定
|
||||
- 对外文档默认资源域名已统一到 `https://s3.jiansun.vip`,同时保留 `https://s3.yydd.ai` 作为备用说明
|
||||
- 相关测试已通过:`standard-model-white-background-route`、`task-detail-full-set`、`url-rewrite`
|
||||
- 构建验证已通过:`npm run build`
|
||||
- 正式环境健康检查已通过:`/api/health`
|
||||
- 正式环境真实调用已验证:
|
||||
- `resourceType=task-detail&spu=CTG0290` 返回 `200`
|
||||
- `resourceType=task-detail&sku=DNZ025802` 返回 `200`
|
||||
- 白底图 `spu=CTG0149` 返回 `200`
|
||||
57
SUPPORT.md
Normal file
57
SUPPORT.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Support
|
||||
|
||||
This document explains where to ask for help and what information to include.
|
||||
|
||||
## Before Asking
|
||||
|
||||
Please check:
|
||||
|
||||
- `README.md` for setup and project boundaries.
|
||||
- `docs/deployment.md` for production deployment.
|
||||
- `docs/operations.md` for quality gates, logging, and smoke tests.
|
||||
- `docs/release-readiness.md` for release verification.
|
||||
- Existing GitHub issues.
|
||||
|
||||
## Bugs
|
||||
|
||||
Use the Bug report issue template when you can provide reproduction steps.
|
||||
|
||||
Include:
|
||||
|
||||
- What happened and what you expected.
|
||||
- Route, workflow, or UI area affected.
|
||||
- Browser, OS, Node, pnpm, and deployment context when relevant.
|
||||
- Sanitized logs or screenshots.
|
||||
|
||||
Do not include secrets, cookies, OAuth codes, Authorization headers, signed URLs, `.env.local` values, private prompts, base64 data, or private user content.
|
||||
|
||||
## Feature Requests
|
||||
|
||||
Use the Feature request issue template. Keep proposals focused and include:
|
||||
|
||||
- The user or operator problem.
|
||||
- Proposed behavior or API shape.
|
||||
- Acceptance criteria.
|
||||
- Relevant alternatives considered.
|
||||
|
||||
## Security Issues
|
||||
|
||||
Do not open public issues for vulnerabilities, auth bypasses, token leaks, signed URL exposure, private data exposure, or suspicious dependency behavior.
|
||||
|
||||
Follow `SECURITY.md` and report privately to the maintainer.
|
||||
|
||||
## Deployment and Operations Help
|
||||
|
||||
For deployment questions, include sanitized versions of:
|
||||
|
||||
- Hosting target.
|
||||
- Node and pnpm versions.
|
||||
- Database/storage provider type.
|
||||
- Relevant command output.
|
||||
- Whether `pnpm check:quality` and `pnpm smoke:routes` pass.
|
||||
|
||||
Never paste real environment variables. Use placeholder names from `.env.example`.
|
||||
|
||||
## Maintainer Expectations
|
||||
|
||||
The project is maintained as a production-oriented codebase. Issues and discussions that include unsafe secrets, private user data, or unsanitized provider payloads may be edited or closed to protect users and operators.
|
||||
2
agent能力提升参考源码/.gitignore
vendored
Normal file
2
agent能力提升参考源码/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
archive/
|
||||
@ -0,0 +1,88 @@
|
||||
# Is legal the same as legitimate: AI reimplementation and the erosion of copyleft
|
||||
|
||||
- **Date:** March 9, 2026
|
||||
- **Author:** Hong Minhee
|
||||
- **Source context:** _Hong Minhee on Things_ (English / 日本語 / 朝鮮語 (國漢文) / 한국어 (한글))
|
||||
- **Archive note:** This copy was normalized from user-provided text for this repository's research/archive context. Site navigation/footer language links were converted into metadata.
|
||||
|
||||
Last week, Dan Blanchard, the maintainer of chardet—a Python library for detecting text encodings used by roughly 130 million projects a month—released a new version. Version 7.0 is 48 times faster than its predecessor, supports multiple cores, and was redesigned from the ground up. Anthropic's Claude is listed as a contributor. The license changed from LGPL to MIT.
|
||||
|
||||
Blanchard's account is that he never looked at the existing source code directly. He fed only the API and the test suite to Claude and asked it to reimplement the library from scratch. The resulting code shares less than 1.3% similarity with any prior version, as measured by JPlag. His conclusion: this is an independent new work, and he is under no obligation to carry forward the LGPL. Mark Pilgrim, the library's original author, opened a GitHub issue to object. The LGPL requires that modifications be distributed under the same license, and a reimplementation produced with ample exposure to the original codebase cannot, in Pilgrim's view, pass as a clean-room effort.
|
||||
|
||||
The dispute drew responses from two prominent figures in the open source world. Armin Ronacher, the creator of Flask, welcomed the relicensing. Salvatore Sanfilippo (antirez), the creator of Redis, published a broader defense of AI reimplementation, grounding it in copyright law and the history of the GNU project. Both conclude, by different routes, that what Blanchard did is legitimate. I respect both writers, and I think both are wrong—or more precisely, both are evading the question that actually matters.
|
||||
|
||||
That question is this: does legal mean legitimate? Neither piece answers it. Both move from “this is legally permissible” to “this is therefore fine,” without pausing at the gap between those two claims. Law sets a floor; clearing it does not mean the conduct is right. That gap is where this essay begins.
|
||||
|
||||
## The analogy points the wrong way
|
||||
|
||||
Antirez builds his case on history. When the GNU project reimplemented the UNIX userspace, it was lawful. So was Linux. Copyright law prohibits copying “protected expressions”—the actual code, its structure, its specific mechanisms—but it does not protect ideas or behavior. AI-assisted reimplementation occupies the same legal ground. Therefore, it is lawful.
|
||||
|
||||
The legal analysis is largely correct, and I am not disputing it. The problem lies in what antirez does next: he presents the legal conclusion as if it were also a social one, and uses a historical analogy that, examined more carefully, argues against his own position.
|
||||
|
||||
When GNU reimplemented the UNIX userspace, the vector ran from proprietary to free. Stallman was using the limits of copyright law to turn proprietary software into free software. The ethical force of that project did not come from its legal permissibility—it came from the direction it was moving, from the fact that it was expanding the commons. That is why people cheered.
|
||||
|
||||
The vector in the chardet case runs the other way. Software protected by a copyleft license—one that guarantees users the right to study, modify, and redistribute derivative works under the same terms—has been reimplemented under a permissive license that carries no such guarantee. This is not a reimplementation that expands the commons. It is one that removes the fencing that protected the commons. Derivative works built on chardet 7.0 are under no obligation to share their source code. That obligation, which applied to a library downloaded 130 million times a month, is now gone.
|
||||
|
||||
Antirez does not address this directional difference. He invokes the GNU precedent, but that precedent is a counterexample to his conclusion, not a supporting one.
|
||||
|
||||
## Does the GPL work against sharing?
|
||||
|
||||
Ronacher's argument is different. He discloses upfront that he has a stake in the outcome: “I personally have a horse in the race here because I too wanted chardet to be under a non-GPL license for many years. So consider me a very biased person in that regard.” He goes on to write that he considers “the GPL to run against that spirit by restricting what can be done with it”—the spirit being that society is better off when we share.
|
||||
|
||||
This claim rests on a fundamental misreading of what the GPL does.
|
||||
|
||||
Start with what the GPL actually prohibits. It does not prohibit keeping source code private. It imposes no constraint on privately modifying GPL software and using it yourself. The GPL's conditions are triggered only by distribution. If you distribute modified code, or offer it as a networked service, you must make the source available under the same terms. This is not a restriction on sharing. It is a condition placed on sharing: if you share, you must share in kind.
|
||||
|
||||
The requirement that improvements be returned to the commons is not a mechanism that suppresses sharing. It is a mechanism that makes sharing recursive and self-reinforcing. The claim that imposing contribution obligations on users of a commons undermines sharing culture does not hold together logically.
|
||||
|
||||
The contrast with the MIT license clarifies the point. Under MIT, anyone may take code, improve it, and close it off into a proprietary product. You can receive from the commons without giving back. If Ronacher calls this structure “more share-friendly,” he is using a concept of sharing with a specific directionality built in: sharing flows toward whoever has more capital and more engineers to take advantage of it.
|
||||
|
||||
The historical record bears this out. In the 1990s, companies routinely absorbed GPL code into proprietary products—not because they had chosen permissive licenses, but because copyleft enforcement was slack. The strengthening of copyleft mechanisms closed that gap. For individual developers and small projects without the resources to compete on anything but reciprocity, copyleft was what made the exchange approximately fair.
|
||||
|
||||
The creator of Flask knows this distinction. If he elides it anyway, the argument is not naïve—it is convenient.
|
||||
|
||||
## A self-refuting example
|
||||
|
||||
The most interesting moment in Ronacher's piece is not the argument but a detail he mentions in passing: Vercel reimplemented GNU Bash using AI and published it, then got visibly upset when Cloudflare reimplemented Next.js the same way.
|
||||
|
||||
Ronacher notes this as an irony and moves on. But the irony cuts deeper than he lets on. Next.js is MIT licensed. Cloudflare's vinext did not violate any license—it did exactly what Ronacher calls a contribution to the culture of openness, applied to a permissively licensed codebase. Vercel's reaction had nothing to do with license infringement; it was purely competitive and territorial. The implicit position is: reimplementing GPL software as MIT is a victory for sharing, but having our own MIT software reimplemented by a competitor is cause for outrage. This is what the claim that permissive licensing is “more share-friendly” than copyleft looks like in practice. The spirit of sharing, it turns out, runs in one direction only: outward from oneself.
|
||||
|
||||
Ronacher registers the contradiction and does not stop. “This development plays into my worldview,” he writes. When you present evidence that cuts against your own position, acknowledge it, and then proceed to your original conclusion unchanged, that is a signal that the conclusion preceded the argument.
|
||||
|
||||
## Legality and social legitimacy are different registers
|
||||
|
||||
Back to the question posed at the start. Is legal the same as legitimate?
|
||||
|
||||
Antirez closes his careful legal analysis as though it settles the matter. Ronacher acknowledges that “there is an obvious moral question here, but that isn't necessarily what I'm interested in.” Both pieces treat legal permissibility as a proxy for social legitimacy. But law only says what conduct it will not prevent—it does not certify that conduct as right. Aggressive tax minimization that never crosses into illegality may still be widely regarded as antisocial. A pharmaceutical company that legally acquires a patent on a long-generic drug and raises the price a hundredfold has done something legal, but that does not make it fine. Legality is a necessary condition; it is not a sufficient one.
|
||||
|
||||
In the chardet case, the distinction is sharper still. What the LGPL protected was not Blanchard's labor alone. It was a social compact agreed to by everyone who contributed to the library over twelve years. The terms of that compact were: if you take this and build on it, you share back under the same terms. This compact operated as a legal instrument, yes, but it was also the foundation of trust that made contribution rational. The fact that a reimplementation may qualify legally as a new work, and the fact that it breaks faith with the original contributors, are separate questions. If a court eventually rules in Blanchard's favor, that ruling will tell us what the law permits. It will not tell us that the act was right.
|
||||
|
||||
Zoë Kooyman, executive director of the FSF, put it plainly: “Refusing to grant others the rights you yourself received as a user is highly antisocial, no matter what method you use.”
|
||||
|
||||
## Whose perspective is the default?
|
||||
|
||||
Reading this debate, I keep returning to a question about position. From where are these two writers looking at the situation?
|
||||
|
||||
Antirez created Redis. Ronacher created Flask. Both are figures at the center of the open source ecosystem, with large audiences and well-established reputations. For them, falling costs of AI reimplementation means something specific: it is easier to reimplement things they want in a different form. Ronacher says explicitly that he had begun reimplementing GNU Readline precisely because of its copyleft terms.
|
||||
|
||||
For the people who have spent years contributing to a library like chardet, the same shift in costs means something else entirely: the copyleft protection around their contributions can be removed. The two writers are speaking from the former position to people in the latter, telling them that this was always lawful, that historical precedent supports it, and that the appropriate response is adaptation.
|
||||
|
||||
When positional asymmetry of this kind is ignored, and the argument is presented as universal analysis, what you get is not analysis but rationalization. Both writers arrive at conclusions that align precisely with their own interests. Readers should hold that fact in mind.
|
||||
|
||||
## What this fight points toward
|
||||
|
||||
Bruce Perens, who wrote the original Open Source Definition, told The Register: “The entire economics of software development are dead, gone, over, kaput!” He meant it as an alarm. Antirez, from a similar assessment of the situation, draws the conclusion: adapt. Ronacher says he finds the direction exciting.
|
||||
|
||||
None of the three responses addresses the central question. When copyleft becomes technically easier to circumvent, does that make it less necessary, or more?
|
||||
|
||||
I think more. What the GPL protected was not the scarcity of code but the freedom of users. The fact that producing code has become cheaper does not make it acceptable to use that code as a vehicle for eroding freedom. If anything, as the friction of reimplementation disappears, so does the friction of stripping copyleft from anything left exposed. The erosion of enforcement capacity is a legal problem. It does not touch the underlying normative judgment.
|
||||
|
||||
That judgment is this: those who take from the commons owe something back to the commons. The principle does not change depending on whether a reimplementation takes five years or five days. No court ruling on AI-generated code will alter its social weight.
|
||||
|
||||
This is where law and community norms diverge. Law is made slowly, after the fact, reflecting existing power arrangements. The norms that open source communities built over decades did not wait for court approval. People chose the GPL when the law offered them no guarantee of its enforcement, because it expressed the values of the communities they wanted to belong to. Those values do not expire when the law changes.
|
||||
|
||||
In previous writing, I argued for a training copyleft (TGPL) as the next step in this line of development. The chardet situation suggests the argument has to go further: to a specification copyleft covering the layer below source code. If source code can now be generated from a specification, the specification is where the essential intellectual content of a GPL project resides. Blanchard's own claim—that he worked only from the test suite and API without reading the source—is, paradoxically, an argument for protecting that test suite and API specification under copyleft terms.
|
||||
|
||||
The history of the GPL is the history of licensing tools evolving in response to new forms of exploitation: GPLv2 to GPLv3, then AGPL. What drove each evolution was not a court ruling but a community reaching a value judgment first and then seeking legal instruments to express it. The same sequence is available now. Whatever courts eventually decide about AI reimplementation, the question we need to answer first is not a legal one. It is a social one. Do those who take from the commons owe something back? I think they do. That judgment does not require a verdict.
|
||||
|
||||
What makes the pieces by antirez and Ronacher worth reading is not that they are right. It is that they make visible, with unusual clarity, what they are choosing not to see. When legality is used as a substitute for a value judgment, the question that actually matters gets buried in the footnotes of a law it has already outgrown.
|
||||
106
agent能力提升参考源码/README.md
Normal file
106
agent能力提升参考源码/README.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Claude Code Python Porting Workspace
|
||||
|
||||
> The primary `src/` tree in this repository is now dedicated to **Python porting work**. The March 31, 2026 Claude Code source exposure is part of the project's background, but the tracked repository is now centered on Python source rather than the exposed TypeScript snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Porting Status
|
||||
|
||||
The main source tree is now Python-first.
|
||||
|
||||
- `src/` contains the active Python porting workspace
|
||||
- `tests/` verifies the current Python workspace
|
||||
- the exposed snapshot is no longer part of the tracked repository state
|
||||
|
||||
The current Python workspace is not yet a complete one-to-one replacement for the original system, but the primary implementation surface is now Python.
|
||||
|
||||
## Why this rewrite exists
|
||||
|
||||
I originally studied the exposed codebase to understand its harness, tool wiring, and agent workflow. After spending more time with the legal and ethical questions—and after reading the essay linked below—I did not want the exposed snapshot itself to remain the main tracked source tree.
|
||||
|
||||
This repository now focuses on Python porting work instead.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```text
|
||||
.
|
||||
├── src/ # Python porting workspace
|
||||
│ ├── __init__.py
|
||||
│ ├── commands.py
|
||||
│ ├── main.py
|
||||
│ ├── models.py
|
||||
│ ├── port_manifest.py
|
||||
│ ├── query_engine.py
|
||||
│ ├── task.py
|
||||
│ └── tools.py
|
||||
├── tests/ # Python verification
|
||||
├── assets/omx/ # OmX workflow screenshots
|
||||
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Python Workspace Overview
|
||||
|
||||
The new Python `src/` tree currently provides:
|
||||
|
||||
- **`port_manifest.py`** — summarizes the current Python workspace structure
|
||||
- **`models.py`** — dataclasses for subsystems, modules, and backlog state
|
||||
- **`commands.py`** — Python-side command port metadata
|
||||
- **`tools.py`** — Python-side tool port metadata
|
||||
- **`query_engine.py`** — renders a Python porting summary from the active workspace
|
||||
- **`main.py`** — a CLI entrypoint for manifest and summary output
|
||||
|
||||
## Quickstart
|
||||
|
||||
Render the Python porting summary:
|
||||
|
||||
```bash
|
||||
python3 -m src.main summary
|
||||
```
|
||||
|
||||
Print the current Python workspace manifest:
|
||||
|
||||
```bash
|
||||
python3 -m src.main manifest
|
||||
```
|
||||
|
||||
List the current Python modules:
|
||||
|
||||
```bash
|
||||
python3 -m src.main subsystems --limit 16
|
||||
```
|
||||
|
||||
Run verification:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
## Related Essay
|
||||
|
||||
- [*Is legal the same as legitimate: AI reimplementation and the erosion of copyleft*](https://writings.hongminhee.org/2026/03/legal-vs-legitimate/)
|
||||
|
||||
The essay is dated **March 9, 2026**, so it should be read as companion analysis that predates the **March 31, 2026** source exposure that motivated this rewrite direction.
|
||||
|
||||
## Built with `oh-my-codex`
|
||||
|
||||
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
|
||||
|
||||
- **`$team` mode:** used for coordinated parallel review and architectural feedback
|
||||
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
|
||||
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
|
||||
|
||||
### OmX workflow screenshots
|
||||
|
||||

|
||||
|
||||
*Ralph/team orchestration view while the README and essay context were being reviewed in terminal panes.*
|
||||
|
||||

|
||||
|
||||
*Split-pane review and verification flow during the final README wording pass.*
|
||||
|
||||
## Ownership / Affiliation Disclaimer
|
||||
|
||||
- This repository does **not** claim ownership of the original Claude Code source material.
|
||||
- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.
|
||||
BIN
agent能力提升参考源码/assets/omx/omx-readme-review-1.png
Normal file
BIN
agent能力提升参考源码/assets/omx/omx-readme-review-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
agent能力提升参考源码/assets/omx/omx-readme-review-2.png
Normal file
BIN
agent能力提升参考源码/assets/omx/omx-readme-review-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
16
agent能力提升参考源码/src/__init__.py
Normal file
16
agent能力提升参考源码/src/__init__.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""Python porting workspace for the Claude Code rewrite effort."""
|
||||
|
||||
from .commands import PORTED_COMMANDS, build_command_backlog
|
||||
from .port_manifest import PortManifest, build_port_manifest
|
||||
from .query_engine import QueryEnginePort
|
||||
from .tools import PORTED_TOOLS, build_tool_backlog
|
||||
|
||||
__all__ = [
|
||||
'PortManifest',
|
||||
'QueryEnginePort',
|
||||
'PORTED_COMMANDS',
|
||||
'PORTED_TOOLS',
|
||||
'build_command_backlog',
|
||||
'build_port_manifest',
|
||||
'build_tool_backlog',
|
||||
]
|
||||
13
agent能力提升参考源码/src/commands.py
Normal file
13
agent能力提升参考源码/src/commands.py
Normal file
@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import PortingBacklog, PortingModule
|
||||
|
||||
PORTED_COMMANDS = (
|
||||
PortingModule('main', 'Expose a Python CLI for manifest and backlog reporting', 'src/main.py', 'implemented'),
|
||||
PortingModule('summary', 'Render a Markdown overview of the current porting workspace', 'src/query_engine.py', 'implemented'),
|
||||
PortingModule('subsystems', 'List the current Python modules participating in the rewrite', 'src/port_manifest.py', 'implemented'),
|
||||
)
|
||||
|
||||
|
||||
def build_command_backlog() -> PortingBacklog:
|
||||
return PortingBacklog(title='Command surface', modules=list(PORTED_COMMANDS))
|
||||
38
agent能力提升参考源码/src/main.py
Normal file
38
agent能力提升参考源码/src/main.py
Normal file
@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .port_manifest import build_port_manifest
|
||||
from .query_engine import QueryEnginePort
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description='Python porting workspace for the Claude Code rewrite effort')
|
||||
subparsers = parser.add_subparsers(dest='command', required=True)
|
||||
subparsers.add_parser('summary', help='render a Markdown summary of the Python porting workspace')
|
||||
subparsers.add_parser('manifest', help='print the current Python workspace manifest')
|
||||
list_parser = subparsers.add_parser('subsystems', help='list the current Python modules in the workspace')
|
||||
list_parser.add_argument('--limit', type=int, default=16)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
manifest = build_port_manifest()
|
||||
if args.command == 'summary':
|
||||
print(QueryEnginePort(manifest).render_summary())
|
||||
return 0
|
||||
if args.command == 'manifest':
|
||||
print(manifest.to_markdown())
|
||||
return 0
|
||||
if args.command == 'subsystems':
|
||||
for subsystem in manifest.top_level_modules[: args.limit]:
|
||||
print(f'{subsystem.name} {subsystem.file_count} {subsystem.notes}')
|
||||
return 0
|
||||
parser.error(f'unknown command: {args.command}')
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
31
agent能力提升参考源码/src/models.py
Normal file
31
agent能力提升参考源码/src/models.py
Normal file
@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Subsystem:
|
||||
name: str
|
||||
path: str
|
||||
file_count: int
|
||||
notes: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortingModule:
|
||||
name: str
|
||||
responsibility: str
|
||||
source_hint: str
|
||||
status: str = 'planned'
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortingBacklog:
|
||||
title: str
|
||||
modules: list[PortingModule] = field(default_factory=list)
|
||||
|
||||
def summary_lines(self) -> list[str]:
|
||||
return [
|
||||
f'- {module.name} [{module.status}] — {module.responsibility} (from {module.source_hint})'
|
||||
for module in self.modules
|
||||
]
|
||||
52
agent能力提升参考源码/src/port_manifest.py
Normal file
52
agent能力提升参考源码/src/port_manifest.py
Normal file
@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Subsystem
|
||||
|
||||
DEFAULT_SRC_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortManifest:
|
||||
src_root: Path
|
||||
total_python_files: int
|
||||
top_level_modules: tuple[Subsystem, ...]
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
lines = [
|
||||
f'Port root: `{self.src_root}`',
|
||||
f'Total Python files: **{self.total_python_files}**',
|
||||
'',
|
||||
'Top-level Python modules:',
|
||||
]
|
||||
for module in self.top_level_modules:
|
||||
lines.append(f'- `{module.name}` ({module.file_count} files) — {module.notes}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def build_port_manifest(src_root: Path | None = None) -> PortManifest:
|
||||
root = src_root or DEFAULT_SRC_ROOT
|
||||
files = [path for path in root.rglob('*.py') if path.is_file()]
|
||||
counter = Counter(
|
||||
path.relative_to(root).parts[0] if len(path.relative_to(root).parts) > 1 else path.name
|
||||
for path in files
|
||||
if path.name != '__pycache__'
|
||||
)
|
||||
notes = {
|
||||
'__init__.py': 'package export surface',
|
||||
'main.py': 'CLI entrypoint',
|
||||
'port_manifest.py': 'workspace manifest generation',
|
||||
'query_engine.py': 'port orchestration summary layer',
|
||||
'commands.py': 'command backlog metadata',
|
||||
'tools.py': 'tool backlog metadata',
|
||||
'models.py': 'shared dataclasses',
|
||||
'task.py': 'task-level planning structures',
|
||||
}
|
||||
modules = tuple(
|
||||
Subsystem(name=name, path=f'src/{name}', file_count=count, notes=notes.get(name, 'Python port support module'))
|
||||
for name, count in counter.most_common()
|
||||
)
|
||||
return PortManifest(src_root=root, total_python_files=len(files), top_level_modules=modules)
|
||||
32
agent能力提升参考源码/src/query_engine.py
Normal file
32
agent能力提升参考源码/src/query_engine.py
Normal file
@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .commands import build_command_backlog
|
||||
from .port_manifest import PortManifest, build_port_manifest
|
||||
from .tools import build_tool_backlog
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryEnginePort:
|
||||
manifest: PortManifest
|
||||
|
||||
@classmethod
|
||||
def from_workspace(cls) -> 'QueryEnginePort':
|
||||
return cls(manifest=build_port_manifest())
|
||||
|
||||
def render_summary(self) -> str:
|
||||
command_backlog = build_command_backlog()
|
||||
tool_backlog = build_tool_backlog()
|
||||
sections = [
|
||||
'# Python Porting Workspace Summary',
|
||||
'',
|
||||
self.manifest.to_markdown(),
|
||||
'',
|
||||
f'{command_backlog.title}:',
|
||||
*command_backlog.summary_lines(),
|
||||
'',
|
||||
f'{tool_backlog.title}:',
|
||||
*tool_backlog.summary_lines(),
|
||||
]
|
||||
return '\n'.join(sections)
|
||||
10
agent能力提升参考源码/src/task.py
Normal file
10
agent能力提升参考源码/src/task.py
Normal file
@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortingTask:
|
||||
title: str
|
||||
detail: str
|
||||
completed: bool = False
|
||||
13
agent能力提升参考源码/src/tools.py
Normal file
13
agent能力提升参考源码/src/tools.py
Normal file
@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import PortingBacklog, PortingModule
|
||||
|
||||
PORTED_TOOLS = (
|
||||
PortingModule('port_manifest', 'Inspect the active Python source tree and summarize the current rewrite surface', 'src/port_manifest.py', 'implemented'),
|
||||
PortingModule('backlog_models', 'Represent subsystem and backlog metadata as Python dataclasses', 'src/models.py', 'implemented'),
|
||||
PortingModule('query_engine', 'Coordinate Python-facing rewrite summaries and reporting', 'src/query_engine.py', 'implemented'),
|
||||
)
|
||||
|
||||
|
||||
def build_tool_backlog() -> PortingBacklog:
|
||||
return PortingBacklog(title='Tool surface', modules=list(PORTED_TOOLS))
|
||||
34
agent能力提升参考源码/tests/test_porting_workspace.py
Normal file
34
agent能力提升参考源码/tests/test_porting_workspace.py
Normal file
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from src.port_manifest import build_port_manifest
|
||||
from src.query_engine import QueryEnginePort
|
||||
|
||||
|
||||
class PortingWorkspaceTests(unittest.TestCase):
|
||||
def test_manifest_counts_python_files(self) -> None:
|
||||
manifest = build_port_manifest()
|
||||
self.assertGreaterEqual(manifest.total_python_files, 7)
|
||||
self.assertTrue(manifest.top_level_modules)
|
||||
|
||||
def test_query_engine_summary_mentions_workspace(self) -> None:
|
||||
summary = QueryEnginePort.from_workspace().render_summary()
|
||||
self.assertIn('Python Porting Workspace Summary', summary)
|
||||
self.assertIn('Command surface', summary)
|
||||
self.assertIn('Tool surface', summary)
|
||||
|
||||
def test_cli_summary_runs(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'src.main', 'summary'],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertIn('Python Porting Workspace Summary', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
30
app/(admin)/_components/admin-layout-shell.tsx
Normal file
30
app/(admin)/_components/admin-layout-shell.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TopNavbar } from "@/components/top-navbar";
|
||||
|
||||
type AdminLayoutShellProps = ComponentProps<typeof TopNavbar> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function shouldHideSidebarChrome(pathname: string) {
|
||||
return pathname === "/settings" || pathname.startsWith("/settings/");
|
||||
}
|
||||
|
||||
export function AdminLayoutShell({
|
||||
children,
|
||||
...topNavbarProps
|
||||
}: AdminLayoutShellProps) {
|
||||
const pathname = usePathname();
|
||||
const hideSidebarChrome = shouldHideSidebarChrome(pathname);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
{hideSidebarChrome ? null : <TopNavbar {...topNavbarProps} />}
|
||||
<div className={hideSidebarChrome ? "flex-1" : "flex-1 pl-16"}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { TopNavbar } from "@/components/top-navbar";
|
||||
import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries";
|
||||
import { AdminLayoutShell } from "./_components/admin-layout-shell";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@ -13,38 +13,45 @@ export default async function AdminLayout({
|
||||
getNavLinks(),
|
||||
getTutorialVideos(),
|
||||
]);
|
||||
const settingsHref =
|
||||
session?.user?.type === "admin"
|
||||
? "/settings"
|
||||
: session?.user?.type === "agent_admin"
|
||||
? "/settings/agent-workflow"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<TopNavbar
|
||||
siteName={siteSettings.siteName}
|
||||
siteLogo={siteSettings.siteLogo || undefined}
|
||||
siteLogoDark={siteSettings.siteLogoDark || undefined}
|
||||
isAdmin={session?.user?.type === "admin"}
|
||||
customLinks={navLinks.map((link) => ({
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
url: link.url,
|
||||
order: link.order,
|
||||
toolboxGroup: link.toolboxGroup || 1,
|
||||
}))}
|
||||
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
|
||||
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
||||
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
||||
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
||||
tutorialVideos={tutorialVideos.map(v => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
videoUrl: v.videoUrl,
|
||||
coverUrl: v.coverUrl,
|
||||
description: v.description,
|
||||
order: v.order,
|
||||
}))}
|
||||
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||
/>
|
||||
<div className="flex-1 pl-16">{children}</div>
|
||||
</div>
|
||||
<AdminLayoutShell
|
||||
siteName={siteSettings.siteName}
|
||||
siteLogo={siteSettings.siteLogo || undefined}
|
||||
siteLogoDark={siteSettings.siteLogoDark || undefined}
|
||||
isAdmin={session?.user?.type === "admin" || session?.user?.type === "agent_admin"}
|
||||
settingsHref={settingsHref}
|
||||
customLinks={navLinks.map((link) => ({
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
url: link.url,
|
||||
order: link.order,
|
||||
toolboxGroup: link.toolboxGroup || 1,
|
||||
}))}
|
||||
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
|
||||
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
||||
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
||||
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
||||
tutorialVideos={tutorialVideos.map((v) => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
videoUrl: v.videoUrl,
|
||||
coverUrl: v.coverUrl,
|
||||
description: v.description,
|
||||
order: v.order,
|
||||
}))}
|
||||
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
|
||||
videoCanvasEnabled={(siteSettings as any).videoCanvasEnabled ?? true}
|
||||
>
|
||||
{children}
|
||||
</AdminLayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
706
app/(admin)/settings/_components/access-security-dashboard.tsx
Normal file
706
app/(admin)/settings/_components/access-security-dashboard.tsx
Normal file
@ -0,0 +1,706 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
Clock3,
|
||||
Globe2,
|
||||
LogIn,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Unlock,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
|
||||
type Severity = "info" | "watch" | "high" | "critical";
|
||||
|
||||
type AccessLogEvent = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
ip: string;
|
||||
userEmail: string | null;
|
||||
userRole: string | null;
|
||||
method: string | null;
|
||||
path: string | null;
|
||||
routeGroup: string | null;
|
||||
statusCode: number | null;
|
||||
outcome: string;
|
||||
severity: Severity;
|
||||
reasons: string[] | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AccessLogTopIp = {
|
||||
ip: string;
|
||||
total: number;
|
||||
uniqueUsers: number;
|
||||
failureCount: number;
|
||||
rateLimitedCount: number;
|
||||
authFailureCount: number;
|
||||
loginSuccessCount: number;
|
||||
suspiciousCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
severity: Severity;
|
||||
failureRate: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
type AccessLogResponse = {
|
||||
overview: {
|
||||
totalEvents: number;
|
||||
uniqueIps: number;
|
||||
abnormalEvents: number;
|
||||
abnormalIps: number;
|
||||
rateLimitedEvents: number;
|
||||
manualBlockedEvents: number;
|
||||
loginSuccessEvents: number;
|
||||
unauthorizedEvents: number;
|
||||
};
|
||||
topIps: AccessLogTopIp[];
|
||||
hourlyStats: Array<{
|
||||
hour: string;
|
||||
count: number;
|
||||
abnormalCount: number;
|
||||
}>;
|
||||
pathStats: Array<{
|
||||
path: string | null;
|
||||
routeGroup: string | null;
|
||||
count: number;
|
||||
abnormalCount: number;
|
||||
}>;
|
||||
recentEvents: AccessLogEvent[];
|
||||
rules: string[];
|
||||
};
|
||||
|
||||
type AccessBlocklistEntry = {
|
||||
id: string;
|
||||
ip: string;
|
||||
reason: string | null;
|
||||
isActive: boolean;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdByEmail: string | null;
|
||||
};
|
||||
|
||||
const severityLabels: Record<Severity, string> = {
|
||||
info: "正常",
|
||||
watch: "关注",
|
||||
high: "异常",
|
||||
critical: "高危",
|
||||
};
|
||||
|
||||
const severityClassNames: Record<Severity, string> = {
|
||||
info:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300",
|
||||
watch:
|
||||
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
|
||||
high:
|
||||
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300",
|
||||
critical:
|
||||
"border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300",
|
||||
};
|
||||
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getStatusClassName(statusCode: number | null) {
|
||||
if (!statusCode) return "text-muted-foreground";
|
||||
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
|
||||
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
|
||||
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
|
||||
return "text-emerald-700 dark:text-emerald-300";
|
||||
}
|
||||
|
||||
function AccessSeverityBadge({ severity }: { severity: Severity }) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
|
||||
variant="outline"
|
||||
>
|
||||
{severityLabels[severity]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessSecurityDashboard() {
|
||||
const [hours, setHours] = useState("24");
|
||||
const [severity, setSeverity] = useState("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [blockIp, setBlockIp] = useState("");
|
||||
const [blockReason, setBlockReason] = useState("");
|
||||
const [isSavingBlock, setIsSavingBlock] = useState(false);
|
||||
|
||||
const accessLogUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
hours,
|
||||
severity,
|
||||
limit: "160",
|
||||
});
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
return `/api/admin/access-logs?${params.toString()}`;
|
||||
}, [hours, query, severity]);
|
||||
|
||||
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
|
||||
accessLogUrl,
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
);
|
||||
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
|
||||
entries: AccessBlocklistEntry[];
|
||||
}>("/api/admin/access-blocklist", fetcher);
|
||||
|
||||
const activeBlocklistEntries = useMemo(
|
||||
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
|
||||
[blocklistData?.entries]
|
||||
);
|
||||
const blockedRules = useMemo(
|
||||
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
|
||||
[activeBlocklistEntries]
|
||||
);
|
||||
|
||||
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
|
||||
const ip = String(targetIp || blockIp).trim();
|
||||
const reason = String(targetReason ?? blockReason).trim();
|
||||
if (!ip) {
|
||||
toast.error("请输入要拉黑的 IP");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingBlock(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/access-blocklist", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ip, reason }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "拉黑失败");
|
||||
}
|
||||
|
||||
toast.success(`${ip} 已加入手动黑名单`);
|
||||
setBlockIp("");
|
||||
setBlockReason("");
|
||||
await Promise.all([mutateBlocklist(), mutate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "拉黑失败");
|
||||
} finally {
|
||||
setIsSavingBlock(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/access-blocklist", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: entry.id,
|
||||
isActive,
|
||||
reason: entry.reason || "",
|
||||
expiresAt: entry.expiresAt || null,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "更新失败");
|
||||
}
|
||||
|
||||
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
|
||||
await Promise.all([mutateBlocklist(), mutate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const overviewCards = [
|
||||
{
|
||||
key: "events",
|
||||
label: "访问记录",
|
||||
value: formatNumber(data?.overview.totalEvents),
|
||||
note: `${hours} 小时窗口`,
|
||||
icon: Activity,
|
||||
className:
|
||||
"border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100",
|
||||
},
|
||||
{
|
||||
key: "ips",
|
||||
label: "独立 IP",
|
||||
value: formatNumber(data?.overview.uniqueIps),
|
||||
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
|
||||
icon: Globe2,
|
||||
className:
|
||||
"border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100",
|
||||
},
|
||||
{
|
||||
key: "abnormal",
|
||||
label: "异常事件",
|
||||
value: formatNumber(data?.overview.abnormalEvents),
|
||||
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
|
||||
icon: ShieldAlert,
|
||||
className:
|
||||
"border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100",
|
||||
},
|
||||
{
|
||||
key: "login",
|
||||
label: "成功登录",
|
||||
value: formatNumber(data?.overview.loginSuccessEvents),
|
||||
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
|
||||
icon: LogIn,
|
||||
className:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
|
||||
},
|
||||
];
|
||||
|
||||
const maxHourlyCount = Math.max(
|
||||
1,
|
||||
...(data?.hourlyStats || []).map((item) => item.count)
|
||||
);
|
||||
const maxPathCount = Math.max(
|
||||
1,
|
||||
...(data?.pathStats || []).map((item) => item.count)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">访问安全分析</CardTitle>
|
||||
<CardDescription>
|
||||
IP 访问记录、登录来源、异常标注与请求趋势
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative sm:w-[260px]">
|
||||
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-9 pl-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索 IP、路径、用户"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Select onValueChange={setHours} value={hours}>
|
||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">近 1 小时</SelectItem>
|
||||
<SelectItem value="6">近 6 小时</SelectItem>
|
||||
<SelectItem value="24">近 24 小时</SelectItem>
|
||||
<SelectItem value="72">近 3 天</SelectItem>
|
||||
<SelectItem value="168">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSeverity} value={severity}>
|
||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部级别</SelectItem>
|
||||
<SelectItem value="abnormal">仅异常</SelectItem>
|
||||
<SelectItem value="watch">关注</SelectItem>
|
||||
<SelectItem value="high">异常</SelectItem>
|
||||
<SelectItem value="critical">高危</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="h-9 gap-2"
|
||||
onClick={() => mutate()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{overviewCards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-lg border p-4", item.className)}
|
||||
key={item.key}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-current/70">{item.label}</div>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="mt-3 font-semibold text-3xl tracking-tight">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
小时趋势
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
橙色表示异常事件占比
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[180px] items-end gap-1">
|
||||
{(data?.hourlyStats || []).map((item) => {
|
||||
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
|
||||
const abnormalHeight =
|
||||
item.count > 0
|
||||
? Math.max(0, (item.abnormalCount / item.count) * height)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
|
||||
key={item.hour}
|
||||
style={{ height: `${height}%` }}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-orange-400"
|
||||
style={{ height: `${abnormalHeight}%` }}
|
||||
/>
|
||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
|
||||
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
|
||||
{formatNumber(item.abnormalCount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
|
||||
暂无访问记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
|
||||
<CardDescription>
|
||||
管理员手动加入后,命中 IP 会被阻断并写入访问日志
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
|
||||
<Input
|
||||
className="h-9"
|
||||
onChange={(event) => setBlockIp(event.target.value)}
|
||||
placeholder="IP 或 IPv4 CIDR"
|
||||
value={blockIp}
|
||||
/>
|
||||
<Input
|
||||
className="h-9"
|
||||
onChange={(event) => setBlockReason(event.target.value)}
|
||||
placeholder="拉黑原因"
|
||||
value={blockReason}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 gap-2"
|
||||
disabled={isSavingBlock}
|
||||
onClick={() => saveBlockEntry()}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
加入黑名单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{activeBlocklistEntries.map((entry) => (
|
||||
<div
|
||||
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
|
||||
key={entry.id}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
|
||||
{entry.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
|
||||
{entry.reason || "未填写原因"}
|
||||
</div>
|
||||
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
|
||||
{formatTime(entry.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
|
||||
onClick={() => updateBlockEntry(entry, false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
解除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeBlocklistEntries.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
|
||||
暂无手动拉黑 IP
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
|
||||
<CardDescription>
|
||||
按限流、失败率、认证异常、敏感路径和请求量综合排序
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(data?.topIps || []).map((item) => (
|
||||
<div
|
||||
className="rounded-lg border bg-background px-4 py-3"
|
||||
key={item.ip}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono font-semibold text-sm">
|
||||
{item.ip}
|
||||
</span>
|
||||
<AccessSeverityBadge severity={item.severity} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
|
||||
<span>{formatNumber(item.total)} 次访问</span>
|
||||
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
|
||||
<span>失败率 {formatPercent(item.failureRate)}</span>
|
||||
<span>最近 {formatTime(item.lastSeen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 md:justify-end">
|
||||
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
|
||||
(reason) => (
|
||||
<Badge
|
||||
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
key={reason}
|
||||
variant="outline"
|
||||
>
|
||||
{reason}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
|
||||
onClick={() =>
|
||||
saveBlockEntry(
|
||||
item.ip,
|
||||
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!data?.topIps || data.topIps.length === 0) && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||||
暂无 IP 聚合数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">访问路径</CardTitle>
|
||||
<CardDescription>按请求数量排序</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{(data?.pathStats || []).map((item) => {
|
||||
const abnormalRate =
|
||||
item.count > 0 ? item.abnormalCount / item.count : 0;
|
||||
return (
|
||||
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{item.path || "/"}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{formatNumber(item.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
|
||||
style={{
|
||||
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="-mt-2 h-full rounded-full bg-orange-400"
|
||||
style={{
|
||||
width: `${Math.round(abnormalRate * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!data?.pathStats || data.pathStats.length === 0) && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||||
暂无路径统计
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">访问日志列表</CardTitle>
|
||||
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(data?.rules || []).map((rule) => (
|
||||
<Badge
|
||||
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
key={rule}
|
||||
variant="outline"
|
||||
>
|
||||
{rule}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="min-w-[980px]">
|
||||
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
|
||||
<div>时间</div>
|
||||
<div>IP</div>
|
||||
<div>状态</div>
|
||||
<div>路径</div>
|
||||
<div>用户</div>
|
||||
<div>标注</div>
|
||||
</div>
|
||||
{(data?.recentEvents || []).map((event) => (
|
||||
<div
|
||||
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
|
||||
key={event.id}
|
||||
>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatTime(event.createdAt)}
|
||||
</div>
|
||||
<div className="font-mono text-xs">{event.ip}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold text-xs",
|
||||
getStatusClassName(event.statusCode)
|
||||
)}
|
||||
>
|
||||
{event.statusCode || "--"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{event.method || "GET"} {event.path || "/"}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-muted-foreground text-xs">
|
||||
{event.reasons?.join("、") || event.outcome}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs">
|
||||
{event.userEmail || "匿名"}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<UserCheck className="h-3 w-3" />
|
||||
{event.userRole || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{event.severity === "high" || event.severity === "critical" ? (
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
) : null}
|
||||
<AccessSeverityBadge severity={event.severity} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!data?.recentEvents || data.recentEvents.length === 0) && (
|
||||
<div className="py-10 text-center text-muted-foreground text-sm">
|
||||
暂无访问日志
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
app/(admin)/settings/_components/admin-settings-navigation.tsx
Normal file
148
app/(admin)/settings/_components/admin-settings-navigation.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ADMIN_SETTINGS_NAV_ITEMS } from "../_lib/settings-navigation";
|
||||
import type { AdminSettingsNavItem } from "../_lib/types";
|
||||
|
||||
function SettingsQuickAction({
|
||||
activeTab,
|
||||
item,
|
||||
onTabChange,
|
||||
stretched = false,
|
||||
}: {
|
||||
activeTab: string;
|
||||
item: AdminSettingsNavItem;
|
||||
onTabChange: (value: string) => void;
|
||||
stretched?: boolean;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
const className =
|
||||
item.value === activeTab
|
||||
? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50";
|
||||
const layoutClassName = stretched
|
||||
? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
|
||||
: "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className={`${layoutClassName} ${className}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${layoutClassName} ${className}`}
|
||||
onClick={() => onTabChange(item.value)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSettingsNavigation({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: {
|
||||
activeTab: string;
|
||||
onTabChange: (value: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||
(item) => item.value === activeTab
|
||||
);
|
||||
const handleSelectChange = (value: string) => {
|
||||
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||
(item) => item.value === value
|
||||
);
|
||||
if (nextItem?.href) {
|
||||
router.push(nextItem.href);
|
||||
return;
|
||||
}
|
||||
onTabChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="系统设置导航"
|
||||
className="sticky top-2 z-30 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85"
|
||||
>
|
||||
<div className="md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select onValueChange={handleSelectChange} value={activeTab}>
|
||||
<SelectTrigger className="h-8 flex-1 border-border/70 bg-background shadow-none">
|
||||
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 rounded-md border-border/70 px-2.5 text-xs"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link href="/">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
|
||||
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
key={item.value}
|
||||
onTabChange={onTabChange}
|
||||
stretched
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 min-w-0 rounded-md border-border/70 px-2.5 text-xs"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link href="/">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回工作台
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
148
app/(admin)/settings/_components/local-ai-command-center.tsx
Normal file
148
app/(admin)/settings/_components/local-ai-command-center.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LOCAL_AI_QUICK_LINK_GROUPS } from "../_lib/local-ai-sections";
|
||||
import type { LocalAiOverviewItem, LocalAiQuickLink } from "../_lib/types";
|
||||
import { SettingStatusBadge } from "./setting-status-badge";
|
||||
|
||||
export function LocalAiCommandCenter({
|
||||
openSectionCount,
|
||||
providerCount,
|
||||
modelCount,
|
||||
overviewItems,
|
||||
quickLinks,
|
||||
isSectionExpanded,
|
||||
onFocusSection,
|
||||
onSetAllExpanded,
|
||||
}: {
|
||||
openSectionCount: number;
|
||||
providerCount: number;
|
||||
modelCount: number;
|
||||
overviewItems: LocalAiOverviewItem[];
|
||||
quickLinks: LocalAiQuickLink[];
|
||||
isSectionExpanded: (sectionKey: string) => boolean;
|
||||
onFocusSection: (sectionKey: string) => void;
|
||||
onSetAllExpanded: (expanded: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="border-b bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground">
|
||||
SYSTEM INTEGRATIONS
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg">
|
||||
高级能力与外部服务
|
||||
</h2>
|
||||
<p className="max-w-3xl text-muted-foreground text-xs leading-5">
|
||||
把商品策划、图像处理、3D/CAD、实时画布和开放 API
|
||||
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<SettingStatusBadge tone="neutral">
|
||||
已展开 {openSectionCount} 个模块
|
||||
</SettingStatusBadge>
|
||||
<SettingStatusBadge tone="enabled">
|
||||
供应商 {providerCount}
|
||||
</SettingStatusBadge>
|
||||
<SettingStatusBadge tone="neutral">
|
||||
模型 {modelCount}
|
||||
</SettingStatusBadge>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
onClick={() => onSetAllExpanded(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
全部展开
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
onClick={() => onSetAllExpanded(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
全部收起
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-px bg-border md:grid-cols-2 xl:grid-cols-4">
|
||||
{overviewItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className="group bg-background p-3 text-left transition-colors hover:bg-muted/30"
|
||||
key={item.key}
|
||||
onClick={() => onFocusSection(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-xs">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingStatusBadge tone={item.tone}>
|
||||
{item.status}
|
||||
</SettingStatusBadge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t bg-muted/10 px-4 py-3">
|
||||
<div className="grid gap-3 lg:grid-cols-4">
|
||||
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
|
||||
<div className="space-y-1.5" key={group}>
|
||||
<div className="font-semibold text-[11px] text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quickLinks
|
||||
.filter((item) => item.group === group)
|
||||
.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] transition-colors hover:bg-background",
|
||||
isSectionExpanded(item.key)
|
||||
? "border-foreground/20 bg-background text-foreground"
|
||||
: "border-border bg-transparent text-muted-foreground"
|
||||
)}
|
||||
key={item.key}
|
||||
onClick={() => onFocusSection(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
item.tone === "enabled" && "bg-emerald-500",
|
||||
item.tone === "disabled" && "bg-zinc-300",
|
||||
item.tone === "warning" && "bg-amber-500",
|
||||
item.tone === "neutral" && "bg-sky-500"
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
app/(admin)/settings/_components/local-ai-section-shell.tsx
Normal file
121
app/(admin)/settings/_components/local-ai-section-shell.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SettingStatusTone } from "../_lib/types";
|
||||
import { SettingStatusBadge } from "./setting-status-badge";
|
||||
|
||||
export function LocalAiGroupHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground uppercase">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||
<h3 className="font-semibold text-sm text-foreground">{title}</h3>
|
||||
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalAiSectionSummary({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{
|
||||
label: string;
|
||||
tone?: SettingStatusTone;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{items.map((item) => (
|
||||
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
|
||||
{item.label}
|
||||
</SettingStatusBadge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalAiSectionToggleButton({
|
||||
expanded,
|
||||
onClick,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="h-7 shrink-0 gap-1 rounded-md px-2 text-[11px]"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{expanded ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)}
|
||||
{expanded ? "收起配置" : "展开配置"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalAiSectionShell({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
summary,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
summary?: ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card id={id}>
|
||||
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{summary}
|
||||
</div>
|
||||
<LocalAiSectionToggleButton expanded={expanded} onClick={onToggle} />
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"local-ai-config-card-content space-y-4 pt-4",
|
||||
!expanded && "hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,754 @@
|
||||
"use client";
|
||||
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||
} from "@/lib/ai/detail-agent-model-config";
|
||||
import {
|
||||
PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL,
|
||||
PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID,
|
||||
} from "@/lib/product-planning/model-config";
|
||||
import type { LocalAiPlanningSectionsProps } from "../../_lib/types";
|
||||
import {
|
||||
LocalAiGroupHeading,
|
||||
LocalAiSectionShell,
|
||||
LocalAiSectionSummary,
|
||||
} from "../local-ai-section-shell";
|
||||
|
||||
export function LocalAiPlanningSections({
|
||||
siteSettings,
|
||||
setSiteSettings,
|
||||
productPlanningApiConfig,
|
||||
setProductPlanningApiConfig,
|
||||
productPlanningAiModelConfig,
|
||||
setProductPlanningAiModelConfig,
|
||||
moonshotDetailAgentConfig,
|
||||
setMoonshotDetailAgentConfig,
|
||||
isLocalAiSectionExpanded,
|
||||
toggleLocalAiSection,
|
||||
handleSaveProductPlanningApiConfig,
|
||||
handleSaveProductPlanningAiModelConfig,
|
||||
handleSaveMoonshotDetailAgentConfig,
|
||||
}: LocalAiPlanningSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
<LocalAiGroupHeading
|
||||
description="把电商数据源、商品策划专用模型、详情 Agent 和业务数据库放在同一组,减少跨页面查找。"
|
||||
eyebrow="Planning"
|
||||
title="商品策划与数据集成"
|
||||
/>
|
||||
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置商品策划工具页面使用的淘宝 / 京东
|
||||
Onebound、问大家和销量接口。保存后商品策划相关接口会优先读取这里的配置,未填写时才回退到环境变量。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("product-planning-api")}
|
||||
id="local-ai-section-product-planning-api"
|
||||
onToggle={() => toggleLocalAiSection("product-planning-api")}
|
||||
summary={
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: siteSettings.productPlanningEnabled
|
||||
? "功能已启用"
|
||||
: "功能已关闭",
|
||||
tone: siteSettings.productPlanningEnabled
|
||||
? "enabled"
|
||||
: "disabled",
|
||||
},
|
||||
{
|
||||
label: productPlanningApiConfig.oneboundBaseUrl
|
||||
? "Onebound 已配置"
|
||||
: "Onebound 待配置",
|
||||
tone: productPlanningApiConfig.oneboundBaseUrl
|
||||
? "enabled"
|
||||
: "warning",
|
||||
},
|
||||
{
|
||||
label: productPlanningApiConfig.planningDbHost
|
||||
? "回写库已配置"
|
||||
: "回写库待配置",
|
||||
tone: productPlanningApiConfig.planningDbHost
|
||||
? "enabled"
|
||||
: "neutral",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title="商品策划工具 API 配置"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-base">启用商品策划工具</Label>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
关闭后左侧导航入口会隐藏,商品策划页面和相关接口也会一并禁用。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={siteSettings.productPlanningEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
productPlanningEnabled: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningOneboundBaseUrl">
|
||||
Onebound API 地址
|
||||
</Label>
|
||||
<Input
|
||||
id="productPlanningOneboundBaseUrl"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
oneboundBaseUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api-gw.onebound.cn"
|
||||
value={productPlanningApiConfig.oneboundBaseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
用于淘宝评价、商品详情、关键词洞察以及京东商品详情。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDetailPlusBaseUrl">
|
||||
详情增强接口地址(公网)
|
||||
</Label>
|
||||
<Input
|
||||
id="productPlanningDetailPlusBaseUrl"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
detailPlusBaseUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="http://ltai5tdb2ogyj59fquyedh.yydd.ai:18082/api/public/taobao-detail-plus"
|
||||
value={productPlanningApiConfig.detailPlusBaseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
竞品分析会优先用这个接口查询商品详情,查不到再走 Onebound 旧接口。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDetailPlusInternalBaseUrl">
|
||||
详情增强接口地址(内网,可选)
|
||||
</Label>
|
||||
<Input
|
||||
id="productPlanningDetailPlusInternalBaseUrl"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
detailPlusInternalBaseUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="http://127.0.0.1:4000/api/public/taobao-detail-plus"
|
||||
value={productPlanningApiConfig.detailPlusInternalBaseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
服务器同机部署时可填内网地址,系统会先尝试内网再尝试公网。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label htmlFor="productPlanningDetailPlusApiKey">
|
||||
详情增强接口 API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="productPlanningDetailPlusApiKey"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
detailPlusApiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="留空保存时会保留原 API Key"
|
||||
type="password"
|
||||
value={productPlanningApiConfig.detailPlusApiKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningQaApiUrl">问大家接口地址</Label>
|
||||
<Input
|
||||
id="productPlanningQaApiUrl"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
qaApiUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="http://119.91.237.2:5432/taobao_social_feed_aggregate"
|
||||
value={productPlanningApiConfig.qaApiUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
用于商品策划工具中的问大家抓取。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningTaobaoApiKey">
|
||||
淘宝 / 京东 API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="productPlanningTaobaoApiKey"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
taobaoApiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入 Onebound API Key"
|
||||
type="password"
|
||||
value={productPlanningApiConfig.taobaoApiKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningTaobaoApiSecret">
|
||||
淘宝 / 京东 API Secret
|
||||
</Label>
|
||||
<Input
|
||||
id="productPlanningTaobaoApiSecret"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
taobaoApiSecret: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入 Onebound API Secret"
|
||||
type="password"
|
||||
value={productPlanningApiConfig.taobaoApiSecret}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningQaToken">问大家 Token</Label>
|
||||
<Input
|
||||
id="productPlanningQaToken"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
qaToken: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入问大家抓取 Token"
|
||||
type="password"
|
||||
value={productPlanningApiConfig.qaToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningSaleApiUrl">销量接口地址</Label>
|
||||
<Input
|
||||
id="productPlanningSaleApiUrl"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
saleApiUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="http://119.91.237.2:5432/taobao_item_month_sale"
|
||||
value={productPlanningApiConfig.saleApiUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
用于商品策划工具中的销量查询。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningSaleToken">销量 Token</Label>
|
||||
<Input
|
||||
id="productPlanningSaleToken"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
saleToken: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入销量查询 Token"
|
||||
type="password"
|
||||
value={productPlanningApiConfig.saleToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/30 p-4 lg:col-span-2">
|
||||
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">企划数据库回写配置</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
旧接口兜底查到的淘宝详情会同步写入新的企划详情表,供后续新接口命中。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">SSL</span>
|
||||
<Switch
|
||||
checked={productPlanningApiConfig.planningDbSsl}
|
||||
onCheckedChange={(checked) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
planningDbSsl: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDbHost">数据库 Host</Label>
|
||||
<Input
|
||||
id="productPlanningDbHost"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
planningDbHost: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="156.225.27.52"
|
||||
value={productPlanningApiConfig.planningDbHost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDbPort">数据库端口</Label>
|
||||
<Input
|
||||
id="productPlanningDbPort"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
planningDbPort: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="60023"
|
||||
value={productPlanningApiConfig.planningDbPort}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDbDatabase">数据库名</Label>
|
||||
<Input
|
||||
id="productPlanningDbDatabase"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
planningDbDatabase: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="planning_database"
|
||||
value={productPlanningApiConfig.planningDbDatabase}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDbUser">数据库账号</Label>
|
||||
<Input
|
||||
id="productPlanningDbUser"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
planningDbUser: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="planning_user"
|
||||
value={productPlanningApiConfig.planningDbUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDbPassword">数据库密码</Label>
|
||||
<Input
|
||||
id="productPlanningDbPassword"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
planningDbPassword: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="留空保存时会保留原密码"
|
||||
type="password"
|
||||
value={productPlanningApiConfig.planningDbPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningDbCharset">字符集</Label>
|
||||
<Input
|
||||
id="productPlanningDbCharset"
|
||||
onChange={(e) =>
|
||||
setProductPlanningApiConfig((prev) => ({
|
||||
...prev,
|
||||
planningDbCharset: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="utf8mb4"
|
||||
value={productPlanningApiConfig.planningDbCharset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 text-xs">
|
||||
<p>
|
||||
• 当前功能状态:
|
||||
{siteSettings.productPlanningEnabled
|
||||
? "已启用,左侧导航和接口开放中。"
|
||||
: "已关闭,入口、页面和接口都会被禁用。"}
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
竞品分析会优先读取详情增强接口;未命中时自动走旧接口,并将旧结果回写到企划详情表。
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
若某个字段留空,系统会继续尝试读取旧环境变量,避免你切换配置期间功能直接中断。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveProductPlanningApiConfig}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存商品策划工具设置
|
||||
</Button>
|
||||
</div>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
为商品策划工具单独绑定一套分析模型。开启后,评价分析、问大家分析、竞品图片分析和
|
||||
SKU 卖点分析会优先走这里配置的 OpenAI
|
||||
兼容模型,不再依赖站点默认模型。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("product-planning-ai-model")}
|
||||
id="local-ai-section-product-planning-ai-model"
|
||||
onToggle={() => toggleLocalAiSection("product-planning-ai-model")}
|
||||
summary={
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: productPlanningAiModelConfig.enabled
|
||||
? "专用模型已启用"
|
||||
: "回退默认模型",
|
||||
tone: productPlanningAiModelConfig.enabled
|
||||
? "enabled"
|
||||
: "neutral",
|
||||
},
|
||||
{
|
||||
label: productPlanningAiModelConfig.modelId || "模型 ID 待配置",
|
||||
tone: productPlanningAiModelConfig.modelId
|
||||
? "neutral"
|
||||
: "warning",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title="商品策划专用 AI 模型"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-base">启用专用模型</Label>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
推荐在商品策划需要固定分析口径时开启。关闭后会自动回退到站点当前可用的默认模型。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={productPlanningAiModelConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setProductPlanningAiModelConfig((prev) => ({
|
||||
...prev,
|
||||
enabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningAiProviderName">提供商名称</Label>
|
||||
<Input
|
||||
id="productPlanningAiProviderName"
|
||||
onChange={(e) =>
|
||||
setProductPlanningAiModelConfig((prev) => ({
|
||||
...prev,
|
||||
providerName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Product Planning Qwen3-VL"
|
||||
value={productPlanningAiModelConfig.providerName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningAiDisplayName">显示名称</Label>
|
||||
<Input
|
||||
id="productPlanningAiDisplayName"
|
||||
onChange={(e) =>
|
||||
setProductPlanningAiModelConfig((prev) => ({
|
||||
...prev,
|
||||
displayName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="商品策划专用 Qwen3-VL"
|
||||
value={productPlanningAiModelConfig.displayName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningAiBaseUrl">
|
||||
OpenAI 兼容 API 地址
|
||||
</Label>
|
||||
<Input
|
||||
id="productPlanningAiBaseUrl"
|
||||
onChange={(e) =>
|
||||
setProductPlanningAiModelConfig((prev) => ({
|
||||
...prev,
|
||||
baseUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_BASE_URL}
|
||||
value={productPlanningAiModelConfig.baseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
支持填写服务根地址,例如
|
||||
`http://103.118.245.6:6081`。系统会自动兼容补全 `/v1` 路径。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productPlanningAiModelId">模型 ID</Label>
|
||||
<Input
|
||||
id="productPlanningAiModelId"
|
||||
onChange={(e) =>
|
||||
setProductPlanningAiModelConfig((prev) => ({
|
||||
...prev,
|
||||
modelId: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID}
|
||||
value={productPlanningAiModelConfig.modelId}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
你的 Qwen3-VL OpenAI 兼容接口若使用默认模型名,可直接保留
|
||||
`Qwen3-VL-4B-Instruct`。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label htmlFor="productPlanningAiApiKey">API Key</Label>
|
||||
<Input
|
||||
id="productPlanningAiApiKey"
|
||||
onChange={(e) =>
|
||||
setProductPlanningAiModelConfig((prev) => ({
|
||||
...prev,
|
||||
apiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入商品策划专用模型 API Key"
|
||||
type="password"
|
||||
value={productPlanningAiModelConfig.apiKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 text-xs">
|
||||
<p>
|
||||
•
|
||||
开启后,商品策划页的文本分析和图片分析都会优先使用这里配置的独立模型。
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
关闭后,系统会继续回退到站点默认文本/视觉模型,不会影响其他页面的模型设置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveProductPlanningAiModelConfig}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存商品策划专用模型
|
||||
</Button>
|
||||
</div>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
为 AI详情生成 单独配置 Kimi Code 编程套餐
|
||||
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("moonshot-detail-agent")}
|
||||
id="local-ai-section-moonshot-detail-agent"
|
||||
onToggle={() => toggleLocalAiSection("moonshot-detail-agent")}
|
||||
summary={
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: moonshotDetailAgentConfig.isEnabled
|
||||
? "模型已启用"
|
||||
: "模型已关闭",
|
||||
tone: moonshotDetailAgentConfig.isEnabled
|
||||
? "enabled"
|
||||
: "disabled",
|
||||
},
|
||||
{
|
||||
label: moonshotDetailAgentConfig.modelId || "模型 ID 待配置",
|
||||
tone: moonshotDetailAgentConfig.modelId ? "neutral" : "warning",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title={<>Kimi Code 编程套餐配置</>}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotProviderName">提供商名称</Label>
|
||||
<Input
|
||||
id="moonshotProviderName"
|
||||
onChange={(e) =>
|
||||
setMoonshotDetailAgentConfig((prev) => ({
|
||||
...prev,
|
||||
providerName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Kimi Code 编程套餐"
|
||||
value={moonshotDetailAgentConfig.providerName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
|
||||
<Input
|
||||
id="moonshotBaseUrl"
|
||||
onChange={(e) =>
|
||||
setMoonshotDetailAgentConfig((prev) => ({
|
||||
...prev,
|
||||
baseUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}
|
||||
value={moonshotDetailAgentConfig.baseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
|
||||
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotApiKey">
|
||||
API Key{" "}
|
||||
{moonshotDetailAgentConfig.providerId ? "(留空则不修改)" : "*"}
|
||||
</Label>
|
||||
<Input
|
||||
id="moonshotApiKey"
|
||||
onChange={(e) =>
|
||||
setMoonshotDetailAgentConfig((prev) => ({
|
||||
...prev,
|
||||
apiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
moonshotDetailAgentConfig.providerId
|
||||
? "留空则不修改当前 Key"
|
||||
: "请输入 Kimi Code 控制台 API Key"
|
||||
}
|
||||
type="password"
|
||||
value={moonshotDetailAgentConfig.apiKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
|
||||
<Input
|
||||
id="moonshotModelId"
|
||||
onChange={(e) =>
|
||||
setMoonshotDetailAgentConfig((prev) => ({
|
||||
...prev,
|
||||
modelId: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID}
|
||||
value={moonshotDetailAgentConfig.modelId}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Kimi Code 编程套餐专用接口固定使用
|
||||
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotDisplayName">模型显示名称</Label>
|
||||
<Input
|
||||
id="moonshotDisplayName"
|
||||
onChange={(e) =>
|
||||
setMoonshotDetailAgentConfig((prev) => ({
|
||||
...prev,
|
||||
displayName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME}
|
||||
value={moonshotDetailAgentConfig.displayName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">启用该详情 Agent 模型</Label>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
关闭后仍会保留记录,但不会被 AI详情生成 选作专用 Agent 模型。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={moonshotDetailAgentConfig.isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setMoonshotDetailAgentConfig((prev) => ({
|
||||
...prev,
|
||||
isEnabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
|
||||
{moonshotDetailAgentConfig.isEnabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 text-xs">
|
||||
<p>
|
||||
• 专用于 AI详情生成 的分镜规划与排版复审,不影响普通 Agent 对话。
|
||||
</p>
|
||||
<p>
|
||||
• 系统会按多模态 Agent
|
||||
模型写入:支持视觉、支持结构化规划、支持协议复审。
|
||||
</p>
|
||||
<p>• 保存成功后会自动写入“AI详情生成 - Agent专用模型”。</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存 Kimi Code 编程套餐配置
|
||||
</Button>
|
||||
</div>
|
||||
</LocalAiSectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
30
app/(admin)/settings/_components/setting-status-badge.tsx
Normal file
30
app/(admin)/settings/_components/setting-status-badge.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SettingStatusTone } from "../_lib/types";
|
||||
|
||||
export function SettingStatusBadge({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: SettingStatusTone;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-5 rounded-md border px-1.5 font-medium text-[10px]",
|
||||
tone === "enabled" &&
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",
|
||||
tone === "warning" && "border-amber-200 bg-amber-50 text-amber-700",
|
||||
tone === "neutral" && "border-sky-200 bg-sky-50 text-sky-700"
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
33
app/(admin)/settings/_lib/local-ai-sections.ts
Normal file
33
app/(admin)/settings/_lib/local-ai-sections.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export const LOCAL_AI_EXPANDED_STORAGE_KEY =
|
||||
"admin-settings:local-ai-expanded-sections";
|
||||
|
||||
export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
|
||||
"product-planning-api": true,
|
||||
"product-planning-ai-model": false,
|
||||
"moonshot-detail-agent": false,
|
||||
tripo3d: false,
|
||||
worldlabs3d: false,
|
||||
"3d-model-alias": false,
|
||||
"kittycad-cad": false,
|
||||
rmbg: false,
|
||||
enhance: false,
|
||||
"enhance-pro": false,
|
||||
"canvas-local-edit": false,
|
||||
"ai-wash": false,
|
||||
extend: false,
|
||||
"watermark-remove": false,
|
||||
"angle-edit": false,
|
||||
"layer-split": false,
|
||||
"third-party-sqlserver": false,
|
||||
"external-image-api": true,
|
||||
"realtime-canvas": false,
|
||||
"canvas-image-task-queue": false,
|
||||
"batch-process": false,
|
||||
};
|
||||
|
||||
export const LOCAL_AI_QUICK_LINK_GROUPS = [
|
||||
"商品策划",
|
||||
"3D/CAD",
|
||||
"图像处理",
|
||||
"运行控制",
|
||||
] as const;
|
||||
120
app/(admin)/settings/_lib/settings-navigation.ts
Normal file
120
app/(admin)/settings/_lib/settings-navigation.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bot,
|
||||
HardDrive,
|
||||
Image as ImageIcon,
|
||||
Key,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Scissors,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import type { AdminSettingsNavGroup } from "./types";
|
||||
|
||||
export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
|
||||
{
|
||||
title: "运营与数据",
|
||||
description: "日常运营最常用入口,优先看用户、资产记录和效果数据。",
|
||||
items: [
|
||||
{
|
||||
value: "users",
|
||||
label: "用户管理",
|
||||
description: "用户、权限与账号状态",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
value: "images",
|
||||
label: "生图/视频记录",
|
||||
description: "生成资产、任务与产物追踪",
|
||||
icon: ImageIcon,
|
||||
},
|
||||
{
|
||||
value: "analytics",
|
||||
label: "数据分析",
|
||||
description: "用量、趋势与运营指标",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
value: "access-security",
|
||||
label: "访问安全",
|
||||
description: "访问 IP、异常标注与登录来源",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "站点体验",
|
||||
description: "面向用户可见的站点配置,影响导航、文案、认证和通知体验。",
|
||||
items: [
|
||||
{
|
||||
value: "site",
|
||||
label: "网站设置",
|
||||
description: "基础信息、展示与安全策略",
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
value: "style-pro",
|
||||
label: "风格化 PRO",
|
||||
description: "独立高级风格化配置",
|
||||
icon: Sparkles,
|
||||
href: "/settings/style-pro",
|
||||
},
|
||||
{
|
||||
value: "nav",
|
||||
label: "导航设置",
|
||||
description: "菜单、入口与站内路径",
|
||||
icon: Menu,
|
||||
},
|
||||
{
|
||||
value: "questions",
|
||||
label: "快捷短语",
|
||||
description: "预设问题与提示短语",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
value: "auth",
|
||||
label: "认证设置",
|
||||
description: "登录、注册与访问控制",
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
value: "notification",
|
||||
label: "钉钉通知",
|
||||
description: "告警、审批与业务通知",
|
||||
icon: Bell,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "模型与基础设施",
|
||||
description: "上线前重点检查,集中管理模型、存储和外部服务依赖。",
|
||||
items: [
|
||||
{
|
||||
value: "ai",
|
||||
label: "AI 模型",
|
||||
description: "供应商、模型与 Key 池",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
value: "storage",
|
||||
label: "存储配置",
|
||||
description: "对象存储、迁移与清理",
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
value: "rmbg",
|
||||
label: "高级能力",
|
||||
description: "本地 AI、3D/CAD 与图像处理",
|
||||
icon: Scissors,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ADMIN_SETTINGS_NAV_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap(
|
||||
(group) => group.items
|
||||
);
|
||||
472
app/(admin)/settings/_lib/types.ts
Normal file
472
app/(admin)/settings/_lib/types.ts
Normal file
@ -0,0 +1,472 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
||||
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
|
||||
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
|
||||
import type { AiWashConfig } from "@/lib/ai-wash/config";
|
||||
import type {
|
||||
EnhanceProKreaConfig,
|
||||
EnhanceProKreaModel,
|
||||
EnhanceProPreset,
|
||||
} from "@/lib/enhance-pro/config";
|
||||
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config";
|
||||
import type { PointsSystemConfig } from "@/lib/points/config";
|
||||
import type { ProductPlanningApiConfig } from "@/lib/product-planning/config";
|
||||
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
|
||||
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
|
||||
|
||||
export type SettingStatusTone = "enabled" | "disabled" | "warning" | "neutral";
|
||||
|
||||
export type AdminSettingsNavItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type AdminSettingsNavGroup = {
|
||||
title: string;
|
||||
description: string;
|
||||
items: AdminSettingsNavItem[];
|
||||
};
|
||||
|
||||
export type LocalAiOverviewItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
tone: SettingStatusTone;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export type LocalAiQuickLinkGroup =
|
||||
| "商品策划"
|
||||
| "3D/CAD"
|
||||
| "图像处理"
|
||||
| "运行控制";
|
||||
|
||||
export type LocalAiQuickLink = {
|
||||
key: string;
|
||||
label: string;
|
||||
group: LocalAiQuickLinkGroup;
|
||||
tone: SettingStatusTone;
|
||||
};
|
||||
|
||||
export type LocalAiSectionKey = keyof typeof DEFAULT_LOCAL_AI_EXPANDED_SECTIONS;
|
||||
|
||||
export type LocalAiSectionControlProps = {
|
||||
isLocalAiSectionExpanded: (sectionKey: LocalAiSectionKey) => boolean;
|
||||
toggleLocalAiSection: (sectionKey: LocalAiSectionKey) => void;
|
||||
};
|
||||
|
||||
export type AsyncAction = () => void | Promise<void>;
|
||||
|
||||
type BivariantStateSetter<T> = {
|
||||
bivarianceHack(value: SetStateAction<T>): void;
|
||||
}["bivarianceHack"];
|
||||
|
||||
export type AdminAiProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
type:
|
||||
| "alibaba"
|
||||
| "openai"
|
||||
| "custom"
|
||||
| "local"
|
||||
| "gemini"
|
||||
| "bltcy-video"
|
||||
| "grsai-gpt-image"
|
||||
| "grsai-video"
|
||||
| "huanke-gemini"
|
||||
| "qianwen-video"
|
||||
| "doubao-video"
|
||||
| "ruxa-video"
|
||||
| "volcano-vision"
|
||||
| "volcano-video"
|
||||
| "alibaba-image"
|
||||
| "bfl-image"
|
||||
| "ruxa-image"
|
||||
| "fal-image"
|
||||
| "snapgen-image"
|
||||
| "openai-image"
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
| "zhipu"
|
||||
| "tripo-3d"
|
||||
| "worldlabs-3d"
|
||||
| "kittycad-cad"
|
||||
| "runway"
|
||||
| "krea"
|
||||
| "volcano-multimodal";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AdminAiModel = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
supportsVision: boolean;
|
||||
supportsImageGen: boolean;
|
||||
isEnabled: boolean;
|
||||
isHidden: boolean;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type AdminProvidersData = {
|
||||
providers: AdminAiProvider[];
|
||||
};
|
||||
|
||||
export type AdminModelsData = {
|
||||
models: AdminAiModel[];
|
||||
};
|
||||
|
||||
export type PlanningSiteSettings = {
|
||||
productPlanningEnabled: boolean;
|
||||
};
|
||||
|
||||
export type MoonshotDetailAgentConfig = {
|
||||
providerId: string;
|
||||
modelEntryId: string;
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export type LocalAiPlanningSectionsProps = LocalAiSectionControlProps & {
|
||||
siteSettings: PlanningSiteSettings;
|
||||
setSiteSettings: BivariantStateSetter<PlanningSiteSettings>;
|
||||
productPlanningApiConfig: ProductPlanningApiConfig;
|
||||
setProductPlanningApiConfig: Dispatch<
|
||||
SetStateAction<ProductPlanningApiConfig>
|
||||
>;
|
||||
productPlanningAiModelConfig: ProductPlanningAiModelConfig;
|
||||
setProductPlanningAiModelConfig: Dispatch<
|
||||
SetStateAction<ProductPlanningAiModelConfig>
|
||||
>;
|
||||
moonshotDetailAgentConfig: MoonshotDetailAgentConfig;
|
||||
setMoonshotDetailAgentConfig: Dispatch<
|
||||
SetStateAction<MoonshotDetailAgentConfig>
|
||||
>;
|
||||
handleSaveProductPlanningApiConfig: AsyncAction;
|
||||
handleSaveProductPlanningAiModelConfig: AsyncAction;
|
||||
handleSaveMoonshotDetailAgentConfig: AsyncAction;
|
||||
};
|
||||
|
||||
export type AiProviderSettingsConfig = {
|
||||
providerId: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export type WorldLabsKeyPoolConfig = {
|
||||
importToken: string;
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & {
|
||||
tripo3dConfig: AiProviderSettingsConfig;
|
||||
setTripo3dConfig: Dispatch<SetStateAction<AiProviderSettingsConfig>>;
|
||||
tripoProviders: AdminAiProvider[];
|
||||
filteredTripoProviders: AdminAiProvider[];
|
||||
enabledTripoProviderCount: number;
|
||||
tripoProviderSearch: string;
|
||||
setTripoProviderSearch: Dispatch<SetStateAction<string>>;
|
||||
tripoProviderBusyId: string | null;
|
||||
worldlabs3dConfig: AiProviderSettingsConfig;
|
||||
setWorldlabs3dConfig: Dispatch<SetStateAction<AiProviderSettingsConfig>>;
|
||||
worldlabsKeyPoolConfig: WorldLabsKeyPoolConfig;
|
||||
setWorldlabsKeyPoolConfig: Dispatch<SetStateAction<WorldLabsKeyPoolConfig>>;
|
||||
worldlabsApiKeysDraft: string;
|
||||
setWorldlabsApiKeysDraft: Dispatch<SetStateAction<string>>;
|
||||
worldlabsProviders: AdminAiProvider[];
|
||||
filteredWorldlabsProviders: AdminAiProvider[];
|
||||
enabledWorldlabsProviderCount: number;
|
||||
worldlabsProviderSearch: string;
|
||||
setWorldlabsProviderSearch: Dispatch<SetStateAction<string>>;
|
||||
worldlabsProviderBusyId: string | null;
|
||||
threeDModelAliasConfig: ThreeDModelAliasConfig;
|
||||
setThreeDModelAliasConfig: Dispatch<SetStateAction<ThreeDModelAliasConfig>>;
|
||||
kittycadCadConfig: AiProviderSettingsConfig;
|
||||
setKittycadCadConfig: Dispatch<SetStateAction<AiProviderSettingsConfig>>;
|
||||
isTestingKittycadCad: boolean;
|
||||
getProviderDefaultBaseUrl: (type?: string) => string;
|
||||
handleEditTripoPoolProvider: (provider: AdminAiProvider) => void;
|
||||
handleToggleTripoPoolProvider: (
|
||||
provider: AdminAiProvider,
|
||||
checked: boolean
|
||||
) => void | Promise<void>;
|
||||
handleDeleteProvider: (id: string) => void | Promise<void>;
|
||||
handleSaveTripo3dConfig: AsyncAction;
|
||||
handleAddTripo3dPoolKey: AsyncAction;
|
||||
handleImportWorldlabs3dPoolKeys: AsyncAction;
|
||||
handleAddWorldlabs3dPoolKey: AsyncAction;
|
||||
handleEditWorldlabsPoolProvider: (provider: AdminAiProvider) => void;
|
||||
handleToggleWorldlabsPoolProvider: (
|
||||
provider: AdminAiProvider,
|
||||
checked: boolean
|
||||
) => void | Promise<void>;
|
||||
handleSaveWorldlabs3dConfig: AsyncAction;
|
||||
handleAddThreeDModelAliasEntry: (
|
||||
group: "tripoModels" | "worldModels"
|
||||
) => void;
|
||||
handleThreeDModelAliasEntryChange: (
|
||||
group: "tripoModels" | "worldModels",
|
||||
index: number,
|
||||
field: keyof ThreeDModelAliasConfig["tripoModels"][number],
|
||||
value: ThreeDModelAliasConfig["tripoModels"][number][keyof ThreeDModelAliasConfig["tripoModels"][number]]
|
||||
) => void;
|
||||
handleRemoveThreeDModelAliasEntry: (
|
||||
group: "tripoModels" | "worldModels",
|
||||
index: number
|
||||
) => void;
|
||||
handleResetThreeDModelAliasConfig: () => void;
|
||||
handleSaveThreeDModelAliasConfig: AsyncAction;
|
||||
handleTestKittycadCadConfig: AsyncAction;
|
||||
handleSaveKittycadCadConfig: AsyncAction;
|
||||
};
|
||||
|
||||
export type EnhanceProKeyRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
maskedKey: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
export type RmbgConfig = {
|
||||
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
photoroomApiKey: string;
|
||||
aosApiKey: string;
|
||||
aosApiBaseUrl: string;
|
||||
aosForegroundType: "auto" | "person" | "object" | "stamp";
|
||||
aosCrop: boolean;
|
||||
aosFormat: "png" | "jpg";
|
||||
};
|
||||
|
||||
export type EnhanceConfig = {
|
||||
provider: "vip98k" | "legacy";
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
type: string;
|
||||
scaleFactor: string;
|
||||
useBackupApi: boolean;
|
||||
backupApiUrl: string;
|
||||
backupApiKey: string;
|
||||
backupScale: string;
|
||||
backupModel: string;
|
||||
};
|
||||
|
||||
export type ExtendConfig = {
|
||||
provider: "vip98k" | "dashscope";
|
||||
apiKey: string;
|
||||
xScale: string;
|
||||
yScale: string;
|
||||
};
|
||||
|
||||
export type WatermarkRemoveConfig = {
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type AngleEditConfig = {
|
||||
enabled: boolean;
|
||||
falApiKey: string;
|
||||
loraScale: number;
|
||||
inferenceSteps: number;
|
||||
guidanceScale: number;
|
||||
maxImageSize: number;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
export type LayerSplitConfig = {
|
||||
enabled: boolean;
|
||||
provider: "replicate";
|
||||
replicateApiToken: string;
|
||||
replicateModel: string;
|
||||
layers: number;
|
||||
resolution: number;
|
||||
trueCfgScale: number;
|
||||
numInferenceSteps: number;
|
||||
cfgNormalize: boolean;
|
||||
useEnPrompt: boolean;
|
||||
negativePrompt: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
modelsData?: AdminModelsData;
|
||||
rmbgConfig: RmbgConfig;
|
||||
setRmbgConfig: Dispatch<SetStateAction<RmbgConfig>>;
|
||||
enhanceConfig: EnhanceConfig;
|
||||
setEnhanceConfig: Dispatch<SetStateAction<EnhanceConfig>>;
|
||||
enhanceProConfig: EnhanceProKreaConfig;
|
||||
setEnhanceProConfig: Dispatch<SetStateAction<EnhanceProKreaConfig>>;
|
||||
enhanceProApiKeysDraft: string;
|
||||
setEnhanceProApiKeysDraft: Dispatch<SetStateAction<string>>;
|
||||
enhanceProApiKeysDraftCount: number;
|
||||
draftEnhanceProKeyRows: EnhanceProKeyRow[];
|
||||
savedEnhanceProKeyRows: EnhanceProKeyRow[];
|
||||
enhanceProPresetEditorMode: "simple" | "advanced";
|
||||
setEnhanceProPresetEditorMode: Dispatch<
|
||||
SetStateAction<"simple" | "advanced">
|
||||
>;
|
||||
selectedEnhanceProPresetId: string;
|
||||
setSelectedEnhanceProPresetId: Dispatch<SetStateAction<string>>;
|
||||
selectedEnhanceProPreset: EnhanceProPreset | null | undefined;
|
||||
selectedEnhanceProPresetIndex: number;
|
||||
draggingEnhanceProPresetId: string;
|
||||
aiWashConfig: AiWashConfig;
|
||||
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
||||
aiWashModeIconUploading: boolean;
|
||||
canvasLocalEditConfig: CanvasLocalEditConfig;
|
||||
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
|
||||
extendConfig: ExtendConfig;
|
||||
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
||||
watermarkRemoveConfig: WatermarkRemoveConfig;
|
||||
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
|
||||
angleEditConfig: AngleEditConfig;
|
||||
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
|
||||
pointsSystemConfig: PointsSystemConfig;
|
||||
updatePointsCostField: (
|
||||
key: keyof PointsSystemConfig,
|
||||
value: unknown
|
||||
) => void;
|
||||
layerSplitConfig: LayerSplitConfig;
|
||||
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
|
||||
renderEnhanceProPresetEditor: (
|
||||
preset: EnhanceProPreset,
|
||||
index: number,
|
||||
expanded?: boolean
|
||||
) => ReactNode;
|
||||
getEnhanceProPresetSummary: (preset: EnhanceProPreset) => string;
|
||||
handleSaveRmbgConfig: AsyncAction;
|
||||
handleSaveEnhanceConfig: AsyncAction;
|
||||
handleSaveEnhanceProConfig: AsyncAction;
|
||||
handleAddEnhanceProPoolKey: () => void;
|
||||
handleDeleteEnhanceProPoolKey: (index: number) => void;
|
||||
handleToggleEnhanceProModelVisible: (
|
||||
model: EnhanceProKreaModel,
|
||||
visible: boolean
|
||||
) => void;
|
||||
handleAddEnhanceProPreset: () => void;
|
||||
handleResetEnhanceProPresets: () => void;
|
||||
handleEnhanceProPresetDragStart: (
|
||||
event: DragEvent<HTMLDivElement>,
|
||||
presetId: string
|
||||
) => void;
|
||||
handleEnhanceProPresetDragEnd: () => void;
|
||||
handleEnhanceProPresetDrop: (
|
||||
event: DragEvent<HTMLDivElement>,
|
||||
targetPresetId: string
|
||||
) => void;
|
||||
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
||||
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
||||
handleSaveAiWashConfig: AsyncAction;
|
||||
handleSaveCanvasLocalEditConfig: AsyncAction;
|
||||
handleSaveExtendConfig: AsyncAction;
|
||||
handleSaveWatermarkRemoveConfig: AsyncAction;
|
||||
handleSaveAngleEditConfig: AsyncAction;
|
||||
handleSaveLayerSplitConfig: AsyncAction;
|
||||
};
|
||||
|
||||
export type ThirdPartySqlServerConfig = {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
encrypt: boolean;
|
||||
trustServerCertificate: boolean;
|
||||
connectionTimeoutMs: number;
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
|
||||
export type RealtimeCanvasProviderType = "bfl" | "replicate" | "fal";
|
||||
|
||||
export type RealtimeCanvasConfig = {
|
||||
enabled: boolean;
|
||||
providerType: RealtimeCanvasProviderType;
|
||||
bflApiKey: string;
|
||||
bflBaseUrl: string;
|
||||
bflModel: string;
|
||||
falApiKey: string;
|
||||
falBaseUrl: string;
|
||||
falModel: string;
|
||||
replicateApiToken: string;
|
||||
replicateModel: string;
|
||||
fallbackEnabled: boolean;
|
||||
fallbackProviderType: RealtimeCanvasProviderType;
|
||||
fallbackBflApiKey: string;
|
||||
fallbackBflBaseUrl: string;
|
||||
fallbackBflModel: string;
|
||||
fallbackFalApiKey: string;
|
||||
fallbackFalBaseUrl: string;
|
||||
fallbackFalModel: string;
|
||||
fallbackReplicateApiToken: string;
|
||||
fallbackReplicateModel: string;
|
||||
renderMode: "strict-structure" | "balanced" | "stylize";
|
||||
throttleInterval: number;
|
||||
maxBuffering: number;
|
||||
maxInputResolution: number;
|
||||
jpegQuality: number;
|
||||
previewMegapixels: number;
|
||||
detailMegapixels: number;
|
||||
outputJpegQuality: number;
|
||||
steps: number;
|
||||
scheduleMu: number;
|
||||
feedback: number;
|
||||
imageSize: "square" | "square_hd";
|
||||
};
|
||||
|
||||
export type SceneFusionConfig = {
|
||||
enabled: boolean;
|
||||
preRmbgEnabled: boolean;
|
||||
defaultPrompt: string;
|
||||
maxScenes: number;
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type LocalAiRuntimeSectionsProps = LocalAiSectionControlProps & {
|
||||
thirdPartySqlServerConfig: ThirdPartySqlServerConfig;
|
||||
setThirdPartySqlServerConfig: Dispatch<
|
||||
SetStateAction<ThirdPartySqlServerConfig>
|
||||
>;
|
||||
isThirdPartySqlServerFieldsVisible: boolean;
|
||||
setIsThirdPartySqlServerFieldsVisible: Dispatch<SetStateAction<boolean>>;
|
||||
isTestingThirdPartySqlServer: boolean;
|
||||
realtimeCanvasConfig: RealtimeCanvasConfig;
|
||||
setRealtimeCanvasConfig: Dispatch<SetStateAction<RealtimeCanvasConfig>>;
|
||||
canvasImageTaskQueueConfig: CanvasImageTaskQueueConfig;
|
||||
setCanvasImageTaskQueueConfig: Dispatch<
|
||||
SetStateAction<CanvasImageTaskQueueConfig>
|
||||
>;
|
||||
sceneFusionConfig: SceneFusionConfig;
|
||||
setSceneFusionConfig: Dispatch<SetStateAction<SceneFusionConfig>>;
|
||||
hasThirdPartySqlServerConfiguredValue: boolean;
|
||||
modelsData?: AdminModelsData;
|
||||
providersData?: AdminProvidersData;
|
||||
DEFAULT_BFL_BASE_URL: string;
|
||||
DEFAULT_BFL_REALTIME_MODEL: string;
|
||||
handleSaveThirdPartySqlServerConfig: AsyncAction;
|
||||
handleTestThirdPartySqlServerConfig: AsyncAction;
|
||||
handleSaveRealtimeCanvasConfig: AsyncAction;
|
||||
handleSaveCanvasImageTaskQueueConfig: AsyncAction;
|
||||
handleSaveSceneFusionConfig: AsyncAction;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
19
app/(admin)/settings/agent-workflow/page.tsx
Normal file
19
app/(admin)/settings/agent-workflow/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AgentWorkflowSettingsClient } from "./agent-workflow-settings-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AgentWorkflowSettingsPage() {
|
||||
const session = await requireAgentWorkflowAdmin().catch(() => null);
|
||||
if (!session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentWorkflowSettingsClient
|
||||
backHref={session.user.type === "admin" ? "/settings" : "/"}
|
||||
backLabel={session.user.type === "admin" ? "返回设置" : "返回首页"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
app/(admin)/settings/agent-workflow/psd-templates/page.tsx
Normal file
19
app/(admin)/settings/agent-workflow/psd-templates/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PsdTemplatesSettingsClient } from "./psd-templates-settings-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AgentWorkflowPsdTemplatesPage() {
|
||||
const session = await requireAgentWorkflowAdmin().catch(() => null);
|
||||
if (!session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<PsdTemplatesSettingsClient
|
||||
backHref="/settings/agent-workflow"
|
||||
backLabel="返回风格化工作流配置"
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminSettingsClient } from "./admin-settings-client";
|
||||
@ -6,6 +7,11 @@ import { AdminSettingsClient } from "./admin-settings-client";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const session = await auth().catch(() => null);
|
||||
if (session?.user?.type === "agent_admin") {
|
||||
redirect("/settings/agent-workflow");
|
||||
}
|
||||
|
||||
try {
|
||||
await requireAdmin();
|
||||
} catch {
|
||||
@ -14,4 +20,3 @@ export default async function AdminSettingsPage() {
|
||||
|
||||
return <AdminSettingsClient />;
|
||||
}
|
||||
|
||||
|
||||
16
app/(admin)/settings/style-pro/page.tsx
Normal file
16
app/(admin)/settings/style-pro/page.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { StyleProSettingsClient } from "./style-pro-settings-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StyleProSettingsPage() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
} catch {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return <StyleProSettingsClient />;
|
||||
}
|
||||
|
||||
1199
app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Normal file
1199
app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
15
app/(admin)/settings/video-canvas/page.tsx
Normal file
15
app/(admin)/settings/video-canvas/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { VideoCanvasSettingsClient } from "./video-canvas-settings-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VideoCanvasSettingsPage() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
} catch {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return <VideoCanvasSettingsClient />;
|
||||
}
|
||||
@ -0,0 +1,887 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import {
|
||||
getVideoCanvasFunctionBillingCopy,
|
||||
type VideoCanvasFunctionConfig,
|
||||
type VideoCanvasFunctionDefinition,
|
||||
type VideoCanvasEffectPreset,
|
||||
type VideoCanvasMotionPreset,
|
||||
type VideoCanvasRolePreset,
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
|
||||
type SettingsResponse = {
|
||||
setting?: {
|
||||
value?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type AiModelOption = {
|
||||
id: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
providerName?: string;
|
||||
providerType?: string;
|
||||
supportsVision?: boolean;
|
||||
supportsImageGen?: boolean;
|
||||
isEnabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
isMultimodal?: boolean;
|
||||
capabilities?: unknown;
|
||||
};
|
||||
|
||||
type AiModelsResponse = {
|
||||
models?: AiModelOption[];
|
||||
};
|
||||
|
||||
const INHERIT_MODEL_VALUE = "__inherit__";
|
||||
|
||||
const functionKindLabels: Record<VideoCanvasFunctionDefinition["kind"], string> = {
|
||||
audio: "音频",
|
||||
image: "图片生成",
|
||||
"image-edit": "图片编辑",
|
||||
multimodal: "多模态",
|
||||
utility: "工具",
|
||||
video: "视频生成",
|
||||
workflow: "工作流",
|
||||
};
|
||||
|
||||
function getModelOptionValue(model: AiModelOption) {
|
||||
return String(model.modelId || model.id || "").trim();
|
||||
}
|
||||
|
||||
function getModelOptionLabel(model: AiModelOption) {
|
||||
const name = model.displayName || model.modelId || model.id;
|
||||
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
|
||||
return `${name}${id}`;
|
||||
}
|
||||
|
||||
function isImageModelOption(model: AiModelOption) {
|
||||
return isImageGenerationModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
function isVideoModelOption(model: AiModelOption) {
|
||||
return isVideoModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
function filterModelsForFunctionKind(
|
||||
models: AiModelOption[],
|
||||
kind: VideoCanvasFunctionDefinition["kind"],
|
||||
) {
|
||||
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
|
||||
let filtered = enabled;
|
||||
if (kind === "image" || kind === "image-edit") {
|
||||
filtered = enabled.filter(isImageModelOption);
|
||||
} else if (kind === "video") {
|
||||
filtered = enabled.filter(isVideoModelOption);
|
||||
} else if (kind === "multimodal") {
|
||||
filtered = enabled.filter(
|
||||
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
|
||||
);
|
||||
}
|
||||
return filtered.length ? filtered : enabled;
|
||||
}
|
||||
|
||||
function ModelSelect({
|
||||
allowInherit = false,
|
||||
kind,
|
||||
models,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
allowInherit?: boolean;
|
||||
kind: VideoCanvasFunctionDefinition["kind"];
|
||||
models: AiModelOption[];
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}) {
|
||||
const filteredModels = filterModelsForFunctionKind(models, kind);
|
||||
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
|
||||
const hasCurrent =
|
||||
normalizedValue === "auto" ||
|
||||
normalizedValue === INHERIT_MODEL_VALUE ||
|
||||
filteredModels.some((model) => getModelOptionValue(model) === normalizedValue);
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue === INHERIT_MODEL_VALUE) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
onChange(nextValue);
|
||||
}}
|
||||
value={normalizedValue}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowInherit ? (
|
||||
<SelectItem value={INHERIT_MODEL_VALUE}>继承全局默认模型</SelectItem>
|
||||
) : (
|
||||
<SelectItem value="auto">自动选择可用模型</SelectItem>
|
||||
)}
|
||||
{!hasCurrent && normalizedValue ? (
|
||||
<SelectItem value={normalizedValue}>当前保存值:{normalizedValue}</SelectItem>
|
||||
) : null}
|
||||
{filteredModels.map((model) => {
|
||||
const optionValue = getModelOptionValue(model);
|
||||
return (
|
||||
<SelectItem key={`${model.id}-${optionValue}`} value={optionValue}>
|
||||
{getModelOptionLabel(model)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{filteredModels.length === 0 ? (
|
||||
<SelectItem disabled value="__no_models__">
|
||||
暂无已启用模型
|
||||
</SelectItem>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoCanvasSettingsClient() {
|
||||
const { data, isLoading, mutate } = useSWR<SettingsResponse>(
|
||||
`/api/admin/settings?key=${VIDEO_CANVAS_FUNCTION_CONFIG_KEY}`,
|
||||
fetcher,
|
||||
);
|
||||
const { data: aiModelsData } = useSWR<AiModelsResponse>("/api/admin/ai-models", fetcher);
|
||||
const [config, setConfig] = useState<VideoCanvasFunctionConfig>(defaultVideoCanvasFunctionConfig);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const modelOptions = useMemo(
|
||||
() => (aiModelsData?.models || []).filter((model) => model.isEnabled !== false),
|
||||
[aiModelsData?.models],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setConfig(normalizeVideoCanvasFunctionConfig(data.setting?.value));
|
||||
}, [data]);
|
||||
|
||||
const functions = useMemo(
|
||||
() =>
|
||||
Object.values(config.functions).sort((a, b) => {
|
||||
const compare = (left: string, right: string) =>
|
||||
left === right ? 0 : left < right ? -1 : 1;
|
||||
return compare(a.kind, b.kind) || compare(a.label, b.label);
|
||||
}),
|
||||
[config.functions],
|
||||
);
|
||||
|
||||
const updateRoot = (updates: Partial<VideoCanvasFunctionConfig>) => {
|
||||
setConfig((current) => ({ ...current, ...updates }));
|
||||
};
|
||||
|
||||
const updateFunction = (
|
||||
key: string,
|
||||
updates: Partial<VideoCanvasFunctionDefinition>,
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
functions: {
|
||||
...current.functions,
|
||||
[key]: {
|
||||
...current.functions[key],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const updateEffectPreset = (
|
||||
index: number,
|
||||
updates: Partial<VideoCanvasEffectPreset>,
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
effectPresets: current.effectPresets.map((preset, presetIndex) =>
|
||||
presetIndex === index ? { ...preset, ...updates } : preset,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateMotionPreset = (
|
||||
index: number,
|
||||
updates: Partial<VideoCanvasMotionPreset>,
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
motionPresets: current.motionPresets.map((preset, presetIndex) =>
|
||||
presetIndex === index ? { ...preset, ...updates } : preset,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addMotionPreset = () => {
|
||||
const createdAt = Date.now();
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
motionPresets: [
|
||||
...current.motionPresets,
|
||||
{
|
||||
id: `motion-${createdAt}`,
|
||||
name: "新运镜预设",
|
||||
previewUrl: "/video-canvas/effects/multi-angle.webp",
|
||||
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeMotionPreset = (index: number) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
motionPresets: current.motionPresets.filter(
|
||||
(_, presetIndex) => presetIndex !== index,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addEffectPreset = () => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
effectPresets: [
|
||||
...current.effectPresets,
|
||||
{
|
||||
id: `effect-${Date.now()}`,
|
||||
title: "新视频特效",
|
||||
author: "官方预设",
|
||||
usageCount: 0,
|
||||
model: "Seedance 2.0",
|
||||
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
|
||||
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
|
||||
commercial: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeEffectPreset = (index: number) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRolePreset = (
|
||||
index: number,
|
||||
updates: Partial<VideoCanvasRolePreset>,
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
rolePresets: current.rolePresets.map((preset, presetIndex) =>
|
||||
presetIndex === index ? { ...preset, ...updates } : preset,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addRolePreset = () => {
|
||||
const createdAt = Date.now();
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
rolePresets: [
|
||||
...current.rolePresets,
|
||||
{
|
||||
id: `role-${createdAt}`,
|
||||
assetId: `public-role:role-${createdAt}`,
|
||||
name: "新角色预设",
|
||||
accent: "#525252",
|
||||
description: "管理员配置的公共角色素材。",
|
||||
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
|
||||
tags: "角色",
|
||||
compliant: true,
|
||||
images: {
|
||||
expression: "",
|
||||
face: "",
|
||||
full: "/video-canvas/effects/product-light-sweep.webp",
|
||||
threeView: "",
|
||||
},
|
||||
filters: {
|
||||
age: "青年",
|
||||
build: "匀称",
|
||||
culture: "东亚",
|
||||
era: "现代",
|
||||
gender: "中性",
|
||||
hairColor: "黑发",
|
||||
species: "人类",
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeRolePreset = (index: number) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
value: config,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "保存失败");
|
||||
}
|
||||
toast.success("视频画布功能配置已保存");
|
||||
await mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefault = () => {
|
||||
setConfig(defaultVideoCanvasFunctionConfig);
|
||||
toast.info("已恢复默认配置,保存后生效");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<Button asChild className="mb-2" size="sm" variant="outline">
|
||||
<Link href="/settings">
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
返回设置
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
|
||||
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={saving || isLoading} onClick={resetToDefault} type="button" variant="outline">
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button disabled={saving || isLoading} onClick={save} type="button">
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">全局模型与开关</CardTitle>
|
||||
<CardDescription>
|
||||
默认模型从后台已启用模型中选择;保持自动时由各接口按当前可用模型选择。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="video-canvas-enabled">启用视频画布功能串联</Label>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
id="video-canvas-enabled"
|
||||
onCheckedChange={(checked) => updateRoot({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultImageModel">默认图片/编辑模型</Label>
|
||||
<ModelSelect
|
||||
kind="image-edit"
|
||||
models={modelOptions}
|
||||
onChange={(value) => updateRoot({ defaultImageModel: value || "auto" })}
|
||||
value={config.defaultImageModel}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultVideoModel">默认视频模型</Label>
|
||||
<ModelSelect
|
||||
kind="video"
|
||||
models={modelOptions}
|
||||
onChange={(value) => updateRoot({ defaultVideoModel: value || "auto" })}
|
||||
value={config.defaultVideoModel}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultMultimodalModel">默认多模态模型</Label>
|
||||
<ModelSelect
|
||||
kind="multimodal"
|
||||
models={modelOptions}
|
||||
onChange={(value) => updateRoot({ defaultMultimodalModel: value || "auto" })}
|
||||
value={config.defaultMultimodalModel}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
添加运镜
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{config.motionPresets.map((preset, index) => (
|
||||
<details
|
||||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
||||
key={`${preset.id}-${index}`}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||||
<img
|
||||
alt=""
|
||||
className="h-14 w-14 shrink-0 rounded-md object-cover"
|
||||
src={preset.previewUrl}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{preset.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{preset.prompt || "未设置插入提示词"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`删除运镜 ${preset.name}`}
|
||||
className="shrink-0"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
removeMotionPreset(index);
|
||||
}}
|
||||
size="icon"
|
||||
title={`删除运镜 ${preset.name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</summary>
|
||||
|
||||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
|
||||
<Input
|
||||
id={`motion-name-${index}`}
|
||||
onChange={(event) =>
|
||||
updateMotionPreset(index, { name: event.target.value })
|
||||
}
|
||||
value={preset.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
|
||||
<Input
|
||||
id={`motion-preview-${index}`}
|
||||
onChange={(event) =>
|
||||
updateMotionPreset(index, { previewUrl: event.target.value })
|
||||
}
|
||||
value={preset.previewUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
|
||||
<Textarea
|
||||
id={`motion-prompt-${index}`}
|
||||
onChange={(event) =>
|
||||
updateMotionPreset(index, { prompt: event.target.value })
|
||||
}
|
||||
value={preset.prompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
添加特效
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{config.effectPresets.map((preset, index) => (
|
||||
<details
|
||||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
||||
key={`${preset.id}-${index}`}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||||
<img
|
||||
alt=""
|
||||
className="h-12 w-12 shrink-0 rounded-md object-cover"
|
||||
src={preset.thumbnailUrl}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{preset.title}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`删除特效 ${preset.title}`}
|
||||
className="shrink-0"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
removeEffectPreset(index);
|
||||
}}
|
||||
size="icon"
|
||||
title={`删除特效 ${preset.title}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</summary>
|
||||
|
||||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
|
||||
<Input
|
||||
id={`effect-title-${index}`}
|
||||
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
|
||||
value={preset.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`effect-author-${index}`}>作者</Label>
|
||||
<Input
|
||||
id={`effect-author-${index}`}
|
||||
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
|
||||
value={preset.author}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
|
||||
<Input
|
||||
id={`effect-model-${index}`}
|
||||
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
|
||||
value={preset.model}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
|
||||
<Input
|
||||
id={`effect-count-${index}`}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
updateEffectPreset(index, {
|
||||
usageCount: Math.max(0, Number(event.target.value) || 0),
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
value={preset.usageCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
|
||||
<Input
|
||||
id={`effect-thumbnail-${index}`}
|
||||
onChange={(event) =>
|
||||
updateEffectPreset(index, { thumbnailUrl: event.target.value })
|
||||
}
|
||||
value={preset.thumbnailUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
|
||||
<Textarea
|
||||
id={`effect-prompt-${index}`}
|
||||
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
|
||||
value={preset.prompt}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 md:col-span-2">
|
||||
<Switch
|
||||
checked={preset.commercial}
|
||||
id={`effect-commercial-${index}`}
|
||||
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
|
||||
/>
|
||||
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
添加角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{config.rolePresets.map((preset, index) => (
|
||||
<details
|
||||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
||||
key={`${preset.id}-${index}`}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||||
<img
|
||||
alt=""
|
||||
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
|
||||
src={preset.images.full}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{preset.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{preset.assetId} · {preset.tags || "未设置标签"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`删除角色 ${preset.name}`}
|
||||
className="shrink-0"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
removeRolePreset(index);
|
||||
}}
|
||||
size="icon"
|
||||
title={`删除角色 ${preset.name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</summary>
|
||||
|
||||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
|
||||
<Input
|
||||
id={`role-name-${index}`}
|
||||
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
|
||||
value={preset.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
|
||||
<Input
|
||||
id={`role-asset-${index}`}
|
||||
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
|
||||
value={preset.assetId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
|
||||
<Input
|
||||
id={`role-tags-${index}`}
|
||||
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
|
||||
value={preset.tags}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
|
||||
<Textarea
|
||||
id={`role-description-${index}`}
|
||||
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
|
||||
value={preset.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
|
||||
<Textarea
|
||||
id={`role-prompt-${index}`}
|
||||
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
|
||||
value={preset.prompt}
|
||||
/>
|
||||
</div>
|
||||
{([
|
||||
["full", "角色立绘"],
|
||||
["face", "脸部近景"],
|
||||
["expression", "表情参考"],
|
||||
["threeView", "三视图"],
|
||||
] as const).map(([key, label]) => (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
|
||||
<Input
|
||||
id={`role-${key}-${index}`}
|
||||
onChange={(event) =>
|
||||
updateRolePreset(index, {
|
||||
images: { ...preset.images, [key]: event.target.value },
|
||||
})
|
||||
}
|
||||
value={preset.images[key]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{([
|
||||
["gender", "性别"],
|
||||
["age", "年龄段"],
|
||||
["species", "种族"],
|
||||
["era", "时代"],
|
||||
["culture", "文化区域"],
|
||||
["build", "体型"],
|
||||
["hairColor", "发色"],
|
||||
] as const).map(([key, label]) => (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
|
||||
<Input
|
||||
id={`role-filter-${key}-${index}`}
|
||||
onChange={(event) =>
|
||||
updateRolePreset(index, {
|
||||
filters: { ...preset.filters, [key]: event.target.value },
|
||||
})
|
||||
}
|
||||
value={preset.filters[key]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 md:col-span-2">
|
||||
<Switch
|
||||
checked={preset.compliant}
|
||||
id={`role-compliant-${index}`}
|
||||
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
|
||||
/>
|
||||
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
视频画布运行时会通过 /api/video-canvas/config 读取这里保存的提示词。比如“多机位九宫格”的分镜规则、黑色分割线要求和镜头说明,都由管理员在这里维护,前端不再写死覆盖。
|
||||
</p>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
||||
计费说明只展示来源,不在这里改价;需要调整积分请回到全站积分设置、模型设置或对应功能设置统一修改。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{functions.map((item) => (
|
||||
<Card className={!item.enabled ? "opacity-70" : undefined} key={item.key}>
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
<span>{item.label}</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
{functionKindLabels[item.kind]}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
{item.route}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">{item.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor={`enabled-${item.key}`}>启用</Label>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
id={`enabled-${item.key}`}
|
||||
onCheckedChange={(checked) => updateFunction(item.key, { enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
|
||||
<ModelSelect
|
||||
allowInherit
|
||||
kind={item.kind}
|
||||
models={modelOptions}
|
||||
onChange={(value) => updateFunction(item.key, { model: value })}
|
||||
value={item.model || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
积分来源:{getVideoCanvasFunctionBillingCopy(item).shortLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{getVideoCanvasFunctionBillingCopy(item).description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Label htmlFor={`prompt-${item.key}`}>管理员可编辑的预设提示词 / 后端规则</Label>
|
||||
<Textarea
|
||||
className="mt-2 min-h-28"
|
||||
id={`prompt-${item.key}`}
|
||||
onChange={(event) => updateFunction(item.key, { prompt: event.target.value })}
|
||||
value={item.prompt}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1 @@
|
||||
import { GET as AuthGET, POST as AuthPOST } from "@/app/(auth)/auth";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
return AuthGET(req);
|
||||
};
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
return AuthPOST(req);
|
||||
};
|
||||
export { GET, POST } from "@/app/(auth)/auth";
|
||||
|
||||
@ -6,8 +6,14 @@ export function buildAuthConfig(authUrl?: string | null): NextAuthConfig {
|
||||
// Use host-only cookies to avoid cross-domain login issues in multi-domain deployments.
|
||||
const cookieDomain = undefined;
|
||||
|
||||
// trustHost 仅在明确配置时开启,避免 host header 注入攻击
|
||||
// 部署在 Vercel 或受信反向代理后时,设置 AUTH_TRUST_HOST=true
|
||||
const trustHost =
|
||||
process.env.AUTH_TRUST_HOST === "true" ||
|
||||
process.env.VERCEL === "1";
|
||||
|
||||
return {
|
||||
trustHost: true,
|
||||
trustHost,
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
newUser: "/",
|
||||
|
||||
@ -10,8 +10,16 @@ import { buildAuthConfig } from "./auth.config";
|
||||
import { db } from "@/lib/db";
|
||||
import { loginToken, user as userTable } from "@/lib/db/schema";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import {
|
||||
establishSingleActiveSession,
|
||||
getValidatedSessionUser,
|
||||
isManagedSessionUserType,
|
||||
requiresSingleActiveSession,
|
||||
revokeSingleActiveSession,
|
||||
} from "@/lib/auth/session-control";
|
||||
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||
|
||||
export type UserType = "guest" | "regular" | "admin";
|
||||
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
@ -19,10 +27,10 @@ declare module "next-auth" {
|
||||
id: string;
|
||||
type: UserType;
|
||||
name?: string | null;
|
||||
sessionKey?: string;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
// biome-ignore lint/nursery/useConsistentTypeDefinitions: "Required"
|
||||
interface User {
|
||||
id?: string;
|
||||
email?: string | null;
|
||||
@ -36,6 +44,7 @@ declare module "next-auth/jwt" {
|
||||
id: string;
|
||||
type: UserType;
|
||||
name?: string | null;
|
||||
sessionKey?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,13 +200,11 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
|
||||
return `${protocol}://${resolvedHost}`;
|
||||
}
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
} = NextAuth(async () => {
|
||||
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
|
||||
const nextAuth = NextAuth(async () => {
|
||||
const runtimeAuthSettings =
|
||||
process.env.CANVAS_E2E_LOCAL === "true"
|
||||
? null
|
||||
: await getAuthSettings().catch(() => null as any);
|
||||
const resolvedAuthUrl =
|
||||
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
||||
const googleClientId =
|
||||
@ -207,7 +214,6 @@ export const {
|
||||
|
||||
return {
|
||||
...buildAuthConfig(resolvedAuthUrl),
|
||||
trustHost: true,
|
||||
providers: createBaseProviders(googleClientId, googleClientSecret),
|
||||
callbacks: {
|
||||
async signIn({ account, user, profile }) {
|
||||
@ -233,10 +239,19 @@ export const {
|
||||
return Boolean(dbUser?.isActive);
|
||||
},
|
||||
async jwt({ token, user, account, profile }) {
|
||||
let resolvedUserId =
|
||||
typeof token.id === "string" && token.id.length > 0 ? token.id : undefined;
|
||||
let resolvedUserType =
|
||||
typeof token.type === "string" && isManagedSessionUserType(token.type)
|
||||
? token.type
|
||||
: undefined;
|
||||
|
||||
if (user?.id && user?.type) {
|
||||
token.id = user.id as string;
|
||||
token.type = user.type;
|
||||
token.name = user.name;
|
||||
resolvedUserId = token.id;
|
||||
resolvedUserType = token.type;
|
||||
}
|
||||
|
||||
if (account?.provider === "google") {
|
||||
@ -257,10 +272,47 @@ export const {
|
||||
token.type = (dbUser.role as UserType) || "regular";
|
||||
token.name = dbUser.name;
|
||||
token.email = dbUser.email;
|
||||
resolvedUserId = dbUser.id;
|
||||
resolvedUserType = (dbUser.role as UserType) || "regular";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (account && resolvedUserId && requiresSingleActiveSession(resolvedUserType)) {
|
||||
const { sessionKey } = await establishSingleActiveSession({
|
||||
userId: resolvedUserId,
|
||||
});
|
||||
token.sessionKey = sessionKey;
|
||||
} else if (!requiresSingleActiveSession(resolvedUserType)) {
|
||||
delete token.sessionKey;
|
||||
}
|
||||
|
||||
if (account && resolvedUserId) {
|
||||
await recordAccessIpEventFromHeaders({
|
||||
eventType: "login_success",
|
||||
userId: resolvedUserId,
|
||||
userEmail:
|
||||
typeof token.email === "string"
|
||||
? token.email
|
||||
: typeof user?.email === "string"
|
||||
? user.email
|
||||
: null,
|
||||
userRole: resolvedUserType || "regular",
|
||||
method: "POST",
|
||||
path: `/api/auth/${account.provider}`,
|
||||
routeGroup: "auth",
|
||||
statusCode: 200,
|
||||
outcome: "login_success",
|
||||
severity: "info",
|
||||
reasons: ["登录成功"],
|
||||
metadata: {
|
||||
provider: account.provider,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn("[Auth] Failed to record login access event:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
@ -268,6 +320,8 @@ export const {
|
||||
session.user.id = token.id;
|
||||
session.user.type = token.type || "regular";
|
||||
session.user.name = token.name as string;
|
||||
session.user.sessionKey =
|
||||
typeof token.sessionKey === "string" ? token.sessionKey : undefined;
|
||||
}
|
||||
|
||||
return session;
|
||||
@ -275,3 +329,68 @@ export const {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
handlers: { GET, POST },
|
||||
auth: rawAuth,
|
||||
signIn,
|
||||
signOut: rawSignOut,
|
||||
} = nextAuth;
|
||||
|
||||
export { GET, POST, signIn };
|
||||
|
||||
export async function auth() {
|
||||
const session = await rawAuth();
|
||||
if (!session?.user?.id) {
|
||||
return session;
|
||||
}
|
||||
|
||||
if (!requiresSingleActiveSession(session.user.type)) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const sessionKey = session.user.sessionKey;
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedUser = await getValidatedSessionUser({
|
||||
userId: session.user.id,
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
if (!validatedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
email: validatedUser.email,
|
||||
name: validatedUser.name ?? session.user.name,
|
||||
type: validatedUser.role,
|
||||
sessionKey,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to validate active session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(options?: Parameters<typeof rawSignOut>[0]) {
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
if (session?.user?.id && requiresSingleActiveSession(session.user.type)) {
|
||||
await revokeSingleActiveSession({
|
||||
userId: session.user.id,
|
||||
sessionKey: session.user.sessionKey,
|
||||
}).catch((error) => {
|
||||
console.error("[Auth] Failed to revoke active session during sign out:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return rawSignOut(options);
|
||||
}
|
||||
|
||||
@ -20,7 +20,9 @@ import { toast } from "@/components/toast";
|
||||
import { type LoginActionState, login } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AntigravityBackground } from "@/components/antigravity-background";
|
||||
import { createClientLogger } from "@/lib/observability/client-logger";
|
||||
|
||||
const logger = createClientLogger("auth-login");
|
||||
export default function Page() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||
@ -39,6 +41,7 @@ export default function Page() {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isHandlingToken, setIsHandlingToken] = useState(false);
|
||||
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
|
||||
|
||||
// 系统通知状态
|
||||
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null);
|
||||
@ -56,16 +59,43 @@ export default function Page() {
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch("/api/auth/validate-session", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (data?.valid === true && data?.userType !== "guest") {
|
||||
window.location.replace("/");
|
||||
return;
|
||||
}
|
||||
setIsCheckingExistingSession(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setIsCheckingExistingSession(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 使用 token 完成钉钉登录
|
||||
const completeDingTalkLogin = useCallback(async (token: string) => {
|
||||
console.log("[DingTalk] Completing login with token:", token.substring(0, 10) + "...");
|
||||
logger.debug("[DingTalk] Completing login with token", { hasToken: Boolean(token) });
|
||||
setIsHandlingToken(true);
|
||||
|
||||
try {
|
||||
// 获取 CSRF token
|
||||
const csrfRes = await fetch("/api/auth/csrf");
|
||||
const { csrfToken } = await csrfRes.json();
|
||||
console.log("[DingTalk] Got CSRF token");
|
||||
logger.debug("[DingTalk] CSRF token resolved");
|
||||
|
||||
// 手动构造登录请求
|
||||
const loginRes = await fetch("/api/auth/callback/credentials", {
|
||||
@ -81,16 +111,17 @@ export default function Page() {
|
||||
credentials: "include", // 重要:确保 cookie 被发送和接收
|
||||
});
|
||||
|
||||
console.log("[DingTalk] Login response status:", loginRes.status);
|
||||
console.log("[DingTalk] Login response headers:", Object.fromEntries(loginRes.headers.entries()));
|
||||
|
||||
// 检查响应类型
|
||||
const contentType = loginRes.headers.get("content-type");
|
||||
console.log("[DingTalk] Content-Type:", contentType);
|
||||
logger.debug("[DingTalk] Login response received", {
|
||||
status: loginRes.status,
|
||||
redirected: loginRes.redirected,
|
||||
contentType,
|
||||
});
|
||||
|
||||
if (loginRes.redirected || loginRes.status === 302) {
|
||||
// 登录成功,被重定向
|
||||
console.log("[DingTalk] Login successful (redirected to:", loginRes.url, ")");
|
||||
logger.debug("[DingTalk] Login redirected successfully");
|
||||
|
||||
// 等待一小段时间确保 cookie 被设置
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
@ -98,10 +129,12 @@ export default function Page() {
|
||||
// 验证 session 是否创建成功
|
||||
const sessionRes = await fetch("/api/auth/session");
|
||||
const session = await sessionRes.json();
|
||||
console.log("[DingTalk] Session after login:", session);
|
||||
logger.debug("[DingTalk] Session validation completed", {
|
||||
hasUser: Boolean(session?.user),
|
||||
});
|
||||
|
||||
if (session && session.user) {
|
||||
console.log("[DingTalk] Session validated, redirecting to home...");
|
||||
logger.debug("[DingTalk] Session validated, redirecting to home...");
|
||||
setIsSuccessful(true);
|
||||
// 使用 replace 而不是 href,避免在历史记录中留下登录页
|
||||
// 增加短暂延迟确保 cookie 完全同步
|
||||
@ -109,17 +142,21 @@ export default function Page() {
|
||||
window.location.replace("/");
|
||||
}, 200);
|
||||
} else {
|
||||
console.error("[DingTalk] Session not created after login");
|
||||
logger.error("[DingTalk] Session not created after login");
|
||||
setIsHandlingToken(false);
|
||||
toast({ type: "error", description: "登录失败:无法创建会话" });
|
||||
}
|
||||
} else if (contentType?.includes("application/json")) {
|
||||
const result = await loginRes.json();
|
||||
console.log("[DingTalk] Login result:", result);
|
||||
logger.debug("[DingTalk] Login JSON response received", {
|
||||
ok: loginRes.ok,
|
||||
hasUrl: Boolean(result?.url),
|
||||
hasError: Boolean(result?.error),
|
||||
});
|
||||
|
||||
if (result.url || loginRes.ok) {
|
||||
// 登录成功
|
||||
console.log("[DingTalk] Login successful");
|
||||
logger.debug("[DingTalk] Login successful");
|
||||
|
||||
// 等待一小段时间确保 cookie 被设置
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
@ -127,10 +164,12 @@ export default function Page() {
|
||||
// 验证 session
|
||||
const sessionRes = await fetch("/api/auth/session");
|
||||
const session = await sessionRes.json();
|
||||
console.log("[DingTalk] Session after login:", session);
|
||||
logger.debug("[DingTalk] Session validation completed", {
|
||||
hasUser: Boolean(session?.user),
|
||||
});
|
||||
|
||||
if (session && session.user) {
|
||||
console.log("[DingTalk] Session validated, redirecting...");
|
||||
logger.debug("[DingTalk] Session validated, redirecting...");
|
||||
setIsSuccessful(true);
|
||||
// 使用 replace 而不是 href,避免在历史记录中留下登录页
|
||||
// 增加短暂延迟确保 cookie 完全同步
|
||||
@ -138,25 +177,30 @@ export default function Page() {
|
||||
window.location.replace(result.url || "/");
|
||||
}, 200);
|
||||
} else {
|
||||
console.error("[DingTalk] Session not created");
|
||||
logger.error("[DingTalk] Session not created");
|
||||
setIsHandlingToken(false);
|
||||
toast({ type: "error", description: "登录失败:无法创建会话" });
|
||||
}
|
||||
} else {
|
||||
// 登录失败
|
||||
console.error("[DingTalk] Login failed:", result);
|
||||
logger.error("[DingTalk] Login failed", {
|
||||
hasError: Boolean(result?.error),
|
||||
});
|
||||
setIsHandlingToken(false);
|
||||
toast({ type: "error", description: result.error || "登录失败,请重试" });
|
||||
}
|
||||
} else {
|
||||
// 未知响应
|
||||
const responseText = await loginRes.text();
|
||||
console.error("[DingTalk] Unexpected response type. Body:", responseText.substring(0, 200));
|
||||
await loginRes.text();
|
||||
logger.error("[DingTalk] Unexpected response type", {
|
||||
status: loginRes.status,
|
||||
contentType,
|
||||
});
|
||||
setIsHandlingToken(false);
|
||||
toast({ type: "error", description: "登录失败:服务器响应异常" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DingTalk] Login error:", error);
|
||||
logger.error("[DingTalk] Login error:", error);
|
||||
setIsHandlingToken(false);
|
||||
toast({ type: "error", description: "登录发生错误:" + (error instanceof Error ? error.message : String(error)) });
|
||||
}
|
||||
@ -167,7 +211,7 @@ export default function Page() {
|
||||
const handleChunkError = (event: ErrorEvent) => {
|
||||
const target = event.target as any;
|
||||
if (target?.src?.includes("/_next/static/chunks/") || event.message?.includes("Failed to load chunk")) {
|
||||
console.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面");
|
||||
logger.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面");
|
||||
// 3秒后自动刷新页面(清除缓存)
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
@ -183,33 +227,31 @@ export default function Page() {
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// 测试标记 - 确认代码已更新
|
||||
console.log("=".repeat(50));
|
||||
console.log("🔔 登录页面代码版本: 2024-12-10 v3.0");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
// 获取网站设置(添加时间戳防止缓存)
|
||||
fetch(`/api/site-settings?t=${Date.now()}`)
|
||||
fetch(`/api/site-settings?scope=auth&t=${Date.now()}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("[Login] Site settings data:", data);
|
||||
logger.debug("[Login] Site settings loaded", {
|
||||
hasSiteName: Boolean(data.siteName),
|
||||
hasSiteLogo: Boolean(data.siteLogo),
|
||||
hasNotification: Boolean(data.notification),
|
||||
});
|
||||
if (data.siteName) setSiteName(data.siteName);
|
||||
if (data.siteLogo) setSiteLogo(data.siteLogo);
|
||||
if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark);
|
||||
|
||||
// 处理系统通知
|
||||
console.log("[Login] Notification data:", data.notification);
|
||||
if (data.notification && data.notification.enabled) {
|
||||
console.log("[Login] Setting notification and showing dialog");
|
||||
logger.debug("[Login] Setting notification and showing dialog");
|
||||
setNotification(data.notification);
|
||||
// 每次访问登录页都显示通知(重要公告)
|
||||
setShowNotification(true);
|
||||
} else {
|
||||
console.log("[Login] Notification not enabled or missing");
|
||||
logger.debug("[Login] Notification not enabled or missing");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[Login] Failed to fetch site settings:", err);
|
||||
logger.error("[Login] Failed to fetch site settings:", err);
|
||||
});
|
||||
|
||||
// 获取认证设置
|
||||
@ -220,18 +262,29 @@ export default function Page() {
|
||||
if (data.isGoogleEnabled) setIsGoogleEnabled(true);
|
||||
if (data.isGoogleConfigured) setIsGoogleConfigured(true);
|
||||
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
|
||||
if (Array.isArray(data.organizations)) {
|
||||
const rawOrganizations = Array.isArray(data.organizations)
|
||||
? data.organizations
|
||||
: Array.isArray(data.dingTalkOrganizations)
|
||||
? data.dingTalkOrganizations
|
||||
: [];
|
||||
|
||||
if (rawOrganizations.length > 0) {
|
||||
setDingTalkOrganizations(
|
||||
data.organizations
|
||||
.map((org: { name?: string }) => org.name)
|
||||
.filter((name: string | undefined): name is string => !!name),
|
||||
rawOrganizations
|
||||
.map((org: { name?: string } | string) =>
|
||||
typeof org === "string" ? org : org.name,
|
||||
)
|
||||
.filter((name: string | undefined): name is string => Boolean(name?.trim()))
|
||||
.map((name: string) => name.trim()),
|
||||
);
|
||||
}
|
||||
if (data.isRegistrationEnabled !== undefined) {
|
||||
setIsRegistrationEnabled(data.isRegistrationEnabled);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
.catch((error) => {
|
||||
logger.error("[Login] Failed to fetch auth settings", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
@ -260,7 +313,10 @@ export default function Page() {
|
||||
// 监听来自钉钉回调页面的消息
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
console.log("[DingTalk] Received message:", event.data);
|
||||
logger.debug("[DingTalk] Received message", {
|
||||
type: event.data?.type,
|
||||
hasToken: Boolean(event.data?.token),
|
||||
});
|
||||
if (event.data && event.data.type === "dingtalk_login_success") {
|
||||
setShowDingTalkModal(false);
|
||||
setDingTalkIframeSrc("");
|
||||
@ -337,7 +393,37 @@ export default function Page() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 根据组织名称启动钉钉登录(在 iframe 中打开)
|
||||
const getSafeLoginCallbackUrl = () => {
|
||||
if (typeof window === "undefined") return "/";
|
||||
|
||||
const callbackUrl = new URLSearchParams(window.location.search).get(
|
||||
"callbackUrl",
|
||||
);
|
||||
if (
|
||||
callbackUrl &&
|
||||
callbackUrl.startsWith("/") &&
|
||||
!callbackUrl.startsWith("//")
|
||||
) {
|
||||
return callbackUrl;
|
||||
}
|
||||
|
||||
return "/";
|
||||
};
|
||||
|
||||
const buildDingTalkLoginUrl = (orgName?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (orgName) params.set("org", orgName);
|
||||
|
||||
const callbackUrl = getSafeLoginCallbackUrl();
|
||||
if (callbackUrl !== "/") {
|
||||
params.set("callbackUrl", callbackUrl);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return `/api/auth/dingtalk/login${query ? `?${query}` : ""}`;
|
||||
};
|
||||
|
||||
// 根据组织名称启动钉钉登录。钉钉授权页禁止第三方 iframe 嵌入,必须使用顶层跳转。
|
||||
const startDingTalkLoginForOrg = (orgName: string) => {
|
||||
// 清除 URL 中的 token 参数,避免干扰
|
||||
if (
|
||||
@ -348,14 +434,15 @@ export default function Page() {
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
|
||||
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
|
||||
orgName,
|
||||
)}`;
|
||||
const loginUrl = buildDingTalkLoginUrl(orgName);
|
||||
|
||||
console.log("[DingTalk] Opening org login:", orgName, loginUrl);
|
||||
setDingTalkIframeSrc(loginUrl);
|
||||
logger.debug("[DingTalk] Redirecting org login", {
|
||||
hasOrg: Boolean(orgName),
|
||||
loginUrl,
|
||||
});
|
||||
setShowDingTalkModal(true);
|
||||
setShowOrgMenu(false);
|
||||
window.location.assign(loginUrl);
|
||||
};
|
||||
|
||||
// 钉钉登录 - 支持多组织
|
||||
@ -372,7 +459,6 @@ export default function Page() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 兼容:直接使用旧的方式在 iframe 中打开
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.location === "undefined"
|
||||
@ -387,27 +473,9 @@ export default function Page() {
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
|
||||
const param = {
|
||||
redirect_uri: encodeURIComponent(
|
||||
window.location.origin + "/dingtalk-callback.html",
|
||||
),
|
||||
client_id: dingTalkAppKey,
|
||||
scope: "openid",
|
||||
response_type: "code",
|
||||
state: "default",
|
||||
prompt: "consent",
|
||||
};
|
||||
|
||||
const redirectUrl =
|
||||
"https://login.dingtalk.com/oauth2/auth?" +
|
||||
Object.keys(param)
|
||||
.map((key) => key + "=" + (param as any)[key])
|
||||
.join("&");
|
||||
|
||||
console.log("[DingTalk] Opening OAuth URL (legacy):", redirectUrl);
|
||||
|
||||
setDingTalkIframeSrc(redirectUrl);
|
||||
setShowDingTalkModal(true);
|
||||
const loginUrl = buildDingTalkLoginUrl();
|
||||
logger.debug("[DingTalk] Redirecting OAuth login", { loginUrl });
|
||||
window.location.assign(loginUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -417,8 +485,10 @@ export default function Page() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 多组织:点击时展开/收起选择菜单(hover 时也会展开)
|
||||
setShowOrgMenu((prev) => !prev);
|
||||
// 多组织:直接打开弹窗中的组织列表,避免悬浮菜单被页面层级覆盖
|
||||
setDingTalkIframeSrc("");
|
||||
setShowDingTalkModal(true);
|
||||
setShowOrgMenu(false);
|
||||
};
|
||||
|
||||
// 关闭钉钉登录弹窗
|
||||
@ -435,6 +505,14 @@ export default function Page() {
|
||||
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
||||
(isGoogleEnabled && isGoogleConfigured);
|
||||
|
||||
if (isCheckingExistingSession) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
|
||||
正在检查登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0 relative overflow-hidden">
|
||||
{/* Antigravity 动画背景 */}
|
||||
@ -459,7 +537,11 @@ export default function Page() {
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16 backdrop-blur-sm bg-background/80 rounded-2xl p-6 pointer-events-auto">
|
||||
{displayLogo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={displayLogo} alt={siteName} className="h-12 w-auto mb-2 object-contain" />
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={siteName}
|
||||
className="mb-2 h-12 w-auto object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
|
||||
<p className="text-gray-500 text-sm dark:text-zinc-400">
|
||||
@ -577,17 +659,7 @@ export default function Page() {
|
||||
<button
|
||||
key={org}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
|
||||
org,
|
||||
)}`;
|
||||
console.log(
|
||||
"[DingTalk] Selected organization:",
|
||||
org,
|
||||
loginUrl,
|
||||
);
|
||||
setDingTalkIframeSrc(loginUrl);
|
||||
}}
|
||||
onClick={() => startDingTalkLoginForOrg(org)}
|
||||
className="flex w-full items-center justify-between rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span>{org}</span>
|
||||
@ -595,7 +667,7 @@ export default function Page() {
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择后将在下方加载钉钉登录页面。
|
||||
选择后将跳转到钉钉完成授权。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -22,6 +22,7 @@ export default function Page() {
|
||||
const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10);
|
||||
const [isSendingCode, setIsSendingCode] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
|
||||
const redirectTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -40,13 +41,33 @@ export default function Page() {
|
||||
.catch(console.error);
|
||||
|
||||
// 获取网站设置(添加时间戳防止缓存)
|
||||
fetch(`/api/site-settings?t=${Date.now()}`)
|
||||
fetch(`/api/site-settings?scope=branding&t=${Date.now()}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.siteName) setSiteName(data.siteName);
|
||||
if (data.siteLogo) setSiteLogo(data.siteLogo);
|
||||
if (data.siteName) {
|
||||
setSiteName(data.siteName);
|
||||
}
|
||||
if (data.siteLogo) {
|
||||
setSiteLogo(data.siteLogo);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch("/api/auth/validate-session", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data?.valid === true && data?.userType !== "guest") {
|
||||
window.location.replace("/");
|
||||
return;
|
||||
}
|
||||
setIsCheckingExistingSession(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsCheckingExistingSession(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -66,7 +87,6 @@ export default function Page() {
|
||||
}
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router is a stable ref
|
||||
useEffect(() => {
|
||||
if (redirectTimerRef.current) {
|
||||
window.clearTimeout(redirectTimerRef.current);
|
||||
@ -119,6 +139,14 @@ export default function Page() {
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
if (isCheckingExistingSession) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
|
||||
正在检查登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
|
||||
44
app/(chat)/ai-cad/page.tsx
Normal file
44
app/(chat)/ai-cad/page.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
const ZOO_MODELING_APP_SRC =
|
||||
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = {
|
||||
title: "HiCAD",
|
||||
};
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function AiCadPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?callbackUrl=%2Fai-cad");
|
||||
}
|
||||
|
||||
const isReady = existsSync(
|
||||
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
|
||||
);
|
||||
|
||||
if (isReady) {
|
||||
redirect(ZOO_MODELING_APP_SRC);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-svh w-full items-center justify-center bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||
<section className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||
<h1 className="font-semibold text-lg">HiCAD 官方前端尚未构建</h1>
|
||||
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||
请运行
|
||||
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||
pnpm zoo-modeling-app:vendor
|
||||
</code>
|
||||
生成官方前端,然后刷新本页。
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -63,7 +63,6 @@ export async function GET(
|
||||
}
|
||||
|
||||
const emptyDataStream = createUIMessageStream<ChatMessage>({
|
||||
// biome-ignore lint/suspicious/noEmptyBlockStatements: "Needs to exist"
|
||||
execute: () => {},
|
||||
});
|
||||
|
||||
|
||||
108
app/(chat)/api/chat/_lib/agent-output.ts
Normal file
108
app/(chat)/api/chat/_lib/agent-output.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { PostRequestBody } from "../schema";
|
||||
|
||||
export function sanitizeAgentOutputSettingsSection(params: {
|
||||
message?: string;
|
||||
userImageParams?: PostRequestBody["imageParams"];
|
||||
hasUserCustomImageParams: boolean;
|
||||
}): string | undefined {
|
||||
const { message, userImageParams, hasUserCustomImageParams } = params;
|
||||
if (!message?.trim()) return message;
|
||||
|
||||
const outputSectionRegex =
|
||||
/(?:###\s*输出设置|【\s*输出设置\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
|
||||
if (!outputSectionRegex.test(message)) return message;
|
||||
|
||||
if (!hasUserCustomImageParams) {
|
||||
return message.replace(
|
||||
outputSectionRegex,
|
||||
"【输出设置】\n- 参数:按用户面板当前设置执行(未自动改写)"
|
||||
);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const aspectRatio =
|
||||
typeof userImageParams?.aspectRatio === "string" ? userImageParams.aspectRatio.trim() : "";
|
||||
const imageSize =
|
||||
typeof userImageParams?.imageSize === "string" ? userImageParams.imageSize.trim() : "";
|
||||
const imageCount =
|
||||
typeof userImageParams?.imageCount === "number" ? userImageParams.imageCount : undefined;
|
||||
|
||||
if (aspectRatio && aspectRatio !== "auto") {
|
||||
lines.push(`- 宽高比:${aspectRatio}(来自用户面板设置)`);
|
||||
}
|
||||
if (imageSize && imageSize.toUpperCase() !== "2K") {
|
||||
lines.push(`- 清晰度档位:${imageSize.toUpperCase()}(来自用户面板设置)`);
|
||||
}
|
||||
if (typeof imageCount === "number" && Number.isFinite(imageCount) && imageCount !== 1) {
|
||||
lines.push(`- 出图数量:${Math.max(1, Math.round(imageCount))} 张(来自用户面板设置)`);
|
||||
}
|
||||
lines.push("- 其余参数:保持用户面板当前设置");
|
||||
|
||||
return message.replace(outputSectionRegex, `【输出设置】\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
export function normalizeAgentReportHeadingLine(line: string): string {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return line;
|
||||
|
||||
const markdownHeading = trimmed.match(/^#{1,6}\s*(.+?)\s*$/);
|
||||
const plainTitle = markdownHeading?.[1]?.trim() || trimmed;
|
||||
const normalizedTitle = plainTitle
|
||||
.replace(/^[^\u4e00-\u9fa5A-Za-z0-9]+/, "")
|
||||
.trim();
|
||||
|
||||
const knownTitles = [
|
||||
"意图分析",
|
||||
"任务规划",
|
||||
"需求理解",
|
||||
"图片分析",
|
||||
"执行计划",
|
||||
"优化后提示词",
|
||||
"输出设置",
|
||||
"脚本规划",
|
||||
"分镜规划",
|
||||
"执行说明",
|
||||
"视频生成完成",
|
||||
];
|
||||
|
||||
if (knownTitles.some((title) => normalizedTitle === title)) {
|
||||
return `【${normalizedTitle}】`;
|
||||
}
|
||||
|
||||
if (markdownHeading) {
|
||||
return `【${normalizedTitle || plainTitle}】`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
export function stripAgentFriendlyMediaEmbeds(message: string): string {
|
||||
if (!message) return message;
|
||||
|
||||
const withoutInlineMedia = message
|
||||
.replace(/!\[[^\]]*]\((https?:\/\/[^\s)]+)\)/gi, "")
|
||||
.replace(/<video[\s\S]*?<\/video>/gi, "")
|
||||
.replace(/<img[^>]*>/gi, "");
|
||||
|
||||
const cleanedLines = withoutInlineMedia
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
if (/^https?:\/\/\S+$/i.test(trimmed)) return false;
|
||||
if (/^\[[^\]]*]\(https?:\/\/[^\s)]+\)$/i.test(trimmed)) return false;
|
||||
return true;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return cleanedLines.replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
export function normalizeAgentReportForDisplay(message?: string): string | undefined {
|
||||
if (!message?.trim()) return message;
|
||||
const stripped = stripAgentFriendlyMediaEmbeds(message);
|
||||
if (!stripped) return undefined;
|
||||
return stripped
|
||||
.split("\n")
|
||||
.map((line) => normalizeAgentReportHeadingLine(line))
|
||||
.join("\n");
|
||||
}
|
||||
540
app/(chat)/api/chat/_lib/agent-scene-workflow.ts
Normal file
540
app/(chat)/api/chat/_lib/agent-scene-workflow.ts
Normal file
@ -0,0 +1,540 @@
|
||||
export type AgentPresetMessageImagePart = {
|
||||
type: "file";
|
||||
url: string;
|
||||
name: string;
|
||||
mediaType: string;
|
||||
assetId?: string;
|
||||
};
|
||||
|
||||
export function extractCurrentMessageImageParts(
|
||||
parts: Array<Record<string, any>>,
|
||||
): AgentPresetMessageImagePart[] {
|
||||
return parts
|
||||
.filter(
|
||||
(part): part is AgentPresetMessageImagePart =>
|
||||
part?.type === "file" &&
|
||||
typeof part?.url === "string" &&
|
||||
/^https?:\/\//i.test(part.url) &&
|
||||
typeof part?.mediaType === "string" &&
|
||||
part.mediaType.startsWith("image/")
|
||||
)
|
||||
.map((part) => ({
|
||||
type: "file",
|
||||
url: String(part.url).trim(),
|
||||
name: String(part.name || "图片").trim() || "图片",
|
||||
mediaType: String(part.mediaType || "image/jpeg").trim() || "image/jpeg",
|
||||
assetId:
|
||||
typeof part.assetId === "string" && part.assetId.trim() ? part.assetId.trim() : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSceneUploadedImageBreakdown(params: {
|
||||
uploadedImageParts: AgentPresetMessageImagePart[];
|
||||
fallbackPrimaryCount: number;
|
||||
fallbackIncludeModelImage: boolean;
|
||||
}) {
|
||||
const normalizedNames = params.uploadedImageParts.map((part) => String(part.name || "").trim());
|
||||
const primaryCountFromNames = normalizedNames.filter((name) => name.startsWith("主SKU参考图")).length;
|
||||
const secondaryCountFromNames = normalizedNames.filter((name) => name.startsWith("搭配SKU参考图")).length;
|
||||
const hasModelFromNames = normalizedNames.some((name) => name.startsWith("品牌模特图"));
|
||||
const hasNamedBreakdown =
|
||||
primaryCountFromNames > 0 || secondaryCountFromNames > 0 || hasModelFromNames;
|
||||
|
||||
if (hasNamedBreakdown) {
|
||||
return {
|
||||
primaryProductImageCount: primaryCountFromNames,
|
||||
bundleProductImageCount: secondaryCountFromNames,
|
||||
includeModelImage: hasModelFromNames,
|
||||
};
|
||||
}
|
||||
|
||||
const uploadedImageCount = params.uploadedImageParts.length;
|
||||
const includeModelImage =
|
||||
params.fallbackIncludeModelImage && uploadedImageCount > params.fallbackPrimaryCount;
|
||||
const bundleProductImageCount = Math.max(
|
||||
0,
|
||||
uploadedImageCount - params.fallbackPrimaryCount - (includeModelImage ? 1 : 0)
|
||||
);
|
||||
const primaryProductImageCount = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
params.fallbackPrimaryCount,
|
||||
uploadedImageCount - bundleProductImageCount - (includeModelImage ? 1 : 0)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
primaryProductImageCount,
|
||||
bundleProductImageCount,
|
||||
includeModelImage,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveScenePromptOptimizationImageUrls(params: {
|
||||
uploadedImageParts: AgentPresetMessageImagePart[];
|
||||
primaryProductImageCount: number;
|
||||
bundleProductImageCount: number;
|
||||
includeModelImage: boolean;
|
||||
}) {
|
||||
const namedPrimaryParts = params.uploadedImageParts.filter((part) =>
|
||||
String(part.name || "").trim().startsWith("主SKU参考图")
|
||||
);
|
||||
const namedModelPart = params.uploadedImageParts.find((part) =>
|
||||
String(part.name || "").trim().startsWith("品牌模特图")
|
||||
);
|
||||
|
||||
if (namedPrimaryParts.length > 0 || namedModelPart) {
|
||||
return {
|
||||
primaryImageUrl: namedPrimaryParts[0]?.url || "",
|
||||
modelImageUrl: namedModelPart?.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
const primaryImageUrl =
|
||||
params.uploadedImageParts.slice(0, Math.max(0, params.primaryProductImageCount))[0]?.url || "";
|
||||
const modelIndex = params.primaryProductImageCount + params.bundleProductImageCount;
|
||||
|
||||
return {
|
||||
primaryImageUrl,
|
||||
modelImageUrl:
|
||||
params.includeModelImage && modelIndex < params.uploadedImageParts.length
|
||||
? params.uploadedImageParts[modelIndex]?.url || null
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAgentSupplementPrompt(text: string, presetName?: string | null) {
|
||||
const normalized = String(text || "").trim();
|
||||
if (!normalized) return "";
|
||||
const escapedPresetName = String(presetName || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const genericPattern = escapedPresetName
|
||||
? new RegExp(`^请按「${escapedPresetName}」风格化配置顺序处理这\\d+张图片。?$`)
|
||||
: /^请按「.+」风格化配置顺序处理这\d+张图片。?$/;
|
||||
if (genericPattern.test(normalized)) {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeAgentWorkflowPrompt(text: string) {
|
||||
return String(text || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseAgentWorkflowPromptSections(text: string) {
|
||||
const sectionMap = new Map<string, string[]>();
|
||||
let activeSection = "__root__";
|
||||
const normalizedPrompt = normalizeAgentWorkflowPrompt(text);
|
||||
if (!normalizedPrompt) {
|
||||
return sectionMap;
|
||||
}
|
||||
|
||||
normalizedPrompt.split("\n").forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
return;
|
||||
}
|
||||
const headerMatch = trimmedLine.match(/^【([^】]{1,30})】$/);
|
||||
if (headerMatch) {
|
||||
activeSection = headerMatch[1].trim();
|
||||
if (!sectionMap.has(activeSection)) {
|
||||
sectionMap.set(activeSection, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLines = sectionMap.get(activeSection) || [];
|
||||
currentLines.push(trimmedLine);
|
||||
sectionMap.set(activeSection, currentLines);
|
||||
});
|
||||
|
||||
return sectionMap;
|
||||
}
|
||||
|
||||
export function buildAgentPromptSectionText(
|
||||
title: string,
|
||||
lines: string[],
|
||||
options?: { bullet?: boolean }
|
||||
) {
|
||||
const normalizedLines = lines
|
||||
.map((line) => String(line || "").trim())
|
||||
.filter(Boolean);
|
||||
if (normalizedLines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sectionBody = options?.bullet
|
||||
? normalizedLines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n")
|
||||
: normalizedLines.join("\n");
|
||||
|
||||
return `【${title}】\n${sectionBody}`;
|
||||
}
|
||||
|
||||
export function buildOptimizedAgentSceneWorkflowPrompt(params: {
|
||||
workflowPrompt: string;
|
||||
optimizationResult?: {
|
||||
sceneGoal: string;
|
||||
environmentSummary: string;
|
||||
propSummary: string;
|
||||
cameraLightingSummary: string;
|
||||
characterSummary: string;
|
||||
negativeConstraints: string[];
|
||||
conflicts: string[];
|
||||
} | null;
|
||||
}) {
|
||||
const normalizedWorkflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt);
|
||||
if (!normalizedWorkflowPrompt) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const optimizationResult = params.optimizationResult || null;
|
||||
if (!optimizationResult) {
|
||||
return normalizedWorkflowPrompt;
|
||||
}
|
||||
|
||||
const sectionMap = parseAgentWorkflowPromptSections(normalizedWorkflowPrompt);
|
||||
const sceneGoalLines = [optimizationResult.sceneGoal, optimizationResult.environmentSummary].filter(
|
||||
Boolean
|
||||
);
|
||||
const propLines = [optimizationResult.propSummary].filter(Boolean);
|
||||
const cameraLightingLines = [optimizationResult.cameraLightingSummary].filter(Boolean);
|
||||
const characterLines = [optimizationResult.characterSummary].filter(Boolean);
|
||||
const negativeLines = Array.from(
|
||||
new Set([
|
||||
...optimizationResult.negativeConstraints,
|
||||
...optimizationResult.conflicts.map((item) => `冲突校正:${item}`),
|
||||
])
|
||||
);
|
||||
|
||||
const optimizedSections = [
|
||||
buildAgentPromptSectionText("项目背景", sectionMap.get("项目背景") || sectionMap.get("__root__") || []),
|
||||
buildAgentPromptSectionText("主SKU锁定", sectionMap.get("主SKU锁定") || []),
|
||||
buildAgentPromptSectionText("搭配SKU锁定", sectionMap.get("搭配SKU锁定") || []),
|
||||
buildAgentPromptSectionText("参考图", sectionMap.get("参考图") || []),
|
||||
buildAgentPromptSectionText("场景创作总纲", sceneGoalLines),
|
||||
buildAgentPromptSectionText("陈列与道具", propLines),
|
||||
buildAgentPromptSectionText("机位与光线", cameraLightingLines),
|
||||
buildAgentPromptSectionText("人物规则", characterLines),
|
||||
buildAgentPromptSectionText("负面约束", negativeLines, { bullet: true }),
|
||||
].filter(Boolean);
|
||||
|
||||
return optimizedSections.join("\n\n").trim();
|
||||
}
|
||||
|
||||
const AGENT_PROMPT_STRUCTURAL_LABELS = new Set([
|
||||
"角色",
|
||||
"技能",
|
||||
"目标",
|
||||
"约束条件",
|
||||
"流程",
|
||||
"输出",
|
||||
]);
|
||||
|
||||
const AGENT_PROMPT_LABEL_SEMANTIC_ALIASES = new Map<string, string>([
|
||||
["角色", "role"],
|
||||
["技能", "skills"],
|
||||
["目标", "goal"],
|
||||
["约束条件", "constraints"],
|
||||
["流程", "process"],
|
||||
["输出", "output"],
|
||||
["人群选择", "audience"],
|
||||
["人物", "audience"],
|
||||
["主题风格", "style"],
|
||||
["整体风格", "style"],
|
||||
["空间选择", "space"],
|
||||
["空间布局", "space"],
|
||||
["主空间", "space"],
|
||||
["子空间选择", "subspace"],
|
||||
["子空间", "subspace"],
|
||||
["硬装方案", "hardDecoration"],
|
||||
["硬装", "hardDecoration"],
|
||||
["软装方案", "softDecoration"],
|
||||
["软装", "softDecoration"],
|
||||
["光氛选择", "lighting"],
|
||||
["光源布置", "lighting"],
|
||||
["灯具", "lighting"],
|
||||
["机位选择", "camera"],
|
||||
["拍摄角度", "camera"],
|
||||
["配饰陈列", "accessories"],
|
||||
["放在产品内的小件配饰", "accessories"],
|
||||
["前景", "sceneLayout"],
|
||||
["中景", "sceneLayout"],
|
||||
["背景", "sceneLayout"],
|
||||
]);
|
||||
|
||||
const AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS = new Set([
|
||||
"role",
|
||||
"skills",
|
||||
"goal",
|
||||
"constraints",
|
||||
"process",
|
||||
"output",
|
||||
"audience",
|
||||
"style",
|
||||
"space",
|
||||
"subspace",
|
||||
"hardDecoration",
|
||||
"softDecoration",
|
||||
"lighting",
|
||||
"camera",
|
||||
"accessories",
|
||||
"sceneLayout",
|
||||
]);
|
||||
|
||||
export function normalizePromptLineForCompare(line: string) {
|
||||
return String(line || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[::]/g, ":")
|
||||
.replace(/[。;;,,、!!??]+$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function parsePromptLineLabel(line: string) {
|
||||
const normalized = String(line || "").replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return { label: "", value: "" };
|
||||
|
||||
const headerOnlyMatch = normalized.match(/^([^::]{1,20})[::]\s*$/);
|
||||
if (headerOnlyMatch) {
|
||||
return {
|
||||
label: headerOnlyMatch[1].trim(),
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = normalized.match(/^([^::]{1,20})[::]\s*(.+)$/);
|
||||
if (colonMatch) {
|
||||
return {
|
||||
label: colonMatch[1].trim(),
|
||||
value: colonMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const whitespaceMatch = normalized.match(/^([^\s]{1,20})\s+(.+)$/);
|
||||
if (whitespaceMatch) {
|
||||
return {
|
||||
label: whitespaceMatch[1].trim(),
|
||||
value: whitespaceMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return { label: "", value: normalized };
|
||||
}
|
||||
|
||||
export function normalizePromptLabel(label: string) {
|
||||
return String(label || "")
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[::]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function getPromptSemanticLabel(label: string) {
|
||||
const normalized = normalizePromptLabel(label);
|
||||
return AGENT_PROMPT_LABEL_SEMANTIC_ALIASES.get(normalized) || normalized;
|
||||
}
|
||||
|
||||
export function collectPromptSemanticLabelsFromText(text: string) {
|
||||
const semanticLabels = new Set<string>();
|
||||
const normalizedText = String(text || "").replace(/\s+/g, " ").trim();
|
||||
if (!normalizedText) {
|
||||
return semanticLabels;
|
||||
}
|
||||
|
||||
for (const [label, semantic] of AGENT_PROMPT_LABEL_SEMANTIC_ALIASES.entries()) {
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedText.includes(`${label}:`) || normalizedText.includes(`${label}:`)) {
|
||||
semanticLabels.add(semantic);
|
||||
continue;
|
||||
}
|
||||
if (normalizedText.includes(`${label} `) || normalizedText.includes(label)) {
|
||||
semanticLabels.add(semantic);
|
||||
}
|
||||
}
|
||||
|
||||
return semanticLabels;
|
||||
}
|
||||
|
||||
export function shouldDropSceneSlotSemanticLine(
|
||||
slotSemanticLabel: string,
|
||||
workflowSemanticLabels: Set<string>,
|
||||
) {
|
||||
if (!slotSemanticLabel || !AGENT_SCENE_WORKFLOW_OWNED_SEMANTICS.has(slotSemanticLabel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (slotSemanticLabel === "sceneLayout") {
|
||||
return [
|
||||
"style",
|
||||
"space",
|
||||
"subspace",
|
||||
"hardDecoration",
|
||||
"softDecoration",
|
||||
"accessories",
|
||||
"camera",
|
||||
"lighting",
|
||||
].some((semantic) => workflowSemanticLabels.has(semantic));
|
||||
}
|
||||
|
||||
return workflowSemanticLabels.has(slotSemanticLabel);
|
||||
}
|
||||
|
||||
export function dedupeAgentSlotPromptAgainstWorkflow(params: {
|
||||
workflowPrompt?: string | null;
|
||||
slotPrompt?: string | null;
|
||||
modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic";
|
||||
}) {
|
||||
const dedupedSlotLines: string[] = [];
|
||||
const seenExactLines = new Set<string>();
|
||||
const workflowStructuralValues = new Map<string, string>();
|
||||
const workflowSemanticLabels = new Set<string>();
|
||||
|
||||
const rememberLine = (line: string) => {
|
||||
const normalizedLine = normalizePromptLineForCompare(line);
|
||||
if (!normalizedLine) {
|
||||
return;
|
||||
}
|
||||
seenExactLines.add(normalizedLine);
|
||||
};
|
||||
|
||||
const normalizedWorkflowLines = normalizeAgentWorkflowPrompt(params.workflowPrompt || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
normalizedWorkflowLines.forEach((line) => {
|
||||
const parsed = parsePromptLineLabel(line);
|
||||
if (parsed.label) {
|
||||
workflowSemanticLabels.add(getPromptSemanticLabel(parsed.label));
|
||||
}
|
||||
collectPromptSemanticLabelsFromText(line).forEach((semantic) => {
|
||||
workflowSemanticLabels.add(semantic);
|
||||
});
|
||||
collectPromptSemanticLabelsFromText(parsed.value || "").forEach((semantic) => {
|
||||
workflowSemanticLabels.add(semantic);
|
||||
});
|
||||
if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) {
|
||||
workflowStructuralValues.set(
|
||||
parsed.label,
|
||||
normalizePromptLineForCompare(parsed.value || "")
|
||||
);
|
||||
}
|
||||
rememberLine(line);
|
||||
});
|
||||
|
||||
const normalizedSlotLines = String(params.slotPrompt || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
let activeSlotSemanticLabel = "";
|
||||
normalizedSlotLines.forEach((line) => {
|
||||
const normalizedLine = normalizePromptLineForCompare(line);
|
||||
if (!normalizedLine || seenExactLines.has(normalizedLine)) {
|
||||
return;
|
||||
}
|
||||
const parsed = parsePromptLineLabel(line);
|
||||
const slotSemanticLabel = parsed.label ? getPromptSemanticLabel(parsed.label) : "";
|
||||
if (slotSemanticLabel) {
|
||||
activeSlotSemanticLabel = slotSemanticLabel;
|
||||
}
|
||||
if (
|
||||
params.modeType === "scene_generation" &&
|
||||
shouldDropSceneSlotSemanticLine(
|
||||
slotSemanticLabel || activeSlotSemanticLabel,
|
||||
workflowSemanticLabels,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (parsed.label && AGENT_PROMPT_STRUCTURAL_LABELS.has(parsed.label)) {
|
||||
const normalizedValue = normalizePromptLineForCompare(parsed.value || "");
|
||||
const workflowValue = workflowStructuralValues.get(parsed.label);
|
||||
if (workflowValue && workflowValue === normalizedValue) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
seenExactLines.add(normalizedLine);
|
||||
dedupedSlotLines.push(line);
|
||||
});
|
||||
|
||||
return dedupedSlotLines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function buildAgentSlotPrompt(params: {
|
||||
workflowPrompt?: string | null;
|
||||
slotPrompt: string;
|
||||
slotTitle?: string | null;
|
||||
userSupplementPrompt?: string | null;
|
||||
includeSlotTitle?: boolean;
|
||||
modeType?: "scene_generation" | "detail_replacement" | "sku_replacement" | "generic";
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
|
||||
if (workflowPrompt) {
|
||||
lines.push(workflowPrompt);
|
||||
}
|
||||
const slotTitle = String(params.slotTitle || "").trim();
|
||||
if (params.includeSlotTitle !== false && slotTitle) {
|
||||
lines.push(`【槽位】${slotTitle}`);
|
||||
}
|
||||
const dedupedSlotPrompt = dedupeAgentSlotPromptAgainstWorkflow({
|
||||
workflowPrompt,
|
||||
slotPrompt: params.slotPrompt,
|
||||
modeType: params.modeType || "generic",
|
||||
});
|
||||
if (dedupedSlotPrompt) {
|
||||
lines.push(dedupedSlotPrompt);
|
||||
}
|
||||
const supplement = String(params.userSupplementPrompt || "").trim();
|
||||
if (supplement) {
|
||||
lines.push(`【补充要求】${supplement}`);
|
||||
}
|
||||
return lines.join("\n\n").trim();
|
||||
}
|
||||
|
||||
export function buildAgentSceneWorkflowPrompt(params: {
|
||||
workflowPrompt?: string | null;
|
||||
slotPrompt?: string | null;
|
||||
userSupplementPrompt?: string | null;
|
||||
imageRolePrompt?: string | null;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
|
||||
if (workflowPrompt) {
|
||||
lines.push(workflowPrompt);
|
||||
}
|
||||
const slotPrompt = String(params.slotPrompt || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (slotPrompt) {
|
||||
lines.push(slotPrompt);
|
||||
}
|
||||
let imageRolePrompt = String(params.imageRolePrompt || "").trim();
|
||||
if (workflowPrompt.includes("品牌模特图:已选") || workflowPrompt.includes("固定模特图")) {
|
||||
imageRolePrompt = imageRolePrompt
|
||||
.replace(
|
||||
/^如当前请求未附带固定模特图,仅在工作流明确要求人物时再安排辅助人物,且人物不能喧宾夺主。$/m,
|
||||
""
|
||||
)
|
||||
.replace(/^当前没有固定模特图,不要凭空生成强人物主体。$/m, "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (imageRolePrompt) {
|
||||
lines.push(imageRolePrompt);
|
||||
}
|
||||
const supplement = String(params.userSupplementPrompt || "").trim();
|
||||
if (supplement) {
|
||||
lines.push(`【补充要求】${supplement}`);
|
||||
}
|
||||
return lines.join("\n\n").trim();
|
||||
}
|
||||
105
app/(chat)/api/chat/_lib/billing.ts
Normal file
105
app/(chat)/api/chat/_lib/billing.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import {
|
||||
consumeUserPoints,
|
||||
getAiModelById,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
const chatApiLogger = createLogger("chat.billing");
|
||||
|
||||
|
||||
const MODEL_UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function getModelCreditCost(
|
||||
modelConfig: any,
|
||||
target: "image" | "video",
|
||||
options?: {
|
||||
duration?: number | null;
|
||||
imageSize?: string | null;
|
||||
resolution?: string | null;
|
||||
},
|
||||
): number {
|
||||
return getModelGenerationCreditCost(modelConfig, target, options);
|
||||
}
|
||||
|
||||
export async function resolveBillingModelByReference(modelRef: string): Promise<any | undefined> {
|
||||
if (!modelRef) return undefined;
|
||||
|
||||
if (MODEL_UUID_REGEX.test(modelRef)) {
|
||||
return await getAiModelById({ id: modelRef }).catch(() => undefined);
|
||||
}
|
||||
|
||||
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
|
||||
const matched = allModels.find(
|
||||
({ model }: any) => model.id === modelRef || model.modelId === modelRef,
|
||||
);
|
||||
return matched?.model;
|
||||
}
|
||||
|
||||
export async function consumeGeneratedVideoPoints(params: {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
modelRefs: Array<string | null | undefined>;
|
||||
duration?: number | null;
|
||||
resolution?: string | null;
|
||||
}) {
|
||||
try {
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
if (!pointsConfig.enabled) return;
|
||||
|
||||
const userData = await getUserById({ id: params.userId }).catch(() => undefined);
|
||||
if (userData?.isHighVip) {
|
||||
chatApiLogger.info(`[Points] Skip video points for VIP user=${params.userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let billingModel: any | undefined;
|
||||
for (const ref of params.modelRefs) {
|
||||
if (!ref) continue;
|
||||
const resolved = await resolveBillingModelByReference(ref);
|
||||
if (!resolved) continue;
|
||||
billingModel = resolved;
|
||||
if (
|
||||
getModelCreditCost(resolved, "video", {
|
||||
duration: params.duration,
|
||||
resolution: params.resolution,
|
||||
}) > 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!billingModel) return;
|
||||
|
||||
const unitCost = getModelCreditCost(billingModel, "video", {
|
||||
duration: params.duration,
|
||||
resolution: params.resolution,
|
||||
});
|
||||
if (unitCost <= 0) return;
|
||||
|
||||
const consumeResult = await consumeUserPoints({
|
||||
userId: params.userId,
|
||||
amount: unitCost,
|
||||
reason: "video_generation",
|
||||
modelId: billingModel.id,
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条${params.duration ? `(${params.duration} 秒)` : ""}${params.resolution ? `(${params.resolution})` : ""}`,
|
||||
});
|
||||
|
||||
if (!consumeResult.ok) {
|
||||
chatApiLogger.warn(`[Points] Failed to consume video points. user=${params.userId} model=${billingModel.id} cost=${unitCost}`);
|
||||
} else if (consumeResult.skipped) {
|
||||
chatApiLogger.info(`[Points] Skipped ${unitCost} video points for VIP user=${params.userId} model=${billingModel.id}`);
|
||||
} else {
|
||||
chatApiLogger.info(`[Points] Consumed ${unitCost} points for video. balance=${consumeResult.balance}`);
|
||||
}
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Points] Failed to consume points after video generation:", error);
|
||||
}
|
||||
}
|
||||
2826
app/(chat)/api/chat/_lib/detail-replacement.ts
Normal file
2826
app/(chat)/api/chat/_lib/detail-replacement.ts
Normal file
File diff suppressed because it is too large
Load Diff
192
app/(chat)/api/chat/_lib/errors.ts
Normal file
192
app/(chat)/api/chat/_lib/errors.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRequestErrorMessage } from "@/lib/request-error";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const chatApiLogger = createLogger("chat.errors");
|
||||
|
||||
|
||||
const DEFAULT_CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS = 55_000;
|
||||
|
||||
export class OperationTimeoutError extends Error {
|
||||
constructor(operation: string, timeoutMs: number) {
|
||||
super(`${operation} timed out after ${timeoutMs}ms`);
|
||||
this.name = "OperationTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getChatStreamErrorMessage(error: unknown): string {
|
||||
const rawMessage =
|
||||
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
|
||||
const normalized = rawMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized.includes("token quota is not enough") ||
|
||||
normalized.includes("pre_consume_token_quota_failed") ||
|
||||
normalized.includes("quota is not enough")
|
||||
) {
|
||||
return "当前对话模型额度不足,请切换其他模型,或联系管理员补充该模型额度。";
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("timeout") ||
|
||||
normalized.includes("timed out") ||
|
||||
normalized.includes("body timeout")
|
||||
) {
|
||||
return "当前对话模型响应超时,请重试,或切换到其他可用模型。";
|
||||
}
|
||||
|
||||
if (rawMessage) {
|
||||
return `当前对话暂时失败:${rawMessage}`;
|
||||
}
|
||||
|
||||
return "当前对话暂时失败,请重试或切换其他模型。";
|
||||
}
|
||||
|
||||
function resolveChatApiFailure(error: unknown) {
|
||||
const detailMessage = getRequestErrorMessage(error);
|
||||
const normalized = String(detailMessage || "").toLowerCase();
|
||||
|
||||
if (
|
||||
normalized.includes("payload too large") ||
|
||||
normalized.includes("request entity too large") ||
|
||||
normalized.includes("body exceeded") ||
|
||||
normalized.includes("content length") ||
|
||||
normalized.includes("413")
|
||||
) {
|
||||
return {
|
||||
code: "payload_too_large:chat",
|
||||
message: "本次请求内容过大,请减少图片或文字内容后重试。",
|
||||
status: 413,
|
||||
category: "payload_too_large",
|
||||
detailMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof OperationTimeoutError ||
|
||||
normalized.includes("timeout") ||
|
||||
normalized.includes("timed out") ||
|
||||
normalized.includes("body timeout") ||
|
||||
normalized.includes("aborterror") ||
|
||||
normalized.includes("aborted")
|
||||
) {
|
||||
return {
|
||||
code: "timeout:chat",
|
||||
message: "当前模型响应超时,请重试,或切换到其他可用模型。",
|
||||
status: 504,
|
||||
category: "timeout",
|
||||
detailMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("fetch failed") ||
|
||||
normalized.includes("econnreset") ||
|
||||
normalized.includes("econnrefused") ||
|
||||
normalized.includes("enotfound") ||
|
||||
normalized.includes("etimedout") ||
|
||||
normalized.includes("und_err") ||
|
||||
normalized.includes("socket hang up")
|
||||
) {
|
||||
return {
|
||||
code: "server_error:chat",
|
||||
message: "模型服务连接失败,请稍后重试,或切换其他可用模型。",
|
||||
status: 502,
|
||||
category: "upstream_connection",
|
||||
detailMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("database") ||
|
||||
normalized.includes("postgres") ||
|
||||
normalized.includes("drizzle")
|
||||
) {
|
||||
return {
|
||||
code: "server_error:chat",
|
||||
message: "服务数据库暂时繁忙,请稍后重试。",
|
||||
status: 503,
|
||||
category: "database",
|
||||
detailMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: "server_error:chat",
|
||||
message: "服务临时繁忙,请稍后重试;如果连续出现,请联系管理员。",
|
||||
status: 500,
|
||||
category: "unknown",
|
||||
detailMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function createChatApiFailureResponse(
|
||||
error: unknown,
|
||||
vercelId: string | null
|
||||
) {
|
||||
const failure = resolveChatApiFailure(error);
|
||||
const traceId = generateUUID();
|
||||
|
||||
chatApiLogger.error("[Chat API] Unhandled request failure:", {
|
||||
traceId,
|
||||
vercelId,
|
||||
category: failure.category,
|
||||
status: failure.status,
|
||||
detailMessage: failure.detailMessage,
|
||||
error,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: failure.code,
|
||||
message: failure.message,
|
||||
cause: `trace:${traceId}`,
|
||||
},
|
||||
{ status: failure.status }
|
||||
);
|
||||
}
|
||||
|
||||
export async function withOperationTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
operationName: string,
|
||||
timeoutMs: number
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new OperationTimeoutError(operationName, timeoutMs)),
|
||||
timeoutMs
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRemainingBudgetMs(startedAt: number, budgetMs: number) {
|
||||
return Math.max(0, budgetMs - (Date.now() - startedAt));
|
||||
}
|
||||
|
||||
export function getBudgetedTimeout(params: {
|
||||
startedAt: number;
|
||||
preferredMs: number;
|
||||
minimumMs?: number;
|
||||
budgetMs?: number;
|
||||
}) {
|
||||
const minimumMs = params.minimumMs ?? 6_000;
|
||||
const remainingMs = getRemainingBudgetMs(
|
||||
params.startedAt,
|
||||
params.budgetMs ?? DEFAULT_CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS
|
||||
);
|
||||
if (remainingMs < minimumMs) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(params.preferredMs, remainingMs);
|
||||
}
|
||||
507
app/(chat)/api/chat/_lib/image-failover.ts
Normal file
507
app/(chat)/api/chat/_lib/image-failover.ts
Normal file
@ -0,0 +1,507 @@
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { convertMessagesWithImages } from "./image-media";
|
||||
|
||||
const chatApiLogger = createLogger("chat.imageFailover");
|
||||
|
||||
|
||||
export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"alibaba-image",
|
||||
"bfl-image",
|
||||
"ruxa-image",
|
||||
"fal-image",
|
||||
"snapgen-image",
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
"grsai-gpt-image",
|
||||
"volcano-vision",
|
||||
"gemini",
|
||||
"huanke-gemini",
|
||||
]);
|
||||
|
||||
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
|
||||
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>();
|
||||
|
||||
export function resolveImageModelRuntimeId(
|
||||
model: { defaultParams?: unknown; modelId?: unknown; id?: unknown } | null | undefined,
|
||||
) {
|
||||
const defaultParams = model?.defaultParams;
|
||||
const defaultEndpoint =
|
||||
typeof defaultParams === "object" &&
|
||||
defaultParams !== null &&
|
||||
"endpoint" in defaultParams
|
||||
? String((defaultParams as Record<string, unknown>).endpoint || "").trim()
|
||||
: "";
|
||||
|
||||
if (defaultEndpoint) {
|
||||
return defaultEndpoint;
|
||||
}
|
||||
|
||||
const modelId = String(model?.modelId || "").trim();
|
||||
if (modelId) {
|
||||
return modelId;
|
||||
}
|
||||
|
||||
return String(model?.id || "").trim();
|
||||
}
|
||||
|
||||
export function normalizeFailoverErrorMessage(error: unknown): string {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message || String(error);
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
msg.includes("model not found") ||
|
||||
msg.includes("model_not_found") ||
|
||||
msg.includes("no available channel for model") ||
|
||||
msg.includes("unknown model") ||
|
||||
msg.includes("invalid model") ||
|
||||
msg.includes("api key is missing") ||
|
||||
msg.includes("invalid api key") ||
|
||||
msg.includes("unauthorized") ||
|
||||
msg.includes("cannot convert argument to a bytestring") ||
|
||||
msg.includes("model maintenance") ||
|
||||
msg.includes("maintenance") ||
|
||||
msg.includes("维护中") ||
|
||||
msg.includes("维护") ||
|
||||
msg.includes("service unavailable") ||
|
||||
msg.includes("temporarily unavailable") ||
|
||||
msg.includes("系统繁忙") ||
|
||||
msg.includes("无可用渠道") ||
|
||||
/(?:^|\D)(?:429|500|502|503|504)(?:\D|$)/.test(msg)
|
||||
);
|
||||
}
|
||||
|
||||
export function isImageFallbackModelCoolingDown(modelId: string): boolean {
|
||||
const snapshot = imageFailoverModelCooldown.get(modelId);
|
||||
if (!snapshot) return false;
|
||||
if (snapshot.until <= Date.now()) {
|
||||
imageFailoverModelCooldown.delete(modelId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setImageFallbackModelCooldown(modelId: string, reason: string): void {
|
||||
imageFailoverModelCooldown.set(modelId, {
|
||||
until: Date.now() + IMAGE_FAILOVER_MODEL_COOLDOWN_MS,
|
||||
reason: reason.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
export function isImageFailoverExcludedModel(model: { excludeFromFailover?: boolean } | null | undefined) {
|
||||
return Boolean(model?.excludeFromFailover);
|
||||
}
|
||||
|
||||
export type ImageFallbackCandidate = {
|
||||
model: any;
|
||||
provider: any;
|
||||
configured: boolean;
|
||||
sameProvider: boolean;
|
||||
nameSimilarity: number;
|
||||
sameName: boolean;
|
||||
};
|
||||
|
||||
const IMAGE_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
|
||||
|
||||
function getModelRefKeys(item: { model?: any } | null | undefined) {
|
||||
return [
|
||||
String(item?.model?.id || "").trim(),
|
||||
String(item?.model?.modelId || "").trim(),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeModelNameForSimilarity(value: unknown) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[「」『』【】[\]()(){}]/g, " ")
|
||||
.replace(/[_/|::,,.。+]+/g, "-")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function tokenizeModelNameForSimilarity(value: unknown) {
|
||||
const normalized = normalizeModelNameForSimilarity(value);
|
||||
const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
|
||||
const tokens = new Set(
|
||||
normalized
|
||||
.split(/[^a-z0-9\u4e00-\u9fa5]+/g)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length >= 2)
|
||||
);
|
||||
|
||||
if (compact.length >= 2) {
|
||||
tokens.add(compact);
|
||||
for (let index = 0; index < compact.length - 1; index += 1) {
|
||||
tokens.add(compact.slice(index, index + 2));
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function scoreModelNameSimilarity(
|
||||
source: { model?: any } | null | undefined,
|
||||
candidate: { model?: any } | null | undefined
|
||||
) {
|
||||
const sourceNames = [
|
||||
source?.model?.displayName,
|
||||
source?.model?.modelId,
|
||||
].filter(Boolean);
|
||||
const candidateNames = [
|
||||
candidate?.model?.displayName,
|
||||
candidate?.model?.modelId,
|
||||
].filter(Boolean);
|
||||
|
||||
let bestScore = 0;
|
||||
for (const sourceName of sourceNames) {
|
||||
const sourceTokens = tokenizeModelNameForSimilarity(sourceName);
|
||||
if (sourceTokens.size === 0) continue;
|
||||
for (const candidateName of candidateNames) {
|
||||
const candidateTokens = tokenizeModelNameForSimilarity(candidateName);
|
||||
if (candidateTokens.size === 0) continue;
|
||||
let overlap = 0;
|
||||
sourceTokens.forEach((token) => {
|
||||
if (candidateTokens.has(token)) overlap += 1;
|
||||
});
|
||||
const score = overlap / Math.max(sourceTokens.size, candidateTokens.size);
|
||||
bestScore = Math.max(bestScore, score);
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
}
|
||||
|
||||
function resolveCurrentImageModelEntry(
|
||||
allModels: Array<{ model: any; provider: any }>,
|
||||
refs: string[]
|
||||
) {
|
||||
const normalizedRefs = new Set(refs.map((item) => String(item || "").trim()).filter(Boolean));
|
||||
if (normalizedRefs.size === 0) return null;
|
||||
return (
|
||||
allModels.find((item) =>
|
||||
getModelRefKeys(item).some((key) => normalizedRefs.has(key))
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function messagePartHasImageInput(part: any) {
|
||||
if (!part || typeof part !== "object") return false;
|
||||
if (part.type === "file" || part.type === "image" || part.type === "image_url") {
|
||||
return true;
|
||||
}
|
||||
if (typeof part.url === "string" && /^https?:\/\//i.test(part.url)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof part.image === "string" && part.image.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (typeof part.image_url?.url === "string" && part.image_url.url.trim()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function countImageInputPartsInMessage(message: any) {
|
||||
const countParts = (parts: any[] | undefined) =>
|
||||
Array.isArray(parts) ? parts.filter(messagePartHasImageInput).length : 0;
|
||||
|
||||
const contentCount = countParts(Array.isArray(message?.content) ? message.content : undefined);
|
||||
if (contentCount > 0) return contentCount;
|
||||
|
||||
return countParts(Array.isArray(message?.parts) ? message.parts : undefined);
|
||||
}
|
||||
|
||||
export function countChatMessageImageInputs(messages: ChatMessage[]) {
|
||||
return messages.reduce(
|
||||
(count, message) => count + countImageInputPartsInMessage(message),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function chatMessagesHaveImageInput(messages: ChatMessage[]) {
|
||||
return countChatMessageImageInputs(messages) > 0;
|
||||
}
|
||||
|
||||
export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
switch (providerType) {
|
||||
case "bfl-image":
|
||||
case "ruxa-image":
|
||||
case "fal-image":
|
||||
case "snapgen-image":
|
||||
case "openai-image":
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "vip98k":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
case "krea":
|
||||
case "gemini":
|
||||
case "huanke-gemini":
|
||||
return providerType;
|
||||
case "grsai-gpt-image":
|
||||
return "gemini";
|
||||
case "volcano-vision":
|
||||
return "volcano";
|
||||
default:
|
||||
return "alibaba-image";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildImageFallbackCandidates(
|
||||
allModels: Array<{ model: any; provider: any }>,
|
||||
options: {
|
||||
currentProviderId?: string;
|
||||
currentModelId?: string;
|
||||
currentModelKey?: string;
|
||||
configuredFallbackIds?: string[];
|
||||
}
|
||||
) {
|
||||
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
|
||||
const currentEntry = resolveCurrentImageModelEntry(allModels, [
|
||||
currentModelId || "",
|
||||
currentModelKey || "",
|
||||
]);
|
||||
const configuredRefs = configuredFallbackIds
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean);
|
||||
const byModelRef = new Map<string, { model: any; provider: any }>();
|
||||
|
||||
const isEligible = (item: { model: any; provider: any }, sameProvider: boolean) => {
|
||||
if (
|
||||
!item?.model?.supportsImageGen ||
|
||||
!item?.model?.isEnabled ||
|
||||
item?.model?.isSubModel ||
|
||||
isImageFailoverExcludedModel(item?.model) ||
|
||||
item?.model?.modelId === currentModelKey ||
|
||||
item?.model?.id === currentModelId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providerType = String(item?.provider?.type || "");
|
||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||
return false;
|
||||
}
|
||||
if (isImageFallbackModelCoolingDown(item.model.id)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
allModels.forEach((item) => {
|
||||
if (!item?.model) return;
|
||||
byModelRef.set(String(item.model.id), item);
|
||||
if (item.model.modelId) {
|
||||
byModelRef.set(String(item.model.modelId), item);
|
||||
}
|
||||
});
|
||||
|
||||
const candidates: ImageFallbackCandidate[] = [];
|
||||
const seenModelIds = new Set<string>();
|
||||
const candidateOrder = new Map<string, number>();
|
||||
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
|
||||
if (!item?.model) return;
|
||||
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
|
||||
if (!isEligible(item, sameProvider)) return;
|
||||
const modelId = String(item.model.id || "");
|
||||
if (!modelId || seenModelIds.has(modelId)) return;
|
||||
seenModelIds.add(modelId);
|
||||
candidateOrder.set(modelId, candidateOrder.size);
|
||||
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
|
||||
candidates.push({
|
||||
model: item.model,
|
||||
provider: item.provider,
|
||||
configured,
|
||||
sameProvider,
|
||||
nameSimilarity,
|
||||
sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
|
||||
});
|
||||
};
|
||||
|
||||
configuredRefs.forEach((modelRef) => {
|
||||
pushCandidate(byModelRef.get(modelRef), true);
|
||||
});
|
||||
|
||||
if (currentProviderId) {
|
||||
allModels
|
||||
.filter((item) => item.provider?.id === currentProviderId)
|
||||
.forEach((item) => pushCandidate(item, false));
|
||||
}
|
||||
|
||||
if (currentEntry) {
|
||||
allModels
|
||||
.filter((item) => scoreModelNameSimilarity(currentEntry, item) >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD)
|
||||
.forEach((item) => pushCandidate(item, false));
|
||||
}
|
||||
|
||||
return candidates.sort((left, right) => {
|
||||
if (left.sameName !== right.sameName) return left.sameName ? -1 : 1;
|
||||
if (left.configured !== right.configured) return left.configured ? -1 : 1;
|
||||
if (left.sameProvider !== right.sameProvider) return left.sameProvider ? -1 : 1;
|
||||
if (right.nameSimilarity !== left.nameSimilarity) {
|
||||
return right.nameSimilarity - left.nameSimilarity;
|
||||
}
|
||||
return (
|
||||
(candidateOrder.get(String(left.model.id || "")) ?? 0) -
|
||||
(candidateOrder.get(String(right.model.id || "")) ?? 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateImageWithFailoverCandidate(params: {
|
||||
candidate: ImageFallbackCandidate;
|
||||
messages: ChatMessage[];
|
||||
imageParams: any;
|
||||
fallbackImageParams: any;
|
||||
}) {
|
||||
const { candidate, messages, imageParams, fallbackImageParams } = params;
|
||||
const providerType = String(candidate.provider?.type || "");
|
||||
const candidateRuntimeModelId = resolveImageModelRuntimeId(candidate.model);
|
||||
if (
|
||||
chatMessagesHaveImageInput(messages) &&
|
||||
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
|
||||
) {
|
||||
throw new Error(
|
||||
`备用模型不支持参考图/图生图输入:${candidate.model.displayName || candidate.model.id}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
providerType === "fal-image" &&
|
||||
isFalGptImage2EditModelId(candidateRuntimeModelId)
|
||||
) {
|
||||
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
|
||||
}
|
||||
const candidateLanguageModel = await getLanguageModelById(
|
||||
candidate.model.id,
|
||||
candidateRuntimeModelId
|
||||
);
|
||||
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
|
||||
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
|
||||
}
|
||||
|
||||
const candidateMessages = await convertMessagesWithImages(
|
||||
messages,
|
||||
getImageFailoverConvertProvider(providerType)
|
||||
);
|
||||
const effectiveImageParams = imageParams || fallbackImageParams;
|
||||
const imageInputCount = countChatMessageImageInputs(messages);
|
||||
chatApiLogger.info("[Chat API] Image failover candidate params:", {
|
||||
model: candidate.model?.modelId || candidate.model?.id,
|
||||
providerType,
|
||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
||||
imageSize: effectiveImageParams?.imageSize,
|
||||
imageCount: effectiveImageParams?.imageCount,
|
||||
hasImageInput: imageInputCount > 0,
|
||||
imageInputCount,
|
||||
multiImageInput: imageInputCount > 1,
|
||||
});
|
||||
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||
messages: candidateMessages,
|
||||
imageParams: effectiveImageParams,
|
||||
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||
modelDisplayName: candidate.model.displayName,
|
||||
overrideModelId: candidateRuntimeModelId || candidate.model.modelId,
|
||||
});
|
||||
|
||||
if (candidateResult?.finishReason === "error") {
|
||||
throw new Error(candidateResult.text || "图片生成失败");
|
||||
}
|
||||
|
||||
return {
|
||||
text: candidateResult.text || "",
|
||||
usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
|
||||
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function getImageCrossProviderCandidates(
|
||||
allModels: any[],
|
||||
options: {
|
||||
currentModelId: string;
|
||||
currentModelKey?: string;
|
||||
currentProviderId?: string;
|
||||
providerPriority: Record<string, number>;
|
||||
}
|
||||
) {
|
||||
const currentEntry = resolveCurrentImageModelEntry(allModels, [
|
||||
options.currentModelId,
|
||||
options.currentModelKey || "",
|
||||
]);
|
||||
return allModels
|
||||
.filter((m) => {
|
||||
if (
|
||||
!m?.model?.supportsImageGen ||
|
||||
!m?.model?.isEnabled ||
|
||||
m?.model?.isSubModel ||
|
||||
isImageFailoverExcludedModel(m?.model)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(String(m?.provider?.type || ""))) {
|
||||
return false;
|
||||
}
|
||||
if (m.model.id === options.currentModelId) return false;
|
||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||
if (isImageFallbackModelCoolingDown(m.model.id)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((item, index) => {
|
||||
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
|
||||
return {
|
||||
...item,
|
||||
__imageFailoverIndex: index,
|
||||
__imageFailoverNameSimilarity: nameSimilarity,
|
||||
__imageFailoverSameName:
|
||||
nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.__imageFailoverSameName !== b.__imageFailoverSameName) {
|
||||
return a.__imageFailoverSameName ? -1 : 1;
|
||||
}
|
||||
if (b.__imageFailoverNameSimilarity !== a.__imageFailoverNameSimilarity) {
|
||||
return b.__imageFailoverNameSimilarity - a.__imageFailoverNameSimilarity;
|
||||
}
|
||||
const left = options.providerPriority[a.provider.type as string] ?? 99;
|
||||
const right = options.providerPriority[b.provider.type as string] ?? 99;
|
||||
if (left !== right) return left - right;
|
||||
const sortOrderDiff = (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
|
||||
if (sortOrderDiff !== 0) return sortOrderDiff;
|
||||
return (a.__imageFailoverIndex ?? 0) - (b.__imageFailoverIndex ?? 0);
|
||||
})
|
||||
.map((item) => {
|
||||
const {
|
||||
__imageFailoverIndex,
|
||||
__imageFailoverNameSimilarity,
|
||||
__imageFailoverSameName,
|
||||
...candidate
|
||||
} = item;
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
1002
app/(chat)/api/chat/_lib/image-media.ts
Normal file
1002
app/(chat)/api/chat/_lib/image-media.ts
Normal file
File diff suppressed because it is too large
Load Diff
173
app/(chat)/api/chat/_lib/message-preparation.ts
Normal file
173
app/(chat)/api/chat/_lib/message-preparation.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
analyzeOptimizedPromptMerge,
|
||||
buildOptimizedPromptPreview,
|
||||
} from "@/lib/ai/runtime/optimized-prompt";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
const chatApiLogger = createLogger("chat.messagePreparation");
|
||||
|
||||
|
||||
/**
|
||||
* 将图片 URL 转换为 base64 格式
|
||||
* 用于将 S3 URL 的图片转换为 base64 发送给 Gemini API
|
||||
*/
|
||||
export async function convertImageUrlToBase64(url: string): Promise<string> {
|
||||
try {
|
||||
chatApiLogger.info("[Image Convert] Fetching image from:", url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const base64 = buffer.toString("base64");
|
||||
|
||||
// 获取 MIME 类型
|
||||
const contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
const dataUrl = `data:${contentType};base64,${base64}`;
|
||||
|
||||
chatApiLogger.info("[Image Convert] Converted to base64, size:", base64.length);
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Image Convert] Failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测消息中是否包含用户标注过的图片(文件名以 annotated- 开头)
|
||||
*/
|
||||
export function hasAnnotatedImage(messages: ChatMessage[]): boolean {
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "file" && "name" in part && typeof part.name === "string") {
|
||||
if (part.name.startsWith("annotated-")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标注图片的系统提示词(用户不可见,后台自动添加)
|
||||
*/
|
||||
export const ANNOTATION_SYSTEM_PROMPT =
|
||||
"【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。";
|
||||
|
||||
export function applyHiddenPromptToMessages(
|
||||
messages: ChatMessage[],
|
||||
hiddenPrompt?: string,
|
||||
): ChatMessage[] {
|
||||
if (!hiddenPrompt || !hiddenPrompt.trim()) return messages;
|
||||
const cloned = messages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => ({ ...part })),
|
||||
}));
|
||||
for (let i = cloned.length - 1; i >= 0; i--) {
|
||||
const msg = cloned[i];
|
||||
if (msg.role !== "user") continue;
|
||||
const textPart = msg.parts.find((part) => part.type === "text") as
|
||||
| { type: "text"; text: string }
|
||||
| undefined;
|
||||
if (textPart) {
|
||||
textPart.text = `${hiddenPrompt}\n\n${textPart.text}`.trim();
|
||||
} else {
|
||||
msg.parts.unshift({ type: "text", text: hiddenPrompt });
|
||||
}
|
||||
break;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function applyOptimizedPromptToLatestUserMessage(
|
||||
messages: ChatMessage[],
|
||||
optimizedPrompt?: string,
|
||||
hiddenPromptPrefix = "",
|
||||
): ChatMessage[] {
|
||||
const prompt = optimizedPrompt?.trim();
|
||||
if (!prompt) return messages;
|
||||
|
||||
const cloned = messages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => ({ ...part })),
|
||||
}));
|
||||
|
||||
for (let i = cloned.length - 1; i >= 0; i -= 1) {
|
||||
const msg = cloned[i];
|
||||
if (msg.role !== "user") continue;
|
||||
|
||||
const textPart = msg.parts.find(
|
||||
(part): part is { type: "text"; text: string } => part.type === "text",
|
||||
);
|
||||
const originalText = textPart?.text?.trim() || "";
|
||||
const { normalizedOriginal, hasOriginalDemand, hasOriginalInOptimized } =
|
||||
analyzeOptimizedPromptMerge(originalText, prompt, hiddenPromptPrefix);
|
||||
const finalPrompt =
|
||||
hasOriginalDemand && !hasOriginalInOptimized
|
||||
? [
|
||||
hiddenPromptPrefix.trim(),
|
||||
`【核心目标(必须优先满足)】${normalizedOriginal}`,
|
||||
`【执行优化细节】${prompt}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
: `${hiddenPromptPrefix}${prompt}`.trim();
|
||||
chatApiLogger.info("[Prompt Merge] Final image prompt prepared:", {
|
||||
original: normalizedOriginal.substring(0, 120),
|
||||
optimized: prompt.substring(0, 120),
|
||||
merged: finalPrompt.substring(0, 180),
|
||||
forcePreserveOriginal: hasOriginalDemand && !hasOriginalInOptimized,
|
||||
});
|
||||
|
||||
if (textPart) {
|
||||
textPart.text = finalPrompt;
|
||||
} else {
|
||||
msg.parts.unshift({ type: "text", text: finalPrompt });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function injectOptimizedPromptSection(params: {
|
||||
message?: string;
|
||||
originalText: string;
|
||||
optimizedPrompt?: string;
|
||||
hiddenPromptPrefix?: string;
|
||||
}): string | undefined {
|
||||
const { message, originalText, optimizedPrompt, hiddenPromptPrefix } = params;
|
||||
if (!message?.trim()) return message;
|
||||
|
||||
const preview = buildOptimizedPromptPreview({
|
||||
originalText,
|
||||
optimizedPrompt,
|
||||
hiddenPromptPrefix,
|
||||
});
|
||||
if (!preview) return message;
|
||||
|
||||
const optimizedPromptSection = `【优化后提示词】\n${preview}`;
|
||||
const optimizedSectionRegex =
|
||||
/(?:###\s*优化后提示词|【\s*优化后提示词\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
|
||||
if (optimizedSectionRegex.test(message)) {
|
||||
return message.replace(optimizedSectionRegex, optimizedPromptSection);
|
||||
}
|
||||
|
||||
const executionSectionRegex =
|
||||
/(?:###\s*执行计划|【\s*执行计划\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
|
||||
if (executionSectionRegex.test(message)) {
|
||||
return message.replace(executionSectionRegex, optimizedPromptSection);
|
||||
}
|
||||
|
||||
const outputSectionRegex = /(?:###\s*输出设置|【\s*输出设置\s*】)/;
|
||||
if (outputSectionRegex.test(message)) {
|
||||
return message.replace(outputSectionRegex, `${optimizedPromptSection}\n$&`);
|
||||
}
|
||||
|
||||
return `${message.trim()}\n\n${optimizedPromptSection}`;
|
||||
}
|
||||
43
app/(chat)/api/chat/_lib/model-resolver.ts
Normal file
43
app/(chat)/api/chat/_lib/model-resolver.ts
Normal file
@ -0,0 +1,43 @@
|
||||
export function resolveModelDisplayNameByModelId(
|
||||
models: Array<{ model: any; provider?: any }> | undefined,
|
||||
modelId: string | undefined,
|
||||
): string {
|
||||
if (!modelId || !models?.length) return "";
|
||||
const match = models.find(
|
||||
(item) => item.model.modelId === modelId || item.model.id === modelId,
|
||||
);
|
||||
return String(match?.model?.displayName || "");
|
||||
}
|
||||
|
||||
export function resolveModelEntryByRuntimeModelId(
|
||||
models: Array<{ model: any; provider?: any }> | undefined,
|
||||
modelId: string | undefined,
|
||||
fallbackRef?: string,
|
||||
) {
|
||||
const candidates = [modelId, fallbackRef]
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean);
|
||||
if (!candidates.length || !models?.length) return null;
|
||||
|
||||
return (
|
||||
models.find((item) =>
|
||||
candidates.some(
|
||||
(candidate) => item.model.modelId === candidate || item.model.id === candidate,
|
||||
),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveModelRefByRuntimeModelId(
|
||||
models: Array<{ model: any; provider?: any }> | undefined,
|
||||
modelId: string | undefined,
|
||||
fallbackRef?: string,
|
||||
): string {
|
||||
const normalizedModelId = String(modelId || "").trim();
|
||||
if (!normalizedModelId) return fallbackRef || "";
|
||||
|
||||
const match = models?.find(
|
||||
(item) => item.model.modelId === normalizedModelId || item.model.id === normalizedModelId,
|
||||
);
|
||||
return String(match?.model?.id || fallbackRef || normalizedModelId || "");
|
||||
}
|
||||
24
app/(chat)/api/chat/_lib/persistence.ts
Normal file
24
app/(chat)/api/chat/_lib/persistence.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export function normalizeAssistantPartsForPersistence(
|
||||
parts: Array<Record<string, any>> | undefined
|
||||
) {
|
||||
const safeParts = Array.isArray(parts) ? parts.filter(Boolean) : [];
|
||||
return safeParts.filter((part) => {
|
||||
const partType = String(part?.type || "").trim();
|
||||
if (!partType) return false;
|
||||
if (
|
||||
partType === "data-keepalive" ||
|
||||
partType === "step-start" ||
|
||||
partType === "step-finish"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
612
app/(chat)/api/chat/_lib/stream.ts
Normal file
612
app/(chat)/api/chat/_lib/stream.ts
Normal file
@ -0,0 +1,612 @@
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { JsonToSseTransformStream } from "ai";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import type {
|
||||
ResolvedChatMediaInput,
|
||||
ResolvedImageRef,
|
||||
ResolvedTargetAssets,
|
||||
} from "@/lib/ai/context-memory/types";
|
||||
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
|
||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const chatApiLogger = createLogger("chat.stream");
|
||||
|
||||
|
||||
export type StreamWriter = {
|
||||
write: (chunk: any) => void;
|
||||
};
|
||||
|
||||
export function safeWriteStreamChunk(
|
||||
writer: StreamWriter,
|
||||
chunk: any,
|
||||
label: string
|
||||
): boolean {
|
||||
try {
|
||||
writer.write(chunk);
|
||||
return true;
|
||||
} catch (error) {
|
||||
chatApiLogger.warn(`[Chat API] Stream write skipped (${label})`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAssistantTextChunk(
|
||||
writer: StreamWriter,
|
||||
text: string,
|
||||
label: string,
|
||||
messageId = generateUUID()
|
||||
) {
|
||||
safeWriteStreamChunk(
|
||||
writer,
|
||||
{
|
||||
type: "data-appendMessage",
|
||||
data: JSON.stringify({
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
}),
|
||||
},
|
||||
label
|
||||
);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
export function writeStructuredDataChunk(
|
||||
writer: StreamWriter,
|
||||
type: string,
|
||||
data: unknown,
|
||||
label: string
|
||||
) {
|
||||
safeWriteStreamChunk(
|
||||
writer,
|
||||
{
|
||||
type,
|
||||
data: JSON.stringify(data),
|
||||
},
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStreamIdList(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.map((id) => String(id || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeStreamMediaUrls(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.map((url) => String(url || "").trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
function isValidStreamMediaUrl(url: string) {
|
||||
return /^https?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
export function normalizeCanvasMediaStreamPayload(params: {
|
||||
imageUrls?: unknown;
|
||||
videoUrls?: unknown;
|
||||
mediaItemIds?: unknown;
|
||||
handledCanvasMediaItemIds?: unknown;
|
||||
}) {
|
||||
const rawImageUrls = normalizeStreamMediaUrls(params.imageUrls);
|
||||
const rawVideoUrls = normalizeStreamMediaUrls(params.videoUrls);
|
||||
const rawEntries = [
|
||||
...rawImageUrls.map((url, index) => ({
|
||||
type: "image" as const,
|
||||
url,
|
||||
originalIndex: index,
|
||||
})),
|
||||
...rawVideoUrls.map((url, index) => ({
|
||||
type: "video" as const,
|
||||
url,
|
||||
originalIndex: rawImageUrls.length + index,
|
||||
})),
|
||||
];
|
||||
const rawMediaItemIds = normalizeStreamIdList(params.mediaItemIds);
|
||||
const validEntries = rawEntries.filter((entry) =>
|
||||
isValidStreamMediaUrl(entry.url)
|
||||
);
|
||||
|
||||
const idByValidIndex = new Map<number, string>();
|
||||
if (rawMediaItemIds.length === rawEntries.length) {
|
||||
validEntries.forEach((entry, validIndex) => {
|
||||
const id = rawMediaItemIds[entry.originalIndex];
|
||||
if (id) idByValidIndex.set(validIndex, id);
|
||||
});
|
||||
} else if (rawMediaItemIds.length === validEntries.length) {
|
||||
rawMediaItemIds.forEach((id, index) => {
|
||||
if (id) idByValidIndex.set(index, id);
|
||||
});
|
||||
}
|
||||
|
||||
const dedupedEntries: Array<{
|
||||
type: "image" | "video";
|
||||
url: string;
|
||||
mediaItemId?: string;
|
||||
}> = [];
|
||||
const seenMediaKeys = new Set<string>();
|
||||
validEntries.forEach((entry, validIndex) => {
|
||||
const key = `${entry.type}:${entry.url}`;
|
||||
if (seenMediaKeys.has(key)) return;
|
||||
seenMediaKeys.add(key);
|
||||
dedupedEntries.push({
|
||||
type: entry.type,
|
||||
url: entry.url,
|
||||
mediaItemId: idByValidIndex.get(validIndex),
|
||||
});
|
||||
});
|
||||
|
||||
if (
|
||||
dedupedEntries.length > 0 &&
|
||||
dedupedEntries.some((entry) => !entry.mediaItemId) &&
|
||||
rawMediaItemIds.length === dedupedEntries.length
|
||||
) {
|
||||
dedupedEntries.forEach((entry, index) => {
|
||||
entry.mediaItemId = rawMediaItemIds[index];
|
||||
});
|
||||
}
|
||||
|
||||
const mediaItemIds = dedupedEntries.every((entry) => entry.mediaItemId)
|
||||
? dedupedEntries.map((entry) => entry.mediaItemId!)
|
||||
: [];
|
||||
|
||||
return {
|
||||
imageUrls: dedupedEntries
|
||||
.filter((entry) => entry.type === "image")
|
||||
.map((entry) => entry.url),
|
||||
videoUrls: dedupedEntries
|
||||
.filter((entry) => entry.type === "video")
|
||||
.map((entry) => entry.url),
|
||||
mediaItemIds,
|
||||
handledCanvasMediaItemIds: normalizeStreamIdList(
|
||||
params.handledCanvasMediaItemIds
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeProtocolDebugChunk(
|
||||
writer: StreamWriter,
|
||||
enabled: boolean,
|
||||
data: unknown,
|
||||
label: string
|
||||
) {
|
||||
if (!enabled) return;
|
||||
writeStructuredDataChunk(writer, "data-protocolDebug", data, label);
|
||||
}
|
||||
|
||||
function normalizeImageLoadingProviderType(providerType?: string | null): string {
|
||||
const normalized = String(providerType || "").trim().toLowerCase();
|
||||
if (normalized === "gemini-image" || normalized === "huanke-gemini") {
|
||||
return "gemini";
|
||||
}
|
||||
if (
|
||||
normalized === "alibaba-image" ||
|
||||
normalized === "bfl-image" ||
|
||||
normalized === "ruxa-image" ||
|
||||
normalized === "fal-image" ||
|
||||
normalized === "snapgen-image" ||
|
||||
normalized === "openai-image" ||
|
||||
normalized === "bananaimage" ||
|
||||
normalized === "visionary" ||
|
||||
normalized === "atlascloud" ||
|
||||
normalized === "vip98k" ||
|
||||
normalized === "stepfun-image" ||
|
||||
normalized === "midjourney" ||
|
||||
normalized === "runway" ||
|
||||
normalized === "krea" ||
|
||||
normalized === "volcano-vision" ||
|
||||
normalized === "gemini"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return "alibaba-image";
|
||||
}
|
||||
|
||||
export function writeImageGenerationStatusChunk(
|
||||
writer: StreamWriter,
|
||||
params: {
|
||||
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
providerType?: string | null;
|
||||
modelName?: string | null;
|
||||
imageCount?: number | null;
|
||||
queuePosition?: number | null;
|
||||
sameModelRunningCount?: number | null;
|
||||
concurrencyLimit?: number | null;
|
||||
estimatedWaitMs?: number | null;
|
||||
},
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-imageGenerationStatus",
|
||||
{
|
||||
phase: params.phase,
|
||||
providerType: normalizeImageLoadingProviderType(params.providerType),
|
||||
modelName: String(params.modelName || "").trim() || undefined,
|
||||
imageCount:
|
||||
Number.isFinite(Number(params.imageCount)) && Number(params.imageCount) > 0
|
||||
? Number(params.imageCount)
|
||||
: undefined,
|
||||
queuePosition:
|
||||
Number.isFinite(Number(params.queuePosition)) &&
|
||||
Number(params.queuePosition) >= 0
|
||||
? Number(params.queuePosition)
|
||||
: undefined,
|
||||
sameModelRunningCount:
|
||||
Number.isFinite(Number(params.sameModelRunningCount)) &&
|
||||
Number(params.sameModelRunningCount) >= 0
|
||||
? Number(params.sameModelRunningCount)
|
||||
: undefined,
|
||||
concurrencyLimit:
|
||||
Number.isFinite(Number(params.concurrencyLimit)) &&
|
||||
Number(params.concurrencyLimit) > 0
|
||||
? Number(params.concurrencyLimit)
|
||||
: undefined,
|
||||
estimatedWaitMs:
|
||||
Number.isFinite(Number(params.estimatedWaitMs)) &&
|
||||
Number(params.estimatedWaitMs) >= 0
|
||||
? Number(params.estimatedWaitMs)
|
||||
: undefined,
|
||||
},
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentProgressStreamPayload = {
|
||||
stage: "planning" | "clarify" | "executing" | "completed" | "failed";
|
||||
currentStep:
|
||||
| "lock_target"
|
||||
| "analyze"
|
||||
| "select_tool"
|
||||
| "execute"
|
||||
| "finalize";
|
||||
title?: string;
|
||||
message?: string;
|
||||
toolName?: string;
|
||||
capability?: string;
|
||||
};
|
||||
|
||||
export function writeAgentProgressChunk(
|
||||
writer: StreamWriter,
|
||||
payload: AgentProgressStreamPayload,
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-agentProgress",
|
||||
{
|
||||
stage: payload.stage,
|
||||
currentStep: payload.currentStep,
|
||||
title: String(payload.title || "").trim() || undefined,
|
||||
message: String(payload.message || "").trim() || undefined,
|
||||
toolName: String(payload.toolName || "").trim() || undefined,
|
||||
capability: String(payload.capability || "").trim() || undefined,
|
||||
},
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCanvasMediaChunk(
|
||||
writer: StreamWriter,
|
||||
params: {
|
||||
messageId: string;
|
||||
text?: string;
|
||||
imageUrls?: string[];
|
||||
videoUrls?: string[];
|
||||
prompt?: string | null;
|
||||
sourceImageUrls?: string[] | null;
|
||||
agentPresetId?: string | null;
|
||||
agentPresetName?: string | null;
|
||||
agentCategoryId?: string | null;
|
||||
agentCategoryName?: string | null;
|
||||
agentExecutionSnapshotId?: string | null;
|
||||
mediaItemIds?: string[] | null;
|
||||
handledCanvasMediaItemIds?: string[] | null;
|
||||
},
|
||||
label: string
|
||||
) {
|
||||
const inputImageUrls = Array.isArray(params.imageUrls)
|
||||
? params.imageUrls
|
||||
: extractImageUrlsFromText(String(params.text || ""));
|
||||
const normalizedPayload = normalizeCanvasMediaStreamPayload({
|
||||
imageUrls: inputImageUrls,
|
||||
videoUrls: params.videoUrls,
|
||||
mediaItemIds: params.mediaItemIds,
|
||||
handledCanvasMediaItemIds: params.handledCanvasMediaItemIds,
|
||||
});
|
||||
|
||||
if (
|
||||
normalizedPayload.imageUrls.length === 0 &&
|
||||
normalizedPayload.videoUrls.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-canvasMedia",
|
||||
{
|
||||
messageId: params.messageId,
|
||||
imageUrls: normalizedPayload.imageUrls,
|
||||
videoUrls: normalizedPayload.videoUrls,
|
||||
prompt: String(params.prompt || "").trim() || undefined,
|
||||
sourceImageUrls: Array.isArray(params.sourceImageUrls)
|
||||
? params.sourceImageUrls
|
||||
.map((url) => String(url || "").trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
: [],
|
||||
agentPresetId: String(params.agentPresetId || "").trim() || undefined,
|
||||
agentPresetName: String(params.agentPresetName || "").trim() || undefined,
|
||||
agentCategoryId: String(params.agentCategoryId || "").trim() || undefined,
|
||||
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
|
||||
agentExecutionSnapshotId:
|
||||
String(params.agentExecutionSnapshotId || "").trim() || undefined,
|
||||
mediaItemIds: normalizedPayload.mediaItemIds,
|
||||
handledCanvasMediaItemIds: normalizedPayload.handledCanvasMediaItemIds,
|
||||
},
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCanvasOperationChunk(
|
||||
writer: StreamWriter,
|
||||
params: {
|
||||
projectId: string;
|
||||
operationId: string;
|
||||
operation: string;
|
||||
status?: "applied" | "failed" | "shadowed";
|
||||
createdNodeIds: string[];
|
||||
createdNodeIdsByMediaItemId?: Record<string, string>;
|
||||
updatedNodeIds?: string[];
|
||||
createdEdgeIds?: string[];
|
||||
canvasRevision?: string;
|
||||
anchorNodeId?: string | null;
|
||||
handledCanvasMediaItemIds?: string[];
|
||||
mediaItemIds?: string[];
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
fallbackToCanvasMedia?: boolean;
|
||||
message?: string;
|
||||
},
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-canvasOperation",
|
||||
{
|
||||
projectId: params.projectId,
|
||||
operationId: params.operationId,
|
||||
operation: params.operation,
|
||||
status: params.status || "applied",
|
||||
createdNodeIds: params.createdNodeIds || [],
|
||||
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
|
||||
updatedNodeIds: params.updatedNodeIds || [],
|
||||
createdEdgeIds: params.createdEdgeIds || [],
|
||||
canvasRevision: params.canvasRevision,
|
||||
anchorNodeId: params.anchorNodeId || undefined,
|
||||
handledCanvasMediaItemIds:
|
||||
params.status && params.status !== "applied"
|
||||
? []
|
||||
: normalizeStreamIdList(params.handledCanvasMediaItemIds),
|
||||
mediaItemIds: normalizeStreamIdList(params.mediaItemIds),
|
||||
errorCode: params.errorCode,
|
||||
errorMessage: params.errorMessage,
|
||||
fallbackToCanvasMedia: params.fallbackToCanvasMedia,
|
||||
message: params.message,
|
||||
},
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCanvasViewOperationChunk(
|
||||
writer: StreamWriter,
|
||||
params: CanvasViewOperationPlan,
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-canvasViewOperation",
|
||||
{
|
||||
version: 1,
|
||||
viewOperationId: params.viewOperationId,
|
||||
operation: params.operation,
|
||||
projectId: params.projectId,
|
||||
nodeIds: params.nodeIds || [],
|
||||
anchorNodeId: params.anchorNodeId,
|
||||
zoom: params.zoom,
|
||||
padding: params.padding,
|
||||
durationMs: params.durationMs,
|
||||
persist: false,
|
||||
center: params.center,
|
||||
reason: params.reason,
|
||||
},
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCanvasCapabilityCommandChunk(
|
||||
writer: StreamWriter,
|
||||
command: CanvasCapabilityClientCommand,
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-canvasCapabilityCommand",
|
||||
command,
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
type AgentTargetLockRefPreview = {
|
||||
url: string;
|
||||
title?: string;
|
||||
nodeId?: string;
|
||||
assetId?: string;
|
||||
source: ResolvedImageRef["source"];
|
||||
role: ResolvedImageRef["role"];
|
||||
isSelected?: boolean;
|
||||
positionLabel?: string;
|
||||
};
|
||||
|
||||
export type AgentTargetLockStreamPayload = {
|
||||
stage: "locked" | "clarify" | "unresolved";
|
||||
resolutionMode: ResolvedTargetAssets["resolutionMode"];
|
||||
confidence: number;
|
||||
reason: string;
|
||||
needsClarification: boolean;
|
||||
clarificationMessage?: string;
|
||||
targetCount: number;
|
||||
candidateCount: number;
|
||||
targets: AgentTargetLockRefPreview[];
|
||||
candidates: AgentTargetLockRefPreview[];
|
||||
};
|
||||
|
||||
function sortTargetLockRefsForCanvasOrdering(refs: ResolvedImageRef[]) {
|
||||
return [...refs].sort((left, right) => {
|
||||
const leftY = Number(left.y ?? 0);
|
||||
const rightY = Number(right.y ?? 0);
|
||||
if (Math.abs(leftY - rightY) > 12) {
|
||||
return leftY - rightY;
|
||||
}
|
||||
return Number(left.x ?? 0) - Number(right.x ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function getTargetLockPositionLabel(
|
||||
ref: ResolvedImageRef,
|
||||
refs: ResolvedImageRef[]
|
||||
): string {
|
||||
if (refs.length <= 1) return "中间";
|
||||
const xs = refs.map((item) => Number(item.x ?? 0));
|
||||
const ys = refs.map((item) => Number(item.y ?? 0));
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
const spanX = Math.max(1, maxX - minX);
|
||||
const spanY = Math.max(1, maxY - minY);
|
||||
const relX = (Number(ref.x ?? 0) - minX) / spanX;
|
||||
const relY = (Number(ref.y ?? 0) - minY) / spanY;
|
||||
|
||||
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
|
||||
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
|
||||
|
||||
if (horizontal === "中" && vertical === "中") return "中间";
|
||||
return `${vertical}${horizontal}`;
|
||||
}
|
||||
|
||||
function serializeAgentTargetLockRefs(refs: ResolvedImageRef[]) {
|
||||
const orderedRefs = sortTargetLockRefsForCanvasOrdering(refs).slice(0, 4);
|
||||
return orderedRefs.map((ref) => ({
|
||||
url: ref.url,
|
||||
title: String(ref.title || "").trim() || undefined,
|
||||
nodeId: ref.nodeId,
|
||||
assetId: ref.assetId,
|
||||
source: ref.source,
|
||||
role: ref.role,
|
||||
isSelected: ref.isSelected === true,
|
||||
positionLabel: getTargetLockPositionLabel(ref, orderedRefs),
|
||||
})) satisfies AgentTargetLockRefPreview[];
|
||||
}
|
||||
|
||||
export function buildAgentTargetLockPayload(params: {
|
||||
requestTargetResolution: ResolvedTargetAssets;
|
||||
requestResolvedMediaInput: ResolvedChatMediaInput;
|
||||
needsClarification: boolean;
|
||||
clarificationMessage?: string;
|
||||
}): AgentTargetLockStreamPayload {
|
||||
const stage = params.needsClarification
|
||||
? "clarify"
|
||||
: params.requestTargetResolution.targetRefs.length > 0
|
||||
? "locked"
|
||||
: "unresolved";
|
||||
|
||||
return {
|
||||
stage,
|
||||
resolutionMode: params.requestTargetResolution.resolutionMode,
|
||||
confidence: Number(params.requestTargetResolution.confidence || 0),
|
||||
reason: params.requestTargetResolution.reason,
|
||||
needsClarification: params.needsClarification,
|
||||
clarificationMessage: params.clarificationMessage?.trim() || undefined,
|
||||
targetCount: params.requestTargetResolution.targetRefs.length,
|
||||
candidateCount: params.requestTargetResolution.candidateRefs.length,
|
||||
targets: serializeAgentTargetLockRefs(params.requestTargetResolution.targetRefs),
|
||||
candidates: serializeAgentTargetLockRefs(
|
||||
params.requestTargetResolution.candidateRefs.length > 0
|
||||
? params.requestTargetResolution.candidateRefs
|
||||
: params.requestResolvedMediaInput.imageRefs
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeAgentTargetLockChunk(
|
||||
writer: StreamWriter,
|
||||
payload: AgentTargetLockStreamPayload,
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(writer, "data-agentTargetLock", payload, label);
|
||||
}
|
||||
|
||||
export function createSseResponse(stream: ReadableStream) {
|
||||
return new Response(stream.pipeThrough(new JsonToSseTransformStream()), {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function startStreamKeepalive(
|
||||
writer: StreamWriter,
|
||||
label: string,
|
||||
intervalMs = 15_000
|
||||
) {
|
||||
const timer = setInterval(() => {
|
||||
safeWriteStreamChunk(
|
||||
writer,
|
||||
{
|
||||
type: "data-keepalive",
|
||||
data: JSON.stringify({ ts: Date.now(), label }),
|
||||
},
|
||||
`${label}:keepalive`
|
||||
);
|
||||
}, intervalMs);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
export async function runBoundedParallel<T>(
|
||||
items: T[],
|
||||
workerCount: number,
|
||||
worker: (item: T, index: number) => Promise<void>
|
||||
) {
|
||||
const maxWorkers = Math.max(1, Math.min(workerCount, items.length));
|
||||
let cursor = 0;
|
||||
let firstError: unknown = null;
|
||||
|
||||
const workers = Array.from({ length: maxWorkers }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
if (firstError) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await worker(items[index]!, index);
|
||||
} catch (error) {
|
||||
firstError = firstError || error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
|
||||
if (firstError) {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
279
app/(chat)/api/chat/_lib/video-models.ts
Normal file
279
app/(chat)/api/chat/_lib/video-models.ts
Normal file
@ -0,0 +1,279 @@
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { after } from "next/server";
|
||||
import {
|
||||
getAiModelById,
|
||||
getEnabledAiModelsWithProvider,
|
||||
updateMessageById,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
|
||||
|
||||
const chatApiLogger = createLogger("chat.videoModels");
|
||||
|
||||
|
||||
export type VideoModeType =
|
||||
| "text-to-video"
|
||||
| "first-frame"
|
||||
| "first-last-frame"
|
||||
| "reference-images";
|
||||
|
||||
export function isVideoProviderType(
|
||||
providerType?: string | null,
|
||||
model?: {
|
||||
modelId?: string | null;
|
||||
capabilities?: unknown;
|
||||
supportsImageGen?: boolean | null;
|
||||
},
|
||||
): boolean {
|
||||
return isVideoModelDescriptor({
|
||||
providerType,
|
||||
modelId: model?.modelId,
|
||||
capabilities: model?.capabilities,
|
||||
supportsImageGen: model?.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
export function isImageGenerationModelType(
|
||||
providerType?: string | null,
|
||||
model?: {
|
||||
modelId?: string | null;
|
||||
capabilities?: unknown;
|
||||
supportsImageGen?: boolean | null;
|
||||
},
|
||||
): boolean {
|
||||
return isImageGenerationModelDescriptor({
|
||||
providerType,
|
||||
modelId: model?.modelId,
|
||||
capabilities: model?.capabilities,
|
||||
supportsImageGen: model?.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
export function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] {
|
||||
const normalized = modelId.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized === "gen4.5" ||
|
||||
normalized === "gen4_turbo" ||
|
||||
normalized === "gen4_aleph" ||
|
||||
normalized === "gen3a_turbo" ||
|
||||
normalized === "veo3" ||
|
||||
normalized === "veo3.1" ||
|
||||
normalized === "veo3.1_fast"
|
||||
) {
|
||||
return ["text-to-video", "first-frame"];
|
||||
}
|
||||
|
||||
if (normalized.includes("lite-t2v")) {
|
||||
return ["text-to-video"];
|
||||
}
|
||||
if (normalized.includes("lite-i2v")) {
|
||||
return ["first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
if (normalized.includes("pro-fast")) {
|
||||
return ["text-to-video", "first-frame"];
|
||||
}
|
||||
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
|
||||
return ["text-to-video", "first-frame", "first-last-frame"];
|
||||
}
|
||||
|
||||
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
|
||||
export function getSupportedVideoModesForModel(model: any): VideoModeType[] {
|
||||
const capabilityModes = (((model?.capabilities as any)?.supportedModes ?? []) as string[]).filter(
|
||||
(mode): mode is VideoModeType =>
|
||||
mode === "text-to-video" ||
|
||||
mode === "first-frame" ||
|
||||
mode === "first-last-frame" ||
|
||||
mode === "reference-images",
|
||||
);
|
||||
|
||||
if (capabilityModes.length > 0) {
|
||||
return capabilityModes;
|
||||
}
|
||||
|
||||
const modelId = String(model?.modelId || "");
|
||||
return inferSupportedVideoModesByModelId(modelId);
|
||||
}
|
||||
|
||||
export function scoreVideoSubModelForMode(modelId: string, mode: VideoModeType): number {
|
||||
const normalized = modelId.toLowerCase();
|
||||
const isProFast = normalized.includes("pro-fast");
|
||||
const isLiteT2V = normalized.includes("lite-t2v");
|
||||
const isLiteI2V = normalized.includes("lite-i2v");
|
||||
const isPro = (normalized.includes("-pro-") || normalized.endsWith("-pro")) && !isProFast;
|
||||
const isV15Pro = normalized.includes("seedance-1-5-pro");
|
||||
|
||||
switch (mode) {
|
||||
case "first-last-frame":
|
||||
if (isV15Pro) return 150;
|
||||
if (isPro) return 120;
|
||||
if (isLiteI2V) return 90;
|
||||
if (isProFast) return -1000;
|
||||
if (isLiteT2V) return -1000;
|
||||
return 10;
|
||||
case "first-frame":
|
||||
if (isV15Pro) return 140;
|
||||
if (isProFast) return 130;
|
||||
if (isPro) return 120;
|
||||
if (isLiteI2V) return 100;
|
||||
if (isLiteT2V) return 20;
|
||||
return 10;
|
||||
case "reference-images":
|
||||
if (isV15Pro) return 150;
|
||||
if (isLiteI2V) return 140;
|
||||
if (isPro) return 110;
|
||||
if (isProFast || isLiteT2V) return -1000;
|
||||
return 10;
|
||||
case "text-to-video":
|
||||
default:
|
||||
if (isV15Pro) return 140;
|
||||
if (isLiteT2V) return 130;
|
||||
if (isProFast) return 120;
|
||||
if (isPro) return 100;
|
||||
if (isLiteI2V) return 80;
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectBestVideoSubModelForMode(
|
||||
subModels: Array<{ model: any }>,
|
||||
mode: VideoModeType,
|
||||
): { model: any } | null {
|
||||
const compatible = subModels.filter(({ model }) => {
|
||||
const supportedModes = getSupportedVideoModesForModel(model);
|
||||
return supportedModes.includes(mode);
|
||||
});
|
||||
|
||||
const candidates = compatible.length > 0 ? compatible : subModels;
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
return [...candidates].sort((a, b) => {
|
||||
const scoreA = scoreVideoSubModelForMode(String(a.model?.modelId || ""), mode);
|
||||
const scoreB = scoreVideoSubModelForMode(String(b.model?.modelId || ""), mode);
|
||||
return scoreB - scoreA;
|
||||
})[0];
|
||||
}
|
||||
|
||||
export async function resolveStoryboardModelCandidates(
|
||||
selectedModelRef: string,
|
||||
): Promise<StoryboardModelCandidate[]> {
|
||||
const selectedModel = await getAiModelById({ id: selectedModelRef }).catch(() => undefined);
|
||||
if (!selectedModel) return [];
|
||||
|
||||
const configuredFallbackIds = [
|
||||
selectedModel.fallbackModelId1,
|
||||
selectedModel.fallbackModelId2,
|
||||
].filter((item): item is string => Boolean(String(item || "").trim()));
|
||||
|
||||
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
|
||||
const sameProvider = allModels
|
||||
.filter(
|
||||
({ model, provider }: any) =>
|
||||
provider?.id === selectedModel.providerId && model?.id !== selectedModel.id && model?.isEnabled,
|
||||
)
|
||||
.map(({ model }: any) => String(model?.id || "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const candidateIds = Array.from(new Set([selectedModel.id, ...configuredFallbackIds, ...sameProvider]));
|
||||
const byModelRef = new Map<string, any>();
|
||||
allModels
|
||||
.map(({ model }: any) => model)
|
||||
.filter(Boolean)
|
||||
.forEach((model: any) => {
|
||||
byModelRef.set(String(model.id), model);
|
||||
if (model.modelId) {
|
||||
byModelRef.set(String(model.modelId), model);
|
||||
}
|
||||
});
|
||||
|
||||
return candidateIds
|
||||
.map((id) => byModelRef.get(id))
|
||||
.filter(Boolean)
|
||||
.map((model: any) => ({
|
||||
id: String(model.id),
|
||||
modelId: String(model.modelId || "").trim(),
|
||||
displayName: String(model.displayName || model.modelId || "").trim(),
|
||||
}))
|
||||
.filter((item) => item.modelId);
|
||||
}
|
||||
|
||||
export function extractVideoUrlFromText(text: string): string | null {
|
||||
const normalizedText = String(text || "");
|
||||
if (!normalizedText) return null;
|
||||
|
||||
const htmlMatch = normalizedText.match(/<video[^>]+src="([^"]+)"/i);
|
||||
if (htmlMatch?.[1]) return htmlMatch[1];
|
||||
|
||||
const markdownMatch = normalizedText.match(
|
||||
/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i,
|
||||
);
|
||||
if (markdownMatch?.[1]) return markdownMatch[1];
|
||||
|
||||
const plainMatch = normalizedText.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
||||
return plainMatch?.[1] || null;
|
||||
}
|
||||
|
||||
export function isAlreadyArchivedVideoUrl(url: string): boolean {
|
||||
return /\/uploads\//i.test(url);
|
||||
}
|
||||
|
||||
export function scheduleVideoArchive(params: {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
temporaryVideoUrl: string;
|
||||
taskId?: string;
|
||||
metadata?: Record<string, any> | null;
|
||||
}) {
|
||||
const { chatId, messageId, temporaryVideoUrl, taskId, metadata } = params;
|
||||
if (!temporaryVideoUrl || isAlreadyArchivedVideoUrl(temporaryVideoUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delayHoursRaw = Number(process.env.VIDEO_ARCHIVE_DELAY_HOURS || 48);
|
||||
const delayHours = Number.isFinite(delayHoursRaw)
|
||||
? Math.max(1, Math.min(24 * 30, Math.round(delayHoursRaw)))
|
||||
: 48;
|
||||
const archiveAfterAtMs = Date.now() + delayHours * 60 * 60 * 1000;
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
const nextMetadata = {
|
||||
...(metadata || {}),
|
||||
temporaryVideoUrl,
|
||||
storageStatus: "temporary",
|
||||
archiveMode: "delayed_job",
|
||||
archiveAfterAt: archiveAfterAtMs,
|
||||
archiveDelayHours: delayHours,
|
||||
archiveTaskId: taskId || messageId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await updateMessageById({
|
||||
messageId,
|
||||
chatId,
|
||||
videoStorageStatus: "temporary",
|
||||
temporaryVideoUrl,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: nextMetadata as any,
|
||||
});
|
||||
|
||||
chatApiLogger.info("[Chat API] 🕒 已登记延迟归档视频:", {
|
||||
messageId,
|
||||
archiveAfterAt: new Date(archiveAfterAtMs).toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] ❌ 登记视频延迟归档失败(保留临时地址):", {
|
||||
messageId,
|
||||
temporaryVideoUrl,
|
||||
error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
||||
|
||||
const textPartSchema = z.object({
|
||||
type: z.enum(["text"]),
|
||||
@ -11,6 +12,8 @@ const filePartSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
url: z.string().url(),
|
||||
assetId: z.string().uuid().optional(),
|
||||
width: z.number().positive().optional(),
|
||||
height: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
const partSchema = z.union([textPartSchema, filePartSchema]);
|
||||
@ -30,11 +33,22 @@ const videoParamsSchema = z.object({
|
||||
returnLastFrame: z.boolean().optional(),
|
||||
}).optional();
|
||||
|
||||
const imageSizeValues = ["0.5K", "1K", "2K", "4K", "8K", "10K", "12K"] as const;
|
||||
|
||||
function normalizeImageSizeInput(value: unknown) {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return imageSizeValues.includes(normalized as (typeof imageSizeValues)[number])
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// 🆕 图片生成参数 schema
|
||||
const imageParamsSchema = z.object({
|
||||
aspectRatio: z.string().optional(),
|
||||
imageSize: z.string().optional(),
|
||||
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
|
||||
imageCount: z.number().min(1).max(10).optional(),
|
||||
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
|
||||
// 阿里生图专用参数
|
||||
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
||||
negativePrompt: z.string().optional(), // 反向提示词
|
||||
@ -50,7 +64,8 @@ const contextPayloadSchema = z
|
||||
projectId: z.string().uuid().optional(),
|
||||
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
|
||||
selectedAssetIds: z.array(z.string().uuid()).max(20).optional(),
|
||||
focusPolicy: z.enum(["auto", "selected", "latest_result", "original"]).optional(),
|
||||
focusPolicy: z.enum(["auto", "selected", "latest_result", "original", "slot"]).optional(),
|
||||
canvasAgentContext: z.custom<CanvasAgentContext>().optional(),
|
||||
canvasImageNodes: z
|
||||
.array(
|
||||
z.object({
|
||||
@ -71,6 +86,41 @@ const contextPayloadSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const agentModeSourceImageSchema = z.object({
|
||||
url: z.string().url(),
|
||||
label: z.string().max(255).optional(),
|
||||
name: z.string().max(255).optional(),
|
||||
assetId: z.string().min(1).max(128).nullable().optional(),
|
||||
});
|
||||
|
||||
const agentModeReferenceSchema = z.object({
|
||||
id: z.string().min(1).max(128),
|
||||
label: z.string().min(1).max(255),
|
||||
imageUrl: z.string().url(),
|
||||
candidateId: z.string().min(1).max(128).nullable().optional(),
|
||||
sortOrder: z.number().min(0).max(999).optional(),
|
||||
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
|
||||
passthrough: z.boolean().nullable().optional(),
|
||||
rerunReferenceImages: z.array(agentModeSourceImageSchema).max(6).optional(),
|
||||
});
|
||||
|
||||
const agentModePayloadSchema = z
|
||||
.object({
|
||||
modeType: z.enum(["detail_replacement", "sku_replacement"]),
|
||||
replacementStrategy: z.enum(["scene_replace", "product_only"]).optional(),
|
||||
query: z.string().max(64).optional(),
|
||||
queryType: z.enum(["sku", "spu", "productId"]).optional(),
|
||||
selectedCandidateId: z.string().min(1).max(128).nullable().optional(),
|
||||
references: z.array(agentModeReferenceSchema).max(40),
|
||||
sourceImages: z
|
||||
.object({
|
||||
productImages: z.array(agentModeSourceImageSchema).max(16).optional(),
|
||||
modelImage: agentModeSourceImageSchema.nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const postRequestBodySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
message: z.object({
|
||||
@ -79,14 +129,19 @@ export const postRequestBodySchema = z.object({
|
||||
parts: z.array(partSchema),
|
||||
}),
|
||||
hiddenPrompt: z.string().max(4000).optional(),
|
||||
workflowPromptOptimizationEnabled: z.boolean().optional(),
|
||||
autoContext: z.string().max(4000).optional(),
|
||||
agentPresetId: z.string().uuid().optional(),
|
||||
// 支持预定义模型名称或数据库中的模型 UUID
|
||||
selectedChatModel: z.string().min(1).max(128),
|
||||
selectedVisibilityType: z.enum(["public", "private"]),
|
||||
composerMode: z.enum(["agent", "image", "video", "detail"]).optional(),
|
||||
deepThinkingEnabled: z.boolean().optional(),
|
||||
debugProtocol: z.boolean().optional(),
|
||||
videoParams: videoParamsSchema, // 视频参数
|
||||
imageParams: imageParamsSchema, // 🆕 图片生成参数
|
||||
contextPayload: contextPayloadSchema,
|
||||
agentModePayload: agentModePayloadSchema,
|
||||
});
|
||||
|
||||
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;
|
||||
|
||||
660
app/(chat)/api/files/direct-upload/route.ts
Normal file
660
app/(chat)/api/files/direct-upload/route.ts
Normal file
@ -0,0 +1,660 @@
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
HeadObjectCommand,
|
||||
ListPartsCommand,
|
||||
PutObjectCommand,
|
||||
UploadPartCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
buildS3PublicUrl,
|
||||
buildStorageObjectKey,
|
||||
createS3Client,
|
||||
getContentType,
|
||||
maybeEnqueueStorageMigrationTask,
|
||||
resolveS3UploadTarget,
|
||||
} from "@/lib/storage/service";
|
||||
import {
|
||||
getOpenClawChatFileContentType,
|
||||
isOpenClawChatFolderFileAllowed,
|
||||
OPENCLAW_CHAT_FILE_MAX_BYTES,
|
||||
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
|
||||
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
|
||||
} from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
const logger = createLogger("direct-upload-api");
|
||||
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
||||
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
|
||||
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
||||
const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
|
||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||
|
||||
function elapsedMs(startedAt: number) {
|
||||
return Date.now() - startedAt;
|
||||
}
|
||||
|
||||
const allowedDirectUploadTypes = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
const allowedOpenClawVideoUploadTypes = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-m4v",
|
||||
"video/x-matroska",
|
||||
]);
|
||||
|
||||
const InitiateSchema = z.object({
|
||||
action: z.literal("initiate"),
|
||||
filename: z.string().min(1).max(256),
|
||||
contentType: z.string().min(1).max(128),
|
||||
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
|
||||
purpose: z.string().max(64).optional(),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const CompleteSchema = z.object({
|
||||
action: z.literal("complete"),
|
||||
key: z.string().min(1).max(512),
|
||||
uploadId: z.string().min(1).max(512).optional(),
|
||||
filename: z.string().min(1).max(256),
|
||||
contentType: z.string().min(1).max(128),
|
||||
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
|
||||
parts: z
|
||||
.array(
|
||||
z.object({
|
||||
ETag: z.string().min(1),
|
||||
PartNumber: z.number().int().positive(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
purpose: z.string().max(64).optional(),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const ResumeSchema = z.object({
|
||||
action: z.literal("resume"),
|
||||
key: z.string().min(1).max(512),
|
||||
uploadId: z.string().min(1).max(512),
|
||||
filename: z.string().min(1).max(256),
|
||||
contentType: z.string().min(1).max(128),
|
||||
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
|
||||
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
|
||||
purpose: z.string().max(64).optional(),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const AbortSchema = z.object({
|
||||
action: z.literal("abort"),
|
||||
key: z.string().min(1).max(512),
|
||||
uploadId: z.string().min(1).max(512),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const RequestSchema = z.discriminatedUnion("action", [
|
||||
InitiateSchema,
|
||||
ResumeSchema,
|
||||
CompleteSchema,
|
||||
AbortSchema,
|
||||
]);
|
||||
|
||||
function normalizeContentType(contentType: string, filename: string) {
|
||||
const trimmed = contentType.trim().toLowerCase();
|
||||
return trimmed || getContentType(filename);
|
||||
}
|
||||
|
||||
function sanitizeDisplayName(filename: string) {
|
||||
return (
|
||||
filename
|
||||
.trim()
|
||||
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 120) || `upload-${Date.now()}`
|
||||
);
|
||||
}
|
||||
|
||||
function userUploadPrefix(userId: string) {
|
||||
return `${DIRECT_UPLOAD_PREFIX}/${userId}`;
|
||||
}
|
||||
|
||||
function assertUserOwnedKey(key: string, userId: string) {
|
||||
const normalized = String(key || "").replace(/^\/+/, "");
|
||||
const prefix = `${userUploadPrefix(userId)}/`;
|
||||
if (!normalized.startsWith(prefix)) {
|
||||
const error = new Error("Invalid upload object key");
|
||||
error.name = "InvalidUploadObjectKeyError";
|
||||
throw error;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isAllowedDirectUpload(
|
||||
contentType: string,
|
||||
purpose: string | undefined,
|
||||
filename: string
|
||||
) {
|
||||
return (
|
||||
allowedDirectUploadTypes.has(contentType) ||
|
||||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
allowedOpenClawVideoUploadTypes.has(contentType)) ||
|
||||
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
|
||||
Boolean(
|
||||
getOpenClawChatFileContentType({ name: filename, type: contentType })
|
||||
)) ||
|
||||
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
|
||||
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
|
||||
);
|
||||
}
|
||||
|
||||
function getPurposeMaxBytes(purpose?: string) {
|
||||
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
|
||||
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
|
||||
}
|
||||
if (
|
||||
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
) {
|
||||
return DIRECT_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
|
||||
}
|
||||
|
||||
function computePartSize(size: number) {
|
||||
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
|
||||
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
|
||||
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
|
||||
return 64 * 1024 * 1024;
|
||||
}
|
||||
|
||||
async function createPartUrls(params: {
|
||||
client: ReturnType<typeof createS3Client>;
|
||||
bucket: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partNumbers: number[];
|
||||
}) {
|
||||
return Promise.all(
|
||||
params.partNumbers.map(async (partNumber) => {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: params.bucket,
|
||||
Key: params.key,
|
||||
UploadId: params.uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
return {
|
||||
partNumber,
|
||||
url: await getSignedUrl(params.client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestStartedAt = Date.now();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof RequestSchema>;
|
||||
try {
|
||||
payload = RequestSchema.parse(await request.json());
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid direct upload request",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("direct_upload_request_start", {
|
||||
action: payload.action,
|
||||
size: "size" in payload ? payload.size : null,
|
||||
contentType: "contentType" in payload ? payload.contentType : null,
|
||||
purpose: "purpose" in payload ? payload.purpose || null : null,
|
||||
clientUploadId:
|
||||
"clientUploadId" in payload ? payload.clientUploadId || null : null,
|
||||
});
|
||||
|
||||
const resolveStartedAt = Date.now();
|
||||
const target = await resolveS3UploadTarget().catch((error) => {
|
||||
logger.error("resolve_storage_target_failed", error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
return NextResponse.json(
|
||||
{ error: "Direct upload is not available for current storage backend" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
logger.info("direct_upload_storage_target_resolved", {
|
||||
action: payload.action,
|
||||
elapsedMs: elapsedMs(resolveStartedAt),
|
||||
provider: target.config.provider || "s3",
|
||||
bucket: target.config.bucket,
|
||||
hasPublicUrl: Boolean(target.config.publicUrl),
|
||||
});
|
||||
|
||||
const client = createS3Client(target.config);
|
||||
const bucket = target.config.bucket;
|
||||
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
|
||||
|
||||
try {
|
||||
if (payload.action === "initiate") {
|
||||
const contentType = normalizeContentType(
|
||||
payload.contentType,
|
||||
payload.filename
|
||||
);
|
||||
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
logger.warn("unsupported_direct_upload_type", {
|
||||
contentType,
|
||||
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
||||
size: payload.size,
|
||||
purpose: payload.purpose || null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||
return NextResponse.json(
|
||||
{ error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = sanitizeDisplayName(payload.filename);
|
||||
const key = buildStorageObjectKey(
|
||||
displayName,
|
||||
userUploadPrefix(session.user.id)
|
||||
);
|
||||
|
||||
const multipartThreshold =
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? DEFAULT_PART_SIZE_BYTES
|
||||
: MULTIPART_THRESHOLD_BYTES;
|
||||
if (payload.size < multipartThreshold) {
|
||||
const presignStartedAt = Date.now();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
|
||||
});
|
||||
const url = await getSignedUrl(client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
});
|
||||
logger.info("direct_upload_single_presigned", {
|
||||
elapsedMs: elapsedMs(presignStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
size: payload.size,
|
||||
contentType,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
};
|
||||
if (usePublicReadAcl) {
|
||||
headers["x-amz-acl"] = "public-read";
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
mode: "single",
|
||||
key,
|
||||
url,
|
||||
headers,
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
|
||||
const multipartStartedAt = Date.now();
|
||||
const createResult = await client.send(
|
||||
new CreateMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
const uploadId = createResult.UploadId;
|
||||
if (!uploadId) {
|
||||
throw new Error("Storage provider did not return uploadId");
|
||||
}
|
||||
|
||||
const partSize = computePartSize(payload.size);
|
||||
const partCount = Math.ceil(payload.size / partSize);
|
||||
const parts = await createPartUrls({
|
||||
client,
|
||||
bucket,
|
||||
key,
|
||||
uploadId,
|
||||
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
|
||||
});
|
||||
logger.info("direct_upload_multipart_prepared", {
|
||||
elapsedMs: elapsedMs(multipartStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
size: payload.size,
|
||||
partCount,
|
||||
partSize,
|
||||
contentType,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
mode: "multipart",
|
||||
key,
|
||||
uploadId,
|
||||
partSize,
|
||||
partCount,
|
||||
parts,
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.action === "resume") {
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||
}
|
||||
const expectedPartSize = computePartSize(payload.size);
|
||||
if (payload.partSize !== expectedPartSize) {
|
||||
return NextResponse.json(
|
||||
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const listed = await client.send(
|
||||
new ListPartsCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: payload.uploadId,
|
||||
MaxParts: 1000,
|
||||
})
|
||||
);
|
||||
const uploadedParts = (listed.Parts || []).flatMap((part) => {
|
||||
if (!part.ETag || typeof part.PartNumber !== "number") return [];
|
||||
return [
|
||||
{
|
||||
ETag: part.ETag,
|
||||
PartNumber: part.PartNumber,
|
||||
size: Number(part.Size || 0),
|
||||
},
|
||||
];
|
||||
});
|
||||
const uploadedPartNumbers = new Set(
|
||||
uploadedParts.map((part) => part.PartNumber)
|
||||
);
|
||||
const partCount = Math.ceil(payload.size / expectedPartSize);
|
||||
const missingPartNumbers = Array.from(
|
||||
{ length: partCount },
|
||||
(_, index) => index + 1
|
||||
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
|
||||
const parts = await createPartUrls({
|
||||
client,
|
||||
bucket,
|
||||
key,
|
||||
uploadId: payload.uploadId,
|
||||
partNumbers: missingPartNumbers,
|
||||
});
|
||||
|
||||
logger.info("direct_upload_multipart_resumed", {
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
size: payload.size,
|
||||
uploadedPartCount: uploadedParts.length,
|
||||
missingPartCount: missingPartNumbers.length,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json({
|
||||
mode: "multipart",
|
||||
key,
|
||||
uploadId: payload.uploadId,
|
||||
partSize: expectedPartSize,
|
||||
partCount,
|
||||
parts,
|
||||
uploadedParts,
|
||||
resumed: true,
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
} catch (error) {
|
||||
const head = await client
|
||||
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
|
||||
.catch(() => null);
|
||||
if (head?.ContentLength === payload.size) {
|
||||
return NextResponse.json({
|
||||
mode: "completed",
|
||||
key,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
logger.warn("direct_upload_resume_session_missing", error, {
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.action === "abort") {
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const abortStartedAt = Date.now();
|
||||
await client.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: payload.uploadId,
|
||||
})
|
||||
);
|
||||
logger.info("direct_upload_multipart_aborted", {
|
||||
elapsedMs: elapsedMs(abortStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||
}
|
||||
|
||||
if (payload.uploadId) {
|
||||
const completeMultipartStartedAt = Date.now();
|
||||
const parts = [...(payload.parts || [])].sort(
|
||||
(a, b) => a.PartNumber - b.PartNumber
|
||||
);
|
||||
if (parts.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing multipart upload parts" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await client.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: payload.uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: parts,
|
||||
},
|
||||
})
|
||||
);
|
||||
logger.info("direct_upload_multipart_completed", {
|
||||
elapsedMs: elapsedMs(completeMultipartStartedAt),
|
||||
partCount: parts.length,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
|
||||
const headStartedAt = Date.now();
|
||||
const head = await client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
logger.info("direct_upload_head_object_completed", {
|
||||
elapsedMs: elapsedMs(headStartedAt),
|
||||
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
const actualSize =
|
||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||
if (actualSize !== null && actualSize !== payload.size) {
|
||||
throw new Error(
|
||||
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = buildS3PublicUrl(target.config, key);
|
||||
const resolvedContentType =
|
||||
head.ContentType || contentType || getContentType(payload.filename);
|
||||
const assetStartedAt = Date.now();
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "file"
|
||||
: resolvedContentType.startsWith("video/")
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType:
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_video_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_file_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_folder_file_upload"
|
||||
: "upload",
|
||||
status: "ready",
|
||||
originalUrl: url,
|
||||
previewUrl:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? null
|
||||
: url,
|
||||
thumbnailUrl:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? null
|
||||
: url,
|
||||
mimeType: resolvedContentType,
|
||||
displayName: payload.filename,
|
||||
sourceTable:
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_video_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_file_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_folder_file_upload"
|
||||
: "direct_upload",
|
||||
metadata: {
|
||||
pathname: key,
|
||||
size: actualSize ?? payload.size,
|
||||
expectedSize: payload.size,
|
||||
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||
purpose: payload.purpose || null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
},
|
||||
});
|
||||
logger.info("direct_upload_asset_registered", {
|
||||
elapsedMs: elapsedMs(assetStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||
assetId: asset?.id ?? null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
await maybeEnqueueStorageMigrationTask({
|
||||
uploaded: { url, pathname: key },
|
||||
storageConfig: target.storageConfig,
|
||||
migrationConfig: target.migrationConfig,
|
||||
}).catch((error) => {
|
||||
logger.warn("enqueue_storage_migration_failed", error);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
url,
|
||||
pathname: key,
|
||||
contentType: resolvedContentType,
|
||||
assetId: asset?.id ?? null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("direct_upload_request_failed", error, {
|
||||
action: payload.action,
|
||||
size: "size" in payload ? payload.size : null,
|
||||
contentType: "contentType" in payload ? payload.contentType : null,
|
||||
purpose: "purpose" in payload ? payload.purpose || null : null,
|
||||
clientUploadId:
|
||||
"clientUploadId" in payload ? payload.clientUploadId || null : null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Direct upload failed",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{
|
||||
status:
|
||||
error instanceof Error &&
|
||||
error.name === "InvalidUploadObjectKeyError"
|
||||
? 403
|
||||
: 500,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,129 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { isSiteAdminType } from "@/lib/middleware/admin";
|
||||
import { getContentType, uploadFile } from "@/lib/storage/service";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
const logger = createLogger("upload-api");
|
||||
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
]);
|
||||
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
]);
|
||||
const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-m4v",
|
||||
"video/x-matroska",
|
||||
]);
|
||||
|
||||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||||
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
]);
|
||||
|
||||
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||
const normalized = contentType.trim().toLowerCase();
|
||||
if (normalized && normalized !== "application/octet-stream") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return getContentType(filename);
|
||||
}
|
||||
|
||||
function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
|
||||
const extension = filename.toLowerCase().split(".").pop() || "unknown";
|
||||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||||
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
||||
}
|
||||
if (
|
||||
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
||||
extension === "avif" ||
|
||||
extension === "tif" ||
|
||||
extension === "tiff"
|
||||
) {
|
||||
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
|
||||
}
|
||||
|
||||
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
|
||||
}
|
||||
|
||||
function isHeicUpload(contentType: string, filename: string) {
|
||||
const extension = filename.toLowerCase().split(".").pop() || "";
|
||||
return (
|
||||
HEIC_UPLOAD_TYPES.has(contentType) ||
|
||||
extension === "heic" ||
|
||||
extension === "heif"
|
||||
);
|
||||
}
|
||||
|
||||
function isSharpCompatibilityUpload(contentType: string, filename: string) {
|
||||
const extension = filename.toLowerCase().split(".").pop() || "";
|
||||
return (
|
||||
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
||||
extension === "avif" ||
|
||||
extension === "tif" ||
|
||||
extension === "tiff"
|
||||
);
|
||||
}
|
||||
|
||||
function toJpegFilename(filename: string) {
|
||||
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
||||
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
|
||||
}
|
||||
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
||||
}
|
||||
return `${trimmed}.jpg`;
|
||||
}
|
||||
|
||||
async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
||||
const convert = (await import("heic-convert")).default;
|
||||
const output = await convert({
|
||||
buffer,
|
||||
format: "JPEG",
|
||||
quality: 0.92,
|
||||
});
|
||||
if (output instanceof ArrayBuffer) {
|
||||
return Buffer.from(new Uint8Array(output));
|
||||
}
|
||||
return Buffer.from(output);
|
||||
}
|
||||
|
||||
async function convertSharpCompatibilityImageToJpeg(
|
||||
buffer: Buffer
|
||||
): Promise<Buffer> {
|
||||
const sharp = (await import("sharp")).default;
|
||||
return sharp(buffer, { animated: false, failOn: "none" })
|
||||
.rotate()
|
||||
.flatten({ background: "#ffffff" })
|
||||
.jpeg({
|
||||
quality: 92,
|
||||
mozjpeg: true,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
/**
|
||||
* 使用Sharp智能优化图片(保持原格式)
|
||||
* 策略:避免双重压缩,只在需要时优化
|
||||
@ -18,7 +138,7 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer>
|
||||
|
||||
// ✅ 智能判断:如果文件已经很小,跳过优化(避免双重压缩)
|
||||
if (fileSizeMB < OPTIMIZE_THRESHOLD_MB) {
|
||||
console.log(`[Upload] 文件已较小(${fileSizeMB.toFixed(2)}MB < ${OPTIMIZE_THRESHOLD_MB}MB),跳过优化,避免双重压缩`);
|
||||
logger.debug(`[Upload] 文件已较小(${fileSizeMB.toFixed(2)}MB < ${OPTIMIZE_THRESHOLD_MB}MB),跳过优化,避免双重压缩`);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@ -43,10 +163,10 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer>
|
||||
// 只有优化后文件明显更小才使用(避免不必要的处理)
|
||||
const reductionPercent = ((1 - optimized.length / buffer.length) * 100);
|
||||
if (reductionPercent > 5) { // 只有减少超过5%才使用
|
||||
console.log(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
|
||||
logger.debug(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
|
||||
return optimized;
|
||||
} else {
|
||||
console.log(`[Upload] JPEG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
|
||||
logger.debug(`[Upload] JPEG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
|
||||
return buffer;
|
||||
}
|
||||
} else if (mimeType === 'image/png') {
|
||||
@ -65,10 +185,10 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer>
|
||||
// 只有优化后文件明显更小才使用
|
||||
const reductionPercent = ((1 - optimized.length / buffer.length) * 100);
|
||||
if (reductionPercent > 5) { // 只有减少超过5%才使用
|
||||
console.log(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
|
||||
logger.debug(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
|
||||
return optimized;
|
||||
} else {
|
||||
console.log(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
|
||||
logger.debug(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@ -76,37 +196,38 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer>
|
||||
// 其他格式返回原图
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
console.error("[Upload] 图片优化失败,使用原图:", error);
|
||||
logger.error("[Upload] 图片优化失败,使用原图:", error);
|
||||
// 优化失败时返回原图,不影响上传流程
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
return view.slice().buffer;
|
||||
}
|
||||
|
||||
// Use Blob instead of File since File is not available in Node.js environment
|
||||
const FileSchema = z.object({
|
||||
file: z
|
||||
.instanceof(Blob)
|
||||
.refine((file) => file.size <= 50 * 1024 * 1024, {
|
||||
message: "File size should be less than 50MB",
|
||||
})
|
||||
// Update the file type based on the kind of files you want to accept
|
||||
.refine((file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type), {
|
||||
message: "File type should be JPEG, PNG or WebP",
|
||||
}),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
console.log("[Upload API] Received upload request");
|
||||
logger.debug("[Upload API] Received upload request");
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
console.log("[Upload API] Unauthorized - no session");
|
||||
logger.debug("[Upload API] Unauthorized - no session");
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (request.body === null) {
|
||||
console.log("[Upload API] Request body is empty");
|
||||
logger.debug("[Upload API] Request body is empty");
|
||||
return new Response("Request body is empty", { status: 400 });
|
||||
}
|
||||
|
||||
@ -114,15 +235,43 @@ export async function POST(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as Blob;
|
||||
|
||||
console.log("[Upload API] FormData received, file exists:", !!file);
|
||||
logger.debug("[Upload API] FormData received, file exists:", !!file);
|
||||
|
||||
if (!file) {
|
||||
console.log("[Upload API] No file in FormData");
|
||||
logger.debug("[Upload API] No file in FormData");
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log("[Upload API] File info:", {
|
||||
type: file.type,
|
||||
// Get filename from formData since Blob doesn't have name property
|
||||
const originalFilename = (formData.get("file") as File).name;
|
||||
const originalContentType = normalizeUploadContentType(
|
||||
file.type,
|
||||
originalFilename
|
||||
);
|
||||
let filename = originalFilename;
|
||||
let contentType = originalContentType;
|
||||
const uploadPurpose = String(formData.get("purpose") || "");
|
||||
const isAtlasCloudReferenceVideo =
|
||||
uploadPurpose === "canvas-video-process" &&
|
||||
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
|
||||
const isOpenClawChatVideoUpload =
|
||||
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
|
||||
const clientUploadId =
|
||||
String(formData.get("clientUploadId") || "").trim() || null;
|
||||
const isSvgUpload = contentType === "image/svg+xml";
|
||||
if (
|
||||
isSvgUpload &&
|
||||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "SVG uploads are only allowed for administrator icon settings" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("[Upload API] File info:", {
|
||||
type: contentType,
|
||||
size: file.size,
|
||||
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
|
||||
});
|
||||
@ -134,28 +283,164 @@ export async function POST(request: Request) {
|
||||
.map((error) => error.message)
|
||||
.join(", ");
|
||||
|
||||
console.log("[Upload API] Validation failed:", errorMessage);
|
||||
logger.debug("[Upload API] Validation failed:", errorMessage);
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get filename from formData since Blob doesn't have name property
|
||||
const filename = (formData.get("file") as File).name;
|
||||
console.log("[Upload API] Filename:", filename);
|
||||
if (
|
||||
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
||||
!isAtlasCloudReferenceVideo &&
|
||||
!isOpenClawChatVideoUpload
|
||||
) {
|
||||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||||
logger.warn("[Upload API] Unsupported upload content type", {
|
||||
contentType,
|
||||
filenameExtension: filename.toLowerCase().split(".").pop() || null,
|
||||
size: file.size,
|
||||
purpose: uploadPurpose || null,
|
||||
clientUploadId,
|
||||
});
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("[Upload API] Filename:", filename);
|
||||
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
console.log("[Upload API] File buffer size:", fileBuffer.byteLength);
|
||||
logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength);
|
||||
|
||||
try {
|
||||
let uploadBuffer: Buffer = Buffer.from(fileBuffer);
|
||||
let convertedFrom: string | null = null;
|
||||
|
||||
if (isHeicUpload(contentType, filename)) {
|
||||
try {
|
||||
const convertedBuffer = await convertHeicToJpeg(uploadBuffer);
|
||||
logger.info("[Upload API] HEIC image converted to JPEG", {
|
||||
clientUploadId,
|
||||
originalContentType: contentType,
|
||||
originalSize: uploadBuffer.length,
|
||||
convertedSize: convertedBuffer.length,
|
||||
});
|
||||
uploadBuffer = convertedBuffer;
|
||||
filename = toJpegFilename(filename);
|
||||
convertedFrom = contentType;
|
||||
contentType = "image/jpeg";
|
||||
} catch (heicError) {
|
||||
logger.warn("[Upload API] HEIC image conversion failed", {
|
||||
clientUploadId,
|
||||
contentType,
|
||||
size: uploadBuffer.length,
|
||||
error:
|
||||
heicError instanceof Error ? heicError.message : String(heicError),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: createUnsupportedImageTypeMessage(contentType, filename),
|
||||
clientUploadId,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (isSharpCompatibilityUpload(contentType, filename)) {
|
||||
try {
|
||||
const convertedBuffer =
|
||||
await convertSharpCompatibilityImageToJpeg(uploadBuffer);
|
||||
logger.info("[Upload API] Compatibility image converted to JPEG", {
|
||||
clientUploadId,
|
||||
originalContentType: contentType,
|
||||
originalSize: uploadBuffer.length,
|
||||
convertedSize: convertedBuffer.length,
|
||||
});
|
||||
uploadBuffer = convertedBuffer;
|
||||
filename = toJpegFilename(filename);
|
||||
convertedFrom = contentType;
|
||||
contentType = "image/jpeg";
|
||||
} catch (conversionError) {
|
||||
logger.warn("[Upload API] Compatibility image conversion failed", {
|
||||
clientUploadId,
|
||||
contentType,
|
||||
size: uploadBuffer.length,
|
||||
error:
|
||||
conversionError instanceof Error
|
||||
? conversionError.message
|
||||
: String(conversionError),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: createUnsupportedImageTypeMessage(contentType, filename),
|
||||
clientUploadId,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
|
||||
const preview = await createAgentWorkflowPreviewImageBuffer(uploadBuffer);
|
||||
const previewResult = await uploadFile(
|
||||
`${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`,
|
||||
toArrayBuffer(preview.buffer)
|
||||
);
|
||||
|
||||
let assetId: string | null = null;
|
||||
try {
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
sourceType: "agent_workflow_preview_upload",
|
||||
status: "ready",
|
||||
originalUrl: previewResult.url,
|
||||
previewUrl: previewResult.url,
|
||||
thumbnailUrl: previewResult.url,
|
||||
mimeType: "image/webp",
|
||||
width: preview.width,
|
||||
height: preview.height,
|
||||
displayName: filename,
|
||||
sourceTable: "agent_workflow_preview_upload",
|
||||
metadata: {
|
||||
pathname: previewResult.pathname,
|
||||
originalSize: fileBuffer.byteLength,
|
||||
previewSize: preview.buffer.length,
|
||||
purpose: uploadPurpose,
|
||||
clientUploadId,
|
||||
convertedFrom,
|
||||
},
|
||||
});
|
||||
assetId = asset?.id ?? null;
|
||||
} catch (assetError) {
|
||||
logger.warn("[Upload API] Failed to register workflow preview asset:", assetError);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...previewResult,
|
||||
url: previewResult.url,
|
||||
previewUrl: previewResult.url,
|
||||
thumbnailUrl: previewResult.url,
|
||||
originalUrl: previewResult.url,
|
||||
previewImageSourceUrl: previewResult.url,
|
||||
assetId,
|
||||
width: preview.width,
|
||||
height: preview.height,
|
||||
optimizedFor: uploadPurpose,
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 使用Sharp优化图片(保持原格式)
|
||||
let optimizedBuffer: Buffer = Buffer.from(fileBuffer);
|
||||
let optimizedBuffer: Buffer = uploadBuffer;
|
||||
|
||||
// ✅ 优化原图(保持JPG/PNG格式)
|
||||
// WebP格式不优化(用于预览图,保持原样)
|
||||
if (file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png') {
|
||||
optimizedBuffer = await optimizeImage(optimizedBuffer, file.type);
|
||||
} else if (file.type === 'image/webp') {
|
||||
// WebP格式不优化(用于预览图),直接使用
|
||||
console.log(`[Upload] WebP格式(预览图),跳过优化,直接上传`);
|
||||
const shouldOptimizeImage =
|
||||
uploadPurpose !== "canvas-upload" &&
|
||||
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
|
||||
|
||||
if (shouldOptimizeImage) {
|
||||
optimizedBuffer = await optimizeImage(optimizedBuffer, contentType);
|
||||
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
|
||||
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
|
||||
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
|
||||
} else if (uploadPurpose === "canvas-upload") {
|
||||
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
|
||||
}
|
||||
|
||||
// 将Buffer转换为ArrayBuffer用于上传
|
||||
@ -172,39 +457,57 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
sourceType: "upload",
|
||||
mediaType:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
status: "ready",
|
||||
originalUrl: data.url,
|
||||
previewUrl: data.url,
|
||||
thumbnailUrl: data.url,
|
||||
mimeType: data.contentType || file.type || null,
|
||||
previewUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
thumbnailUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
mimeType: data.contentType || contentType || null,
|
||||
displayName: filename,
|
||||
sourceTable: "upload",
|
||||
sourceTable: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
metadata: {
|
||||
pathname: data.pathname,
|
||||
size: optimizedBuffer.length,
|
||||
purpose: uploadPurpose || null,
|
||||
clientUploadId,
|
||||
convertedFrom,
|
||||
},
|
||||
});
|
||||
assetId = asset?.id ?? null;
|
||||
} catch (assetError) {
|
||||
console.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
|
||||
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
|
||||
}
|
||||
|
||||
console.log("[Upload API] Upload successful:", data.url);
|
||||
logger.debug("[Upload API] Upload successful:", data.url);
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
assetId,
|
||||
clientUploadId,
|
||||
convertedFrom,
|
||||
});
|
||||
} catch (_error) {
|
||||
console.error("[Upload API] Upload failed:", _error);
|
||||
logger.error("[Upload API] Upload failed:", _error);
|
||||
return NextResponse.json({
|
||||
error: "Upload failed",
|
||||
details: _error instanceof Error ? _error.message : String(_error)
|
||||
}, { status: 500 });
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("[Upload API] Request processing failed:", _error);
|
||||
logger.error("[Upload API] Request processing failed:", _error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to process request",
|
||||
|
||||
@ -3,19 +3,24 @@ import {
|
||||
getAgentAiModelWithProvider,
|
||||
getDefaultAiModel,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getFrontendDisplayAiModelsWithProvider,
|
||||
getSiteSettings,
|
||||
getSystemSetting,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { isAiModelAgentAuto, isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
||||
|
||||
const VIDEO_PROVIDER_TYPES = new Set([
|
||||
"volcano-video",
|
||||
"doubao-video",
|
||||
"qianwen-video",
|
||||
"fal-video",
|
||||
"ruxa-video",
|
||||
]);
|
||||
import {
|
||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||
getAgentWorkflowBusinessModeSettings,
|
||||
normalizeAgentWorkflowUiConfig,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
|
||||
function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) {
|
||||
const displayName = String(model.displayName || "").toLowerCase();
|
||||
@ -42,9 +47,98 @@ function sortModelsForDisplay<T extends { sortOrder?: number | null; displayName
|
||||
});
|
||||
}
|
||||
|
||||
function isWorkflowImageGenerationModel(model: {
|
||||
providerType?: string | null;
|
||||
modelId?: string | null;
|
||||
capabilities?: unknown;
|
||||
supportsImageGen?: boolean | null;
|
||||
}) {
|
||||
return (
|
||||
isImageGenerationModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
}) &&
|
||||
!isVideoModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
if (isLocalCanvasE2E) {
|
||||
const models = [
|
||||
{
|
||||
id: "local-e2e-image-model",
|
||||
modelId: "nano-banana-pro-local",
|
||||
displayName: "Nano Banana Pro",
|
||||
avatarUrl: null,
|
||||
description: "本地视频画布测试图片模型",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
isMultimodal: true,
|
||||
isDefault: true,
|
||||
providerName: "Local E2E",
|
||||
providerType: "local",
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
capabilities: { image: true },
|
||||
videoToolCapability: null,
|
||||
imageGenConfig: {
|
||||
creditCostPerImage: 1,
|
||||
creditCostPerVideo: 20,
|
||||
videoDurationOptions: [5, 10],
|
||||
},
|
||||
sortOrder: 0,
|
||||
selectable: true,
|
||||
},
|
||||
{
|
||||
id: "local-e2e-video-model",
|
||||
modelId: "kling-3-local",
|
||||
displayName: "可灵 3.0",
|
||||
avatarUrl: null,
|
||||
description: "本地视频画布测试视频模型",
|
||||
supportsVision: true,
|
||||
supportsImageGen: false,
|
||||
isMultimodal: true,
|
||||
isDefault: false,
|
||||
providerName: "Local E2E",
|
||||
providerType: "local",
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
capabilities: { video: true },
|
||||
videoToolCapability: {
|
||||
supported: true,
|
||||
durations: [5, 10],
|
||||
resolutions: ["720p", "1080p"],
|
||||
},
|
||||
imageGenConfig: {
|
||||
creditCostPerVideo: 20,
|
||||
videoDurationOptions: [5, 10],
|
||||
},
|
||||
sortOrder: 1,
|
||||
selectable: true,
|
||||
},
|
||||
];
|
||||
|
||||
return Response.json({
|
||||
models,
|
||||
defaultModelId: models[0].id,
|
||||
agentWorkflowConfiguredDefaultModelId: null,
|
||||
agentWorkflowDefaultModelId: null,
|
||||
agentWorkflowConfiguredModels: [],
|
||||
agentModel: null,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
@ -54,11 +148,62 @@ export async function GET() {
|
||||
const currentUser = await getUserById({ id: session.user.id });
|
||||
const allowHiddenModels =
|
||||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
||||
const includeUnavailable =
|
||||
new URL(request.url).searchParams.get("includeUnavailable") === "1";
|
||||
|
||||
// 只获取主模型(不包括子模型),子模型由后端根据 videoParams.mode 自动选择
|
||||
const modelsWithProvider = await getEnabledAiModelsWithProvider(false, true);
|
||||
const modelsWithProvider = includeUnavailable
|
||||
? await getFrontendDisplayAiModelsWithProvider(false, true)
|
||||
: await getEnabledAiModelsWithProvider(false, true);
|
||||
const agentModelWithProvider = await getAgentAiModelWithProvider();
|
||||
const defaultModel = await getDefaultAiModel();
|
||||
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
type ModelWithProvider = (typeof modelsWithProvider)[number];
|
||||
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
|
||||
id: model.id,
|
||||
modelId: model.modelId,
|
||||
displayName: model.displayName,
|
||||
avatarUrl: model.avatarUrl,
|
||||
description: model.description,
|
||||
supportsVision: model.supportsVision,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
isMultimodal: isAiModelMultimodal(model),
|
||||
isDefault: model.isDefault,
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
isSubModel: model.isSubModel,
|
||||
parentModelId: model.parentModelId,
|
||||
capabilities: model.capabilities, // 🆕 返回子模型的能力配置
|
||||
videoToolCapability: buildVideoToolCapability({
|
||||
providerType: provider.type,
|
||||
modelId: model.modelId,
|
||||
rawCapabilities: model.capabilities,
|
||||
imageGenConfig: (model.imageGenConfig || null) as
|
||||
| { videoDurationOptions?: number[] | null }
|
||||
| null,
|
||||
}),
|
||||
imageGenConfig: model.imageGenConfig,
|
||||
sortOrder: model.sortOrder ?? 0,
|
||||
...getAiModelFrontendAvailability(model),
|
||||
});
|
||||
const workflowImageModelById = new Map<string, ModelWithProvider>();
|
||||
modelsWithProvider.forEach((entry) => {
|
||||
const { model, provider } = entry;
|
||||
if (
|
||||
!isAiModelAgentAuto(model) &&
|
||||
isWorkflowImageGenerationModel({
|
||||
providerType: provider.type,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
})
|
||||
) {
|
||||
workflowImageModelById.set(model.id, entry);
|
||||
}
|
||||
});
|
||||
const visibleModelsWithProvider = sortModelsForDisplay(
|
||||
modelsWithProvider
|
||||
.filter(
|
||||
@ -78,39 +223,40 @@ export async function GET() {
|
||||
});
|
||||
|
||||
// 转换为前端需要的格式
|
||||
const models = visibleModelsWithProviderSorted.map(({ model, provider }) => ({
|
||||
id: model.id,
|
||||
modelId: model.modelId,
|
||||
displayName: model.displayName,
|
||||
avatarUrl: model.avatarUrl,
|
||||
description: model.description,
|
||||
supportsVision: model.supportsVision,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
isMultimodal: isAiModelMultimodal(model),
|
||||
isDefault: model.isDefault,
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
isSubModel: model.isSubModel,
|
||||
parentModelId: model.parentModelId,
|
||||
capabilities: model.capabilities, // 🆕 返回子模型的能力配置
|
||||
videoToolCapability: buildVideoToolCapability({
|
||||
providerType: provider.type,
|
||||
modelId: model.modelId,
|
||||
rawCapabilities: model.capabilities,
|
||||
}),
|
||||
imageGenConfig: model.imageGenConfig,
|
||||
sortOrder: model.sortOrder ?? 0,
|
||||
}));
|
||||
const models = visibleModelsWithProviderSorted.map(serializeModel);
|
||||
const selectableModels = models.filter((model) => model.selectable !== false);
|
||||
const preferredNanoBananaModel = models.find(
|
||||
(model) =>
|
||||
model.supportsImageGen &&
|
||||
!VIDEO_PROVIDER_TYPES.has(model.providerType || "") &&
|
||||
model.selectable !== false &&
|
||||
isImageGenerationModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
}) &&
|
||||
!isVideoModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
}) &&
|
||||
isNanoBananaProModel(model)
|
||||
);
|
||||
const preferredImageModel = models.find(
|
||||
(model) =>
|
||||
model.supportsImageGen &&
|
||||
!VIDEO_PROVIDER_TYPES.has(model.providerType || "")
|
||||
model.selectable !== false &&
|
||||
isImageGenerationModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
}) &&
|
||||
!isVideoModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
})
|
||||
);
|
||||
|
||||
const resolvedDefaultModelId =
|
||||
@ -118,15 +264,51 @@ export async function GET() {
|
||||
preferredImageModel?.id ??
|
||||
(defaultModel &&
|
||||
!isAiModelAgentAuto(defaultModel) &&
|
||||
models.some((model) => model.id === defaultModel.id)
|
||||
selectableModels.some((model) => model.id === defaultModel.id)
|
||||
? defaultModel.id
|
||||
: models.find((model) => model.isDefault)?.id)) ??
|
||||
models[0]?.id ??
|
||||
: selectableModels.find((model) => model.isDefault)?.id)) ??
|
||||
selectableModels[0]?.id ??
|
||||
null;
|
||||
const configuredAgentWorkflowModelId = String(
|
||||
siteSettings?.agentWorkflowDefaultModelId || ""
|
||||
).trim();
|
||||
const hasConfiguredAgentWorkflowModel = Boolean(
|
||||
configuredAgentWorkflowModelId &&
|
||||
configuredAgentWorkflowModelId !== "auto" &&
|
||||
configuredAgentWorkflowModelId !== "__auto"
|
||||
);
|
||||
const agentWorkflowDefaultModel = hasConfiguredAgentWorkflowModel
|
||||
? workflowImageModelById.get(configuredAgentWorkflowModelId) || null
|
||||
: null;
|
||||
const workflowUiConfig = normalizeAgentWorkflowUiConfig(
|
||||
agentWorkflowUiConfigSetting?.value
|
||||
);
|
||||
const workflowBusinessModeSettings = getAgentWorkflowBusinessModeSettings(
|
||||
workflowUiConfig
|
||||
);
|
||||
const configuredWorkflowModelIds = new Set<string>();
|
||||
if (agentWorkflowDefaultModel) {
|
||||
configuredWorkflowModelIds.add(agentWorkflowDefaultModel.model.id);
|
||||
}
|
||||
workflowBusinessModeSettings.modes.forEach((mode) => {
|
||||
const modelId = String(mode.defaultModelId || "").trim();
|
||||
if (modelId && workflowImageModelById.has(modelId)) {
|
||||
configuredWorkflowModelIds.add(modelId);
|
||||
}
|
||||
});
|
||||
const agentWorkflowConfiguredModels = Array.from(configuredWorkflowModelIds)
|
||||
.map((id) => workflowImageModelById.get(id))
|
||||
.filter((entry): entry is ModelWithProvider => Boolean(entry))
|
||||
.map(serializeModel);
|
||||
|
||||
return Response.json({
|
||||
models,
|
||||
defaultModelId: resolvedDefaultModelId,
|
||||
agentWorkflowConfiguredDefaultModelId: hasConfiguredAgentWorkflowModel
|
||||
? configuredAgentWorkflowModelId
|
||||
: null,
|
||||
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
|
||||
agentWorkflowConfiguredModels,
|
||||
agentModel: agentModelWithProvider
|
||||
? {
|
||||
id: agentModelWithProvider.model.id,
|
||||
|
||||
@ -30,6 +30,9 @@ export async function GET(request: Request) {
|
||||
providerType: provider.type,
|
||||
modelId: model.modelId,
|
||||
rawCapabilities: model.capabilities,
|
||||
imageGenConfig: (model.imageGenConfig || null) as
|
||||
| { videoDurationOptions?: number[] | null }
|
||||
| null,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
cancelVideoStoryboardTaskByUser,
|
||||
getVideoStoryboardTaskDetail,
|
||||
} from "@/lib/video-agent/task-store";
|
||||
import {
|
||||
requireStoryboardTaskAccess,
|
||||
StoryboardTaskAccessError,
|
||||
} from "@/lib/video-agent/user-task-access";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = paramsSchema.parse(await params);
|
||||
const detail = await requireStoryboardTaskAccess({
|
||||
taskId: parsed.taskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const status = String((detail.task as any).status || "").trim();
|
||||
if (status === "cancelled") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: detail.task,
|
||||
shots: detail.shots,
|
||||
});
|
||||
}
|
||||
|
||||
await cancelVideoStoryboardTaskByUser({
|
||||
taskId: parsed.taskId,
|
||||
error: "user_cancelled_via_api",
|
||||
});
|
||||
|
||||
const nextDetail = await getVideoStoryboardTaskDetail(parsed.taskId);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: nextDetail?.task || detail.task,
|
||||
shots: nextDetail?.shots || detail.shots,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StoryboardTaskAccessError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "取消任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
101
app/(chat)/api/video/storyboard/tasks/[taskId]/confirm/route.ts
Normal file
101
app/(chat)/api/video/storyboard/tasks/[taskId]/confirm/route.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { executeConfirmedStoryboardTask } from "@/lib/video-agent/execute-storyboard-task";
|
||||
import { getVideoStoryboardTaskDetail, markVideoStoryboardConfirmed } from "@/lib/video-agent/task-store";
|
||||
import {
|
||||
requireStoryboardTaskAccess,
|
||||
StoryboardTaskAccessError,
|
||||
} from "@/lib/video-agent/user-task-access";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = paramsSchema.parse(await params);
|
||||
const detail = await requireStoryboardTaskAccess({
|
||||
taskId: parsed.taskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const task = detail.task as any;
|
||||
const status = String(task.status || "").trim();
|
||||
const confirmationStatus = String(task.confirmationStatus || "").trim();
|
||||
|
||||
if (status === "cancelled") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "任务已取消,无法确认执行" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (["succeeded", "partial_success", "failed"].includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "任务已结束,无法再次确认执行" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (status === "running" && confirmationStatus === "confirmed") {
|
||||
return NextResponse.json(
|
||||
{ success: true, queued: false, task: detail.task, shots: detail.shots },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
await markVideoStoryboardConfirmed({
|
||||
taskId: parsed.taskId,
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
await executeConfirmedStoryboardTask({
|
||||
taskId: parsed.taskId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Storyboard Confirm API] Background execution failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const nextDetail = await getVideoStoryboardTaskDetail(parsed.taskId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
queued: true,
|
||||
task: nextDetail?.task || detail.task,
|
||||
shots: nextDetail?.shots || detail.shots,
|
||||
},
|
||||
{ status: 202 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof StoryboardTaskAccessError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "确认执行失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
56
app/(chat)/api/video/storyboard/tasks/[taskId]/route.ts
Normal file
56
app/(chat)/api/video/storyboard/tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
requireStoryboardTaskAccess,
|
||||
StoryboardTaskAccessError,
|
||||
} from "@/lib/video-agent/user-task-access";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = paramsSchema.parse(await params);
|
||||
const detail = await requireStoryboardTaskAccess({
|
||||
taskId: parsed.taskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: detail.task,
|
||||
shots: detail.shots,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StoryboardTaskAccessError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "查询任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { regenerateStoryboardShotHd } from "@/lib/video-agent/revise-storyboard-task";
|
||||
import {
|
||||
requireStoryboardTaskAccess,
|
||||
StoryboardTaskAccessError,
|
||||
} from "@/lib/video-agent/user-task-access";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
shotIndex: z.coerce.number().int().min(1).max(99),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string; shotIndex: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.parse(await params);
|
||||
const detail = await requireStoryboardTaskAccess({
|
||||
taskId: parsedParams.taskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const status = String((detail.task as any).status || "").trim();
|
||||
const confirmationStatus = String((detail.task as any).confirmationStatus || "").trim();
|
||||
if (status === "running" && confirmationStatus === "confirmed") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "任务正在执行视频,暂时不能重生高清预览" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await regenerateStoryboardShotHd({
|
||||
taskId: parsedParams.taskId,
|
||||
shotIndex: parsedParams.shotIndex,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: result.ok,
|
||||
message: result.message,
|
||||
task: result.detail?.task || detail.task,
|
||||
shots: result.detail?.shots || detail.shots,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StoryboardTaskAccessError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "重生高清预览失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
getVideoStoryboardTaskDetail,
|
||||
markVideoStoryboardDraftForRevision,
|
||||
updateVideoStoryboardShotPrompt,
|
||||
} from "@/lib/video-agent/task-store";
|
||||
import { updateStoryboardShotPromptAndRegenerateHd } from "@/lib/video-agent/revise-storyboard-task";
|
||||
import {
|
||||
requireStoryboardTaskAccess,
|
||||
StoryboardTaskAccessError,
|
||||
} from "@/lib/video-agent/user-task-access";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
shotIndex: z.coerce.number().int().min(1).max(99),
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
prompt: z.string().trim().min(1).max(4000),
|
||||
regenerateHd: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string; shotIndex: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.parse(await params);
|
||||
const parsedBody = bodySchema.parse(await request.json().catch(() => ({})));
|
||||
const detail = await requireStoryboardTaskAccess({
|
||||
taskId: parsedParams.taskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const status = String((detail.task as any).status || "").trim();
|
||||
const confirmationStatus = String((detail.task as any).confirmationStatus || "").trim();
|
||||
if (status === "running" && confirmationStatus === "confirmed") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "任务正在执行视频,暂时不能修改镜头" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedBody.regenerateHd) {
|
||||
const result = await updateStoryboardShotPromptAndRegenerateHd({
|
||||
taskId: parsedParams.taskId,
|
||||
shotIndex: parsedParams.shotIndex,
|
||||
prompt: parsedBody.prompt,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: result.ok,
|
||||
message: result.message,
|
||||
task: result.detail?.task || detail.task,
|
||||
shots: result.detail?.shots || detail.shots,
|
||||
});
|
||||
}
|
||||
|
||||
await markVideoStoryboardDraftForRevision({
|
||||
taskId: parsedParams.taskId,
|
||||
});
|
||||
await updateVideoStoryboardShotPrompt({
|
||||
taskId: parsedParams.taskId,
|
||||
shotIndex: parsedParams.shotIndex,
|
||||
prompt: parsedBody.prompt,
|
||||
});
|
||||
const nextDetail = await getVideoStoryboardTaskDetail(parsedParams.taskId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `已更新第 ${parsedParams.shotIndex} 镜头文案。`,
|
||||
task: nextDetail?.task || detail.task,
|
||||
shots: nextDetail?.shots || detail.shots,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StoryboardTaskAccessError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "更新镜头失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
// 默认进入最近画布,避免误触直接新建
|
||||
export default function CanvasRedirectPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const shouldCreateNew = searchParams.get("new") === "1";
|
||||
|
||||
const openCanvas = async () => {
|
||||
if (shouldCreateNew) {
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(`/canvas/${newId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/canvas", { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const items = result?.data || [];
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
const latest = sorted[0];
|
||||
if (latest?.id) {
|
||||
router.replace(`/canvas/${latest.id}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(`/canvas/${newId}`);
|
||||
};
|
||||
|
||||
void openCanvas();
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">正在打开画布...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,83 +1,5 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
|
||||
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
|
||||
import { convertToUIMessages } from "@/lib/utils";
|
||||
|
||||
export default function Page(props: { params: Promise<{ id: string }> }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<ChatPage params={props.params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const chat = await getChatById({ id });
|
||||
|
||||
if (!chat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
// 如果用户未登录,重定向到登录页面
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (chat.visibility === "private") {
|
||||
if (session.user.id !== chat.userId) {
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const messagesFromDb = await getMessagesByChatId({
|
||||
id,
|
||||
});
|
||||
|
||||
const uiMessages = convertToUIMessages(messagesFromDb);
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const chatModelFromCookie = cookieStore.get("chat-model");
|
||||
|
||||
if (!chatModelFromCookie) {
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
autoResume={true}
|
||||
id={chat.id}
|
||||
initialChatModel={DEFAULT_CHAT_MODEL}
|
||||
initialLastContext={chat.lastContext ?? undefined}
|
||||
initialMessages={uiMessages}
|
||||
initialVisibilityType={chat.visibility}
|
||||
isReadonly={session?.user?.id !== chat.userId}
|
||||
userType={session?.user?.type}
|
||||
/>
|
||||
<DataStreamHandler />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
autoResume={true}
|
||||
id={chat.id}
|
||||
initialChatModel={chatModelFromCookie.value}
|
||||
initialLastContext={chat.lastContext ?? undefined}
|
||||
initialMessages={uiMessages}
|
||||
initialVisibilityType={chat.visibility}
|
||||
isReadonly={session?.user?.id !== chat.userId}
|
||||
userType={session?.user?.type}
|
||||
/>
|
||||
<DataStreamHandler />
|
||||
</>
|
||||
);
|
||||
export default function Page() {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
12
app/(chat)/flow-board/layout.tsx
Normal file
12
app/(chat)/flow-board/layout.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export default function FlowBoardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
app/(chat)/flow-board/page.tsx
Normal file
21
app/(chat)/flow-board/page.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
|
||||
import { getSiteSettings } from "@/lib/db/queries";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "流程白板",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function FlowBoardPage() {
|
||||
const siteSettings = await getSiteSettings();
|
||||
|
||||
return (
|
||||
<FlowBoardClient
|
||||
siteLogo={siteSettings.siteLogo || null}
|
||||
siteLogoDark={siteSettings.siteLogoDark || null}
|
||||
siteName={siteSettings.siteName || "AI Chatbot"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
|
||||
type GalleryImage = {
|
||||
@ -30,6 +32,35 @@ type Pagination = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
function buildGalleryDownloadFilename(url: string, alt: string) {
|
||||
const rawUrl = String(url || "").trim();
|
||||
const rawAlt = String(alt || "").trim();
|
||||
const safeBase =
|
||||
rawAlt
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80) || "gallery-image";
|
||||
|
||||
let extension = "png";
|
||||
try {
|
||||
const pathname = new URL(rawUrl).pathname;
|
||||
const candidate = pathname.split("/").pop() || "";
|
||||
const inferred = candidate.split(".").pop()?.trim().toLowerCase();
|
||||
if (inferred && /^[a-z0-9]{2,5}$/.test(inferred)) {
|
||||
extension = inferred;
|
||||
}
|
||||
} catch {
|
||||
const candidate = rawUrl.split("/").pop()?.split("?")[0] || "";
|
||||
const inferred = candidate.split(".").pop()?.trim().toLowerCase();
|
||||
if (inferred && /^[a-z0-9]{2,5}$/.test(inferred)) {
|
||||
extension = inferred;
|
||||
}
|
||||
}
|
||||
|
||||
return `${safeBase}-${Date.now()}.${extension}`;
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState<GalleryImage[]>([]);
|
||||
@ -72,19 +103,41 @@ export default function GalleryPage() {
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, alt: string) => {
|
||||
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
|
||||
if (!originalUrl) {
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
toast.info("正在准备下载...");
|
||||
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = `${alt || "image"}-${Date.now()}.png`;
|
||||
link.download = buildGalleryDownloadFilename(originalUrl, alt);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
||||
toast.success("图片已开始下载");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
const fallbackLink = document.createElement("a");
|
||||
fallbackLink.href = originalUrl;
|
||||
fallbackLink.target = "_blank";
|
||||
fallbackLink.rel = "noopener noreferrer";
|
||||
document.body.appendChild(fallbackLink);
|
||||
fallbackLink.click();
|
||||
document.body.removeChild(fallbackLink);
|
||||
toast.error("下载失败,已为你打开原图,可直接另存为");
|
||||
}
|
||||
};
|
||||
|
||||
@ -485,13 +538,17 @@ export default function GalleryPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t bg-background">
|
||||
<div className="border-t bg-background p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{selectedImage.alt}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatBeijingTime(selectedImage.createdAt)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
模型 ID: {selectedImage.modelId || "未知模型"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatBeijingTime(selectedImage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleDownload(selectedImage.url, selectedImage.alt)}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { cookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import { Suspense } from "react";
|
||||
import { ChatShell } from "@/components/chat-shell";
|
||||
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||
@ -7,49 +5,42 @@ import { auth } from "../(auth)/auth";
|
||||
import {
|
||||
getNavLinks,
|
||||
getSiteSettings,
|
||||
getSystemSetting,
|
||||
getTutorialVideos,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<SidebarWrapper>{children}</SidebarWrapper>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
</>
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<SidebarWrapper>{children}</SidebarWrapper>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
const [session, cookieStore, siteSettings, navLinks, tutorialVideos, homeVariantSetting] =
|
||||
const [session, siteSettings, navLinks, tutorialVideos] =
|
||||
await Promise.all([
|
||||
auth(),
|
||||
cookies(),
|
||||
getSiteSettings(),
|
||||
getNavLinks(),
|
||||
getTutorialVideos(),
|
||||
getSystemSetting({ key: "home_variant" }).catch(() => undefined),
|
||||
]);
|
||||
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
||||
const homeVariant =
|
||||
homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
|
||||
const settingsHref =
|
||||
session?.user?.type === "admin"
|
||||
? "/settings"
|
||||
: session?.user?.type === "agent_admin"
|
||||
? "/settings/agent-workflow"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ChatShell
|
||||
user={session?.user}
|
||||
isCollapsed={isCollapsed}
|
||||
homeVariant={homeVariant}
|
||||
siteName={siteSettings.siteName}
|
||||
siteLogo={siteSettings.siteLogo || undefined}
|
||||
siteLogoDark={siteSettings.siteLogoDark || undefined}
|
||||
isAdmin={session?.user?.type === "admin"}
|
||||
isAdmin={session?.user?.type === "admin" || session?.user?.type === "agent_admin"}
|
||||
settingsHref={settingsHref}
|
||||
customLinks={navLinks.map((link) => ({
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
@ -71,6 +62,9 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
}))}
|
||||
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
|
||||
agentEnabled={(siteSettings as any).agentEnabled ?? true}
|
||||
videoCanvasEnabled={(siteSettings as any).videoCanvasEnabled ?? true}
|
||||
>
|
||||
{children}
|
||||
</ChatShell>
|
||||
|
||||
73
app/(chat)/model3d/page.tsx
Normal file
73
app/(chat)/model3d/page.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Model3dRedirectPageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getSearchParam(
|
||||
params: Record<string, string | string[] | undefined>,
|
||||
key: string
|
||||
) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function buildModel3dUrl(
|
||||
canvasId: string,
|
||||
params: Record<string, string | string[] | undefined>
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key === "new" || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => query.append(key, item));
|
||||
continue;
|
||||
}
|
||||
query.set(key, value);
|
||||
}
|
||||
const queryString = query.toString();
|
||||
return `/canvas/${canvasId}/model3d${queryString ? `?${queryString}` : ""}`;
|
||||
}
|
||||
|
||||
async function getLatestCanvasId(userId: string) {
|
||||
const [latestProject] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, userId),
|
||||
eq(canvasProject.workspaceType, "canvas")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasProject.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
return latestProject?.id || null;
|
||||
}
|
||||
|
||||
export default async function Model3dRedirectPage({
|
||||
searchParams,
|
||||
}: Model3dRedirectPageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?callbackUrl=%2Fmodel3d");
|
||||
}
|
||||
|
||||
const shouldCreateNew = getSearchParam(params, "new") === "1";
|
||||
if (shouldCreateNew) {
|
||||
redirect(buildModel3dUrl(randomUUID(), params));
|
||||
}
|
||||
|
||||
const latestCanvasId = await getLatestCanvasId(session.user.id).catch(() => null);
|
||||
redirect(buildModel3dUrl(latestCanvasId || randomUUID(), params));
|
||||
}
|
||||
@ -1,165 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||
import { LovartHome } from "@/components/home/lovart-home";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { auth } from "../(auth)/auth";
|
||||
import {
|
||||
getDefaultAiModel,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getUserById,
|
||||
getSiteSettings,
|
||||
getSystemSetting,
|
||||
} from "@/lib/db/queries";
|
||||
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
|
||||
|
||||
const VIDEO_PROVIDER_TYPES = new Set([
|
||||
"volcano-video",
|
||||
"doubao-video",
|
||||
"qianwen-video",
|
||||
"fal-video",
|
||||
"ruxa-video",
|
||||
]);
|
||||
|
||||
function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) {
|
||||
const displayName = String(model.displayName || "").toLowerCase();
|
||||
const modelId = String(model.modelId || "").toLowerCase();
|
||||
return (
|
||||
displayName.includes("nano banana pro") ||
|
||||
displayName.includes("nano-banana-pro") ||
|
||||
modelId.includes("nano-banana-pro") ||
|
||||
modelId.includes("nano_banana_pro") ||
|
||||
modelId.includes("nano-banana")
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<NewChatPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function NewChatPage() {
|
||||
const id = generateUUID();
|
||||
|
||||
const [session, cookieStore, siteSettings, homeVariantSetting, surpriseCategoriesSetting] =
|
||||
await Promise.all([
|
||||
auth(),
|
||||
cookies(),
|
||||
getSiteSettings(),
|
||||
getSystemSetting({ key: "home_variant" }).catch(() => undefined),
|
||||
getSystemSetting({ key: "home_surprise_categories" }).catch(() => undefined),
|
||||
]);
|
||||
|
||||
const homeVariant = homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
|
||||
const surpriseCategories = Array.isArray(surpriseCategoriesSetting?.value)
|
||||
? surpriseCategoriesSetting?.value
|
||||
.map((item: unknown) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
: ["全部", "精选"];
|
||||
|
||||
if (!session?.user && homeVariant !== "lovart") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (homeVariant === "lovart") {
|
||||
return (
|
||||
<LovartHome
|
||||
user={
|
||||
session?.user
|
||||
? {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
type: session.user.type,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isAuthenticated={Boolean(session?.user)}
|
||||
siteName={siteSettings.siteName || "AI Chatbot"}
|
||||
siteLogo={siteSettings.siteLogo || siteSettings.siteLogoDark || ""}
|
||||
surpriseCategories={surpriseCategories}
|
||||
heroPromoText={siteSettings.lovartPromoText}
|
||||
heroPromoActionText={siteSettings.lovartPromoActionText}
|
||||
heroTitleText={siteSettings.lovartHeroTitle}
|
||||
heroSubtitleText={siteSettings.lovartHeroSubtitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const modelIdFromCookie = cookieStore.get("chat-model");
|
||||
|
||||
let initialModelId = modelIdFromCookie?.value;
|
||||
const currentUser = session?.user?.id
|
||||
? await getUserById({ id: session.user.id }).catch(() => undefined)
|
||||
: undefined;
|
||||
const allowHiddenModels =
|
||||
session?.user?.type === "admin" || Boolean(currentUser?.isHighVip);
|
||||
const defaultModel = await getDefaultAiModel();
|
||||
const enabledModels = await getEnabledAiModelsWithProvider(false, true);
|
||||
const visibleModels = enabledModels.filter(
|
||||
({ model }) =>
|
||||
!isAiModelAgentAuto(model) &&
|
||||
(allowHiddenModels || !model.isHidden)
|
||||
);
|
||||
const preferredImageModels = visibleModels.filter(
|
||||
({ model, provider }) =>
|
||||
model.supportsImageGen &&
|
||||
!VIDEO_PROVIDER_TYPES.has(provider.type)
|
||||
);
|
||||
const nanoBananaModel = preferredImageModels.find(({ model }) =>
|
||||
isNanoBananaProModel(model)
|
||||
);
|
||||
const cookieModel = initialModelId
|
||||
? visibleModels.find(({ model }) => model.id === initialModelId)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
!initialModelId ||
|
||||
!cookieModel
|
||||
) {
|
||||
if (nanoBananaModel) {
|
||||
initialModelId = nanoBananaModel.model.id;
|
||||
} else if (
|
||||
defaultModel &&
|
||||
!isAiModelAgentAuto(defaultModel) &&
|
||||
(allowHiddenModels || !defaultModel.isHidden) &&
|
||||
preferredImageModels.some(({ model }) => model.id === defaultModel.id)
|
||||
) {
|
||||
initialModelId = defaultModel.id;
|
||||
} else if (preferredImageModels.length > 0) {
|
||||
const preferredDefaultImageModel = preferredImageModels.find(
|
||||
({ model }) => model.isDefault
|
||||
);
|
||||
initialModelId = (preferredDefaultImageModel || preferredImageModels[0]).model.id;
|
||||
} else if (
|
||||
defaultModel &&
|
||||
!isAiModelAgentAuto(defaultModel) &&
|
||||
(allowHiddenModels || !defaultModel.isHidden)
|
||||
) {
|
||||
initialModelId = defaultModel.id;
|
||||
} else if (visibleModels.length > 0) {
|
||||
initialModelId = visibleModels[0].model.id;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
autoResume={false}
|
||||
id={id}
|
||||
initialChatModel={initialModelId || ""}
|
||||
initialMessages={[]}
|
||||
initialVisibilityType="private"
|
||||
isReadonly={false}
|
||||
key={id}
|
||||
userType={session?.user?.type}
|
||||
/>
|
||||
<DataStreamHandler />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user