Compare commits
690 Commits
codex/main
...
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 |
20
.env.example
20
.env.example
@ -9,6 +9,13 @@
|
|||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
AUTH_SECRET=replace-with-random-secret
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
# Public application URL. Required for production and self-hosted Auth.js.
|
# Public application URL. Required for production and self-hosted Auth.js.
|
||||||
AUTH_URL=https://your-domain.com
|
AUTH_URL=https://your-domain.com
|
||||||
NEXTAUTH_URL=https://your-domain.com
|
NEXTAUTH_URL=https://your-domain.com
|
||||||
@ -98,12 +105,25 @@ WORLDLABS_API_KEY=
|
|||||||
WORLDLABS_BASE_URL=
|
WORLDLABS_BASE_URL=
|
||||||
WORLDLABS_IMPORT_TOKEN=
|
WORLDLABS_IMPORT_TOKEN=
|
||||||
WORLDLABS_KEY_POOL_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_TOKEN=
|
||||||
KITTYCAD_API_KEY=
|
KITTYCAD_API_KEY=
|
||||||
KITTYCAD_BASE_URL=https://api.zoo.dev
|
KITTYCAD_BASE_URL=https://api.zoo.dev
|
||||||
ZOO_API_TOKEN=
|
ZOO_API_TOKEN=
|
||||||
ZOO_BASE_URL=https://api.zoo.dev
|
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 / video callback.
|
||||||
RUXA_CALLBACK_URL=
|
RUXA_CALLBACK_URL=
|
||||||
RUXA_VIDEO_CALLBACK_URL=
|
RUXA_VIDEO_CALLBACK_URL=
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -11,6 +11,8 @@ coverage
|
|||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
.next/
|
.next/
|
||||||
|
.next-build/
|
||||||
|
.next-previous/
|
||||||
out/
|
out/
|
||||||
build
|
build
|
||||||
|
|
||||||
@ -87,5 +89,14 @@ checkpoints/
|
|||||||
# 第三方参考源码(不提交到git)
|
# 第三方参考源码(不提交到git)
|
||||||
reference-sources/
|
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)
|
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||||
!lib/db/migrations/*.sql
|
!lib/db/migrations/*.sql
|
||||||
|
|||||||
@ -144,18 +144,10 @@ log.error("Operation failed", error, { userId: "123" });
|
|||||||
#### 7. 修复 service 层错误吞没
|
#### 7. 修复 service 层错误吞没
|
||||||
**风险等级**: 🟡 中等风险
|
**风险等级**: 🟡 中等风险
|
||||||
**改动**:
|
**改动**:
|
||||||
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
|
|
||||||
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
||||||
|
|
||||||
**潜在问题**:
|
**潜在问题**:
|
||||||
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
||||||
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
|
|
||||||
|
|
||||||
**测试建议**:
|
|
||||||
```bash
|
|
||||||
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
|
|
||||||
# 检查日志是否包含完整错误堆栈
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,11 +11,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation";
|
import { ADMIN_SETTINGS_NAV_ITEMS } from "../_lib/settings-navigation";
|
||||||
import type { AdminSettingsNavItem } from "../_lib/types";
|
import type { AdminSettingsNavItem } from "../_lib/types";
|
||||||
|
|
||||||
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
|
|
||||||
|
|
||||||
function SettingsQuickAction({
|
function SettingsQuickAction({
|
||||||
activeTab,
|
activeTab,
|
||||||
item,
|
item,
|
||||||
@ -33,8 +31,8 @@ function SettingsQuickAction({
|
|||||||
? "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-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";
|
: "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
|
const layoutClassName = stretched
|
||||||
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4"
|
? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
|
||||||
: "h-9 shrink-0 gap-2 rounded-full border px-3.5";
|
: "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
|
||||||
|
|
||||||
if (item.href) {
|
if (item.href) {
|
||||||
return (
|
return (
|
||||||
@ -45,7 +43,7 @@ function SettingsQuickAction({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Link href={item.href}>
|
<Link href={item.href}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -59,7 +57,7 @@ function SettingsQuickAction({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@ -73,9 +71,13 @@ export function AdminSettingsNavigation({
|
|||||||
onTabChange: (value: string) => void;
|
onTabChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab);
|
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||||
|
(item) => item.value === activeTab
|
||||||
|
);
|
||||||
const handleSelectChange = (value: string) => {
|
const handleSelectChange = (value: string) => {
|
||||||
const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value);
|
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||||
|
(item) => item.value === value
|
||||||
|
);
|
||||||
if (nextItem?.href) {
|
if (nextItem?.href) {
|
||||||
router.push(nextItem.href);
|
router.push(nextItem.href);
|
||||||
return;
|
return;
|
||||||
@ -84,15 +86,18 @@ export function AdminSettingsNavigation({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm">
|
<nav
|
||||||
<div className="flex flex-col gap-2 md:hidden">
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<Select onValueChange={handleSelectChange} value={activeTab}>
|
<Select onValueChange={handleSelectChange} value={activeTab}>
|
||||||
<SelectTrigger className="h-9 flex-1 border-border/70 bg-background shadow-none">
|
<SelectTrigger className="h-8 flex-1 border-border/70 bg-background shadow-none">
|
||||||
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
|
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@ -102,7 +107,7 @@ export function AdminSettingsNavigation({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
className="h-9 rounded-full border-border/70 px-3 text-[13px]"
|
className="h-8 rounded-md border-border/70 px-2.5 text-xs"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@ -113,35 +118,22 @@ export function AdminSettingsNavigation({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
|
||||||
<SettingsQuickAction
|
|
||||||
activeTab={activeTab}
|
|
||||||
item={item}
|
|
||||||
key={item.value}
|
|
||||||
onTabChange={onTabChange}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
|
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
||||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
<SettingsQuickAction
|
||||||
<div className="min-w-[118px] flex-1" key={item.value}>
|
activeTab={activeTab}
|
||||||
<SettingsQuickAction
|
item={item}
|
||||||
activeTab={activeTab}
|
key={item.value}
|
||||||
item={item}
|
onTabChange={onTabChange}
|
||||||
onTabChange={onTabChange}
|
stretched
|
||||||
stretched
|
/>
|
||||||
/>
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
className="h-9 shrink-0 rounded-full border-border/70 px-3.5 text-[13px]"
|
className="h-8 min-w-0 rounded-md border-border/70 px-2.5 text-xs"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@ -151,6 +143,6 @@ export function AdminSettingsNavigation({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,22 +26,22 @@ export function LocalAiCommandCenter({
|
|||||||
onSetAllExpanded: (expanded: boolean) => void;
|
onSetAllExpanded: (expanded: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border bg-background">
|
<div className="overflow-hidden rounded-lg border bg-background">
|
||||||
<div className="border-b bg-muted/20 px-5 py-4">
|
<div className="border-b bg-muted/20 px-4 py-3">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<div className="font-semibold text-muted-foreground text-xs">
|
<div className="font-semibold text-[10px] text-muted-foreground">
|
||||||
SYSTEM INTEGRATIONS
|
SYSTEM INTEGRATIONS
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-semibold text-2xl tracking-normal">
|
<h2 className="font-semibold text-lg">
|
||||||
高级能力与外部服务
|
高级能力与外部服务
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-3xl text-muted-foreground text-sm leading-6">
|
<p className="max-w-3xl text-muted-foreground text-xs leading-5">
|
||||||
把商品策划、图像处理、3D/CAD、实时画布和开放 API
|
把商品策划、图像处理、3D/CAD、实时画布和开放 API
|
||||||
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
|
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<SettingStatusBadge tone="neutral">
|
<SettingStatusBadge tone="neutral">
|
||||||
已展开 {openSectionCount} 个模块
|
已展开 {openSectionCount} 个模块
|
||||||
</SettingStatusBadge>
|
</SettingStatusBadge>
|
||||||
@ -52,7 +52,7 @@ export function LocalAiCommandCenter({
|
|||||||
模型 {modelCount}
|
模型 {modelCount}
|
||||||
</SettingStatusBadge>
|
</SettingStatusBadge>
|
||||||
<Button
|
<Button
|
||||||
className="h-7 rounded-md px-2 text-xs"
|
className="h-6 rounded-md px-2 text-[11px]"
|
||||||
onClick={() => onSetAllExpanded(true)}
|
onClick={() => onSetAllExpanded(true)}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
@ -61,7 +61,7 @@ export function LocalAiCommandCenter({
|
|||||||
全部展开
|
全部展开
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="h-7 rounded-md px-2 text-xs"
|
className="h-6 rounded-md px-2 text-[11px]"
|
||||||
onClick={() => onSetAllExpanded(false)}
|
onClick={() => onSetAllExpanded(false)}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
@ -77,21 +77,21 @@ export function LocalAiCommandCenter({
|
|||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="group bg-background p-4 text-left transition-colors hover:bg-muted/30"
|
className="group bg-background p-3 text-left transition-colors hover:bg-muted/30"
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => onFocusSection(item.key)}
|
onClick={() => onFocusSection(item.key)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-semibold text-sm">
|
<div className="truncate font-semibold text-xs">
|
||||||
{item.title}
|
{item.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-muted-foreground text-xs">
|
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||||
{item.description}
|
{item.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -104,20 +104,20 @@ export function LocalAiCommandCenter({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t bg-muted/10 px-5 py-4">
|
<div className="border-t bg-muted/10 px-4 py-3">
|
||||||
<div className="grid gap-4 lg:grid-cols-4">
|
<div className="grid gap-3 lg:grid-cols-4">
|
||||||
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
|
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
|
||||||
<div className="space-y-2" key={group}>
|
<div className="space-y-1.5" key={group}>
|
||||||
<div className="font-semibold text-muted-foreground text-xs">
|
<div className="font-semibold text-[11px] text-muted-foreground">
|
||||||
{group}
|
{group}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{quickLinks
|
{quickLinks
|
||||||
.filter((item) => item.group === group)
|
.filter((item) => item.group === group)
|
||||||
.map((item) => (
|
.map((item) => (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs transition-colors hover:bg-background",
|
"inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] transition-colors hover:bg-background",
|
||||||
isSectionExpanded(item.key)
|
isSectionExpanded(item.key)
|
||||||
? "border-foreground/20 bg-background text-foreground"
|
? "border-foreground/20 bg-background text-foreground"
|
||||||
: "border-border bg-transparent text-muted-foreground"
|
: "border-border bg-transparent text-muted-foreground"
|
||||||
|
|||||||
@ -24,13 +24,13 @@ export function LocalAiGroupHeading({
|
|||||||
description: string;
|
description: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-muted/20 px-4 py-3">
|
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||||
<div className="font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.16em]">
|
<div className="font-semibold text-[10px] text-muted-foreground uppercase">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||||
<h3 className="font-semibold text-base text-foreground">{title}</h3>
|
<h3 className="font-semibold text-sm text-foreground">{title}</h3>
|
||||||
<p className="max-w-3xl text-muted-foreground text-sm">{description}</p>
|
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -45,7 +45,7 @@ export function LocalAiSectionSummary({
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
|
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
|
||||||
{item.label}
|
{item.label}
|
||||||
@ -65,16 +65,16 @@ export function LocalAiSectionToggleButton({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
className="h-8 shrink-0 gap-1.5 rounded-md px-2.5 text-xs"
|
className="h-7 shrink-0 gap-1 rounded-md px-2 text-[11px]"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ArrowUp className="h-3.5 w-3.5" />
|
<ArrowUp className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowDown className="h-3.5 w-3.5" />
|
<ArrowDown className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
{expanded ? "收起配置" : "展开配置"}
|
{expanded ? "收起配置" : "展开配置"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -100,9 +100,9 @@ export function LocalAiSectionShell({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card id={id}>
|
<Card id={id}>
|
||||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-xl">{title}</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
{summary}
|
{summary}
|
||||||
</div>
|
</div>
|
||||||
@ -110,7 +110,7 @@ export function LocalAiSectionShell({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent
|
<CardContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"local-ai-config-card-content space-y-6 pt-6",
|
"local-ai-config-card-content space-y-4 pt-4",
|
||||||
!expanded && "hidden"
|
!expanded && "hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -75,12 +75,16 @@ export function LocalAiImagePipelineSections({
|
|||||||
aiWashConfig,
|
aiWashConfig,
|
||||||
setAiWashConfig,
|
setAiWashConfig,
|
||||||
aiWashModeIconUploading,
|
aiWashModeIconUploading,
|
||||||
|
canvasLocalEditConfig,
|
||||||
|
setCanvasLocalEditConfig,
|
||||||
extendConfig,
|
extendConfig,
|
||||||
setExtendConfig,
|
setExtendConfig,
|
||||||
watermarkRemoveConfig,
|
watermarkRemoveConfig,
|
||||||
setWatermarkRemoveConfig,
|
setWatermarkRemoveConfig,
|
||||||
angleEditConfig,
|
angleEditConfig,
|
||||||
setAngleEditConfig,
|
setAngleEditConfig,
|
||||||
|
pointsSystemConfig,
|
||||||
|
updatePointsCostField,
|
||||||
layerSplitConfig,
|
layerSplitConfig,
|
||||||
setLayerSplitConfig,
|
setLayerSplitConfig,
|
||||||
isLocalAiSectionExpanded,
|
isLocalAiSectionExpanded,
|
||||||
@ -101,14 +105,17 @@ export function LocalAiImagePipelineSections({
|
|||||||
handleMoveEnhanceProPreset,
|
handleMoveEnhanceProPreset,
|
||||||
handleAiWashModeIconUpload,
|
handleAiWashModeIconUpload,
|
||||||
handleSaveAiWashConfig,
|
handleSaveAiWashConfig,
|
||||||
|
handleSaveCanvasLocalEditConfig,
|
||||||
handleSaveExtendConfig,
|
handleSaveExtendConfig,
|
||||||
handleSaveWatermarkRemoveConfig,
|
handleSaveWatermarkRemoveConfig,
|
||||||
handleSaveAngleEditConfig,
|
handleSaveAngleEditConfig,
|
||||||
handleSaveLayerSplitConfig,
|
handleSaveLayerSplitConfig,
|
||||||
}: LocalAiImagePipelineSectionsProps) {
|
}: LocalAiImagePipelineSectionsProps) {
|
||||||
const availableImageModels = (modelsData?.models || []).filter(
|
const availableImageModels = (modelsData?.models || []).filter(
|
||||||
(model) => model.supportsImageGen && model.isEnabled && !model.isHidden
|
(model) => model.supportsImageGen && model.isEnabled
|
||||||
);
|
);
|
||||||
|
const formatAiWashModelOptionLabel = (model: (typeof availableImageModels)[number]) =>
|
||||||
|
`${model.displayName || model.modelId}${model.isHidden ? " · 前台隐藏" : ""}`;
|
||||||
const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
|
const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
|
||||||
if (controlId === "creativity") {
|
if (controlId === "creativity") {
|
||||||
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
|
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
|
||||||
@ -152,12 +159,15 @@ export function LocalAiImagePipelineSections({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
rmbgConfig.apiKey ||
|
rmbgConfig.provider === "vip98k"
|
||||||
|
? "服务商 Key 已配置"
|
||||||
|
: rmbgConfig.apiKey ||
|
||||||
rmbgConfig.photoroomApiKey ||
|
rmbgConfig.photoroomApiKey ||
|
||||||
rmbgConfig.aosApiKey
|
rmbgConfig.aosApiKey
|
||||||
? "Key 已配置"
|
? "Key 已配置"
|
||||||
: "Key 待配置",
|
: "Key 待配置",
|
||||||
tone:
|
tone:
|
||||||
|
rmbgConfig.provider === "vip98k" ||
|
||||||
rmbgConfig.apiKey ||
|
rmbgConfig.apiKey ||
|
||||||
rmbgConfig.photoroomApiKey ||
|
rmbgConfig.photoroomApiKey ||
|
||||||
rmbgConfig.aosApiKey
|
rmbgConfig.aosApiKey
|
||||||
@ -172,7 +182,26 @@ export function LocalAiImagePipelineSections({
|
|||||||
{/* 服务提供商选择 */}
|
{/* 服务提供商选择 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>抠图服务提供商</Label>
|
<Label>抠图服务提供商</Label>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<div
|
||||||
|
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||||||
|
rmbgConfig.provider === "vip98k"
|
||||||
|
? "border-primary bg-accent ring-1 ring-primary"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
|
||||||
|
rmbgConfig.provider === "vip98k"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||||||
rmbgConfig.provider === "rmbg"
|
rmbgConfig.provider === "rmbg"
|
||||||
@ -259,6 +288,13 @@ export function LocalAiImagePipelineSections({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{rmbgConfig.provider === "vip98k" && (
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||||
|
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
|
||||||
|
VIP98K 服务商读取,不会在此重复保存。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* RMBG 配置 */}
|
{/* RMBG 配置 */}
|
||||||
{rmbgConfig.provider === "rmbg" && (
|
{rmbgConfig.provider === "rmbg" && (
|
||||||
<>
|
<>
|
||||||
@ -464,7 +500,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
|
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("enhance")}
|
expanded={isLocalAiSectionExpanded("enhance")}
|
||||||
@ -474,17 +510,21 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionSummary
|
<LocalAiSectionSummary
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: enhanceConfig.useBackupApi
|
label: enhanceConfig.provider === "vip98k"
|
||||||
|
? "VIP98K"
|
||||||
|
: enhanceConfig.useBackupApi
|
||||||
? "备用 Real-ESRGAN"
|
? "备用 Real-ESRGAN"
|
||||||
: "主 API",
|
: "主 API",
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
|
enhanceConfig.provider === "vip98k" ||
|
||||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||||
? "Key 已配置"
|
? "Key 已配置"
|
||||||
: "Key 待配置",
|
: "Key 待配置",
|
||||||
tone:
|
tone:
|
||||||
|
enhanceConfig.provider === "vip98k" ||
|
||||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||||
? "enabled"
|
? "enabled"
|
||||||
: "warning",
|
: "warning",
|
||||||
@ -494,6 +534,13 @@ export function LocalAiImagePipelineSections({
|
|||||||
}
|
}
|
||||||
title="变清晰API配置"
|
title="变清晰API配置"
|
||||||
>
|
>
|
||||||
|
{enhanceConfig.provider === "vip98k" ? (
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||||
|
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
|
||||||
|
VIP98K 服务商读取。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* API切换开关 */}
|
{/* API切换开关 */}
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@ -742,6 +789,8 @@ export function LocalAiImagePipelineSections({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button onClick={handleSaveEnhanceConfig}>
|
<Button onClick={handleSaveEnhanceConfig}>
|
||||||
<Save className="mr-2 h-4 w-4" /> 保存配置
|
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||||||
@ -1417,6 +1466,112 @@ export function LocalAiImagePipelineSections({
|
|||||||
</Button>
|
</Button>
|
||||||
</LocalAiSectionShell>
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
|
<LocalAiSectionShell
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
|
||||||
|
id="local-ai-section-canvas-local-edit"
|
||||||
|
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
|
||||||
|
summary={
|
||||||
|
<LocalAiSectionSummary
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: canvasLocalEditConfig.modelId
|
||||||
|
? "普通局部编辑已指定"
|
||||||
|
: "普通局部编辑继承默认",
|
||||||
|
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: canvasLocalEditConfig.scaleAdjustModelId
|
||||||
|
? "比例调整已指定"
|
||||||
|
: "比例调整兼容旧默认",
|
||||||
|
tone: canvasLocalEditConfig.scaleAdjustModelId
|
||||||
|
? "enabled"
|
||||||
|
: "neutral",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title="局部编辑模型配置"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCanvasLocalEditConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modelId: value === "__none__" ? "" : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={canvasLocalEditConfig.modelId || "__none__"}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="canvasLocalEditModel">
|
||||||
|
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
|
||||||
|
{availableImageModels.length > 0 ? (
|
||||||
|
availableImageModels.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.modelId}>
|
||||||
|
{formatAiWashModelOptionLabel(model)}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem disabled value="__empty__">
|
||||||
|
暂无可用生图模型,请先到 AI 模型里启用
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
适用于手动画框并填写提示词的局部编辑。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCanvasLocalEditConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
scaleAdjustModelId: value === "__none__" ? "" : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="canvasScaleAdjustModel">
|
||||||
|
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
|
||||||
|
{availableImageModels.length > 0 ? (
|
||||||
|
availableImageModels.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.modelId}>
|
||||||
|
{formatAiWashModelOptionLabel(model)}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem disabled value="__empty__">
|
||||||
|
暂无可用生图模型,请先到 AI 模型里启用
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSaveCanvasLocalEditConfig}>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
|
||||||
|
</Button>
|
||||||
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
@ -1447,6 +1602,12 @@ export function LocalAiImagePipelineSections({
|
|||||||
label: aiWashConfig.modeLabel || "AI洗图",
|
label: aiWashConfig.modeLabel || "AI洗图",
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: aiWashConfig.hideModelSelector
|
||||||
|
? "前台隐藏模型"
|
||||||
|
: "前台可选模型",
|
||||||
|
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@ -1633,6 +1794,23 @@ export function LocalAiImagePipelineSections({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">隐藏前台生图模型选择</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
开启后,AI洗图页面不再显示生图模型下拉框,直接使用后台预设或默认模型。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={aiWashConfig.hideModelSelector}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setAiWashConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
hideModelSelector: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-xl border p-4">
|
<div className="space-y-4 rounded-xl border p-4">
|
||||||
@ -1743,7 +1921,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
{availableImageModels.length > 0 ? (
|
{availableImageModels.length > 0 ? (
|
||||||
availableImageModels.map((model) => (
|
availableImageModels.map((model) => (
|
||||||
<SelectItem key={model.id} value={model.modelId}>
|
<SelectItem key={model.id} value={model.modelId}>
|
||||||
{model.displayName || model.modelId}
|
{formatAiWashModelOptionLabel(model)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@ -2105,7 +2283,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
key={model.id}
|
key={model.id}
|
||||||
value={model.modelId}
|
value={model.modelId}
|
||||||
>
|
>
|
||||||
{model.displayName || model.modelId}
|
{formatAiWashModelOptionLabel(model)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@ -2188,7 +2366,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
|
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("extend")}
|
expanded={isLocalAiSectionExpanded("extend")}
|
||||||
@ -2198,8 +2376,14 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionSummary
|
<LocalAiSectionSummary
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
|
label:
|
||||||
tone: extendConfig.apiKey ? "enabled" : "warning",
|
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
||||||
|
? "Key 已配置"
|
||||||
|
: "Key 待配置",
|
||||||
|
tone:
|
||||||
|
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
||||||
|
? "enabled"
|
||||||
|
: "warning",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
||||||
@ -2210,6 +2394,12 @@ export function LocalAiImagePipelineSections({
|
|||||||
}
|
}
|
||||||
title="图像拓展API配置"
|
title="图像拓展API配置"
|
||||||
>
|
>
|
||||||
|
{extendConfig.provider === "vip98k" ? (
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||||
|
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
|
||||||
|
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="extendApiKey">API密钥 *</Label>
|
<Label htmlFor="extendApiKey">API密钥 *</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -2226,6 +2416,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
|
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
|
||||||
@ -2383,6 +2574,13 @@ export function LocalAiImagePipelineSections({
|
|||||||
label: `${angleEditConfig.inferenceSteps} steps`,
|
label: `${angleEditConfig.inferenceSteps} steps`,
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
|
||||||
|
tone:
|
||||||
|
pointsSystemConfig.creditCostPerImageAngleEdit > 0
|
||||||
|
? "neutral"
|
||||||
|
: "disabled",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@ -2453,6 +2651,28 @@ export function LocalAiImagePipelineSections({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* 积分消耗 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="angleEditCreditCost">积分消耗</Label>
|
||||||
|
<Input
|
||||||
|
id="angleEditCreditCost"
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePointsCostField(
|
||||||
|
"creditCostPerImageAngleEdit",
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={pointsSystemConfig.creditCostPerImageAngleEdit}
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
每次成功生成多角度图片后扣除的积分;填写 0 表示免费。保存本配置时会同步保存到全站积分设置。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* LoRA 强度 */}
|
{/* LoRA 强度 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@ -372,9 +372,10 @@ export function LocalAiModel3dSections({
|
|||||||
<Input
|
<Input
|
||||||
id="worldlabsKeyPoolImportToken"
|
id="worldlabsKeyPoolImportToken"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWorldlabsKeyPoolConfig({
|
setWorldlabsKeyPoolConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
importToken: e.target.value,
|
importToken: e.target.value,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token"
|
placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token"
|
||||||
type="password"
|
type="password"
|
||||||
@ -915,8 +916,8 @@ export function LocalAiModel3dSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
|
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
|
||||||
服务,保存后写入数据库 AI 提供商配置。
|
提供商配置。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
||||||
@ -1002,7 +1003,7 @@ export function LocalAiModel3dSections({
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
|
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -590,7 +590,7 @@ export function LocalAiPlanningSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
|
为 AI详情生成 单独配置 Kimi Code 编程套餐
|
||||||
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -615,7 +615,7 @@ export function LocalAiPlanningSections({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title={<>Moonshot 官方 Kimi K2.6 配置</>}
|
title={<>Kimi Code 编程套餐配置</>}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="moonshotProviderName">提供商名称</Label>
|
<Label htmlFor="moonshotProviderName">提供商名称</Label>
|
||||||
@ -627,13 +627,13 @@ export function LocalAiPlanningSections({
|
|||||||
providerName: e.target.value,
|
providerName: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="Moonshot AI (Official)"
|
placeholder="Kimi Code 编程套餐"
|
||||||
value={moonshotDetailAgentConfig.providerName}
|
value={moonshotDetailAgentConfig.providerName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="moonshotBaseUrl">官方 API 地址</Label>
|
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
|
||||||
<Input
|
<Input
|
||||||
id="moonshotBaseUrl"
|
id="moonshotBaseUrl"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -646,7 +646,7 @@ export function LocalAiPlanningSections({
|
|||||||
value={moonshotDetailAgentConfig.baseUrl}
|
value={moonshotDetailAgentConfig.baseUrl}
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `
|
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
|
||||||
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -667,7 +667,7 @@ export function LocalAiPlanningSections({
|
|||||||
placeholder={
|
placeholder={
|
||||||
moonshotDetailAgentConfig.providerId
|
moonshotDetailAgentConfig.providerId
|
||||||
? "留空则不修改当前 Key"
|
? "留空则不修改当前 Key"
|
||||||
: "请输入 Moonshot 官方 API Key"
|
: "请输入 Kimi Code 控制台 API Key"
|
||||||
}
|
}
|
||||||
type="password"
|
type="password"
|
||||||
value={moonshotDetailAgentConfig.apiKey}
|
value={moonshotDetailAgentConfig.apiKey}
|
||||||
@ -675,7 +675,7 @@ export function LocalAiPlanningSections({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
|
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="moonshotModelId"
|
id="moonshotModelId"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -688,8 +688,8 @@ export function LocalAiPlanningSections({
|
|||||||
value={moonshotDetailAgentConfig.modelId}
|
value={moonshotDetailAgentConfig.modelId}
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
已按你提供的 Moonshot 官方文档默认填写
|
Kimi Code 编程套餐专用接口固定使用
|
||||||
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
|
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -745,7 +745,7 @@ export function LocalAiPlanningSections({
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
保存 Moonshot Kimi K2.6 配置
|
保存 Kimi Code 编程套餐配置
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</LocalAiSectionShell>
|
</LocalAiSectionShell>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function SettingStatusBadge({
|
|||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 rounded-md border px-2 font-medium text-[11px]",
|
"h-5 rounded-md border px-1.5 font-medium text-[10px]",
|
||||||
tone === "enabled" &&
|
tone === "enabled" &&
|
||||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||||
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",
|
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
|
|||||||
rmbg: false,
|
rmbg: false,
|
||||||
enhance: false,
|
enhance: false,
|
||||||
"enhance-pro": false,
|
"enhance-pro": false,
|
||||||
|
"canvas-local-edit": false,
|
||||||
"ai-wash": false,
|
"ai-wash": false,
|
||||||
extend: false,
|
extend: false,
|
||||||
"watermark-remove": false,
|
"watermark-remove": false,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Scissors,
|
Scissors,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldAlert,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -37,6 +38,12 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
|
|||||||
description: "用量、趋势与运营指标",
|
description: "用量、趋势与运营指标",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "access-security",
|
||||||
|
label: "访问安全",
|
||||||
|
description: "访问 IP、异常标注与登录来源",
|
||||||
|
icon: ShieldAlert,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -107,3 +114,7 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const ADMIN_SETTINGS_NAV_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap(
|
||||||
|
(group) => group.items
|
||||||
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
||||||
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
|
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 { AiWashConfig } from "@/lib/ai-wash/config";
|
||||||
import type {
|
import type {
|
||||||
EnhanceProKreaConfig,
|
EnhanceProKreaConfig,
|
||||||
@ -8,6 +9,7 @@ import type {
|
|||||||
EnhanceProPreset,
|
EnhanceProPreset,
|
||||||
} from "@/lib/enhance-pro/config";
|
} from "@/lib/enhance-pro/config";
|
||||||
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-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 { ProductPlanningApiConfig } from "@/lib/product-planning/config";
|
||||||
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
|
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
|
||||||
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
|
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
|
||||||
@ -85,7 +87,12 @@ export type AdminAiProvider = {
|
|||||||
| "bfl-image"
|
| "bfl-image"
|
||||||
| "ruxa-image"
|
| "ruxa-image"
|
||||||
| "fal-image"
|
| "fal-image"
|
||||||
|
| "snapgen-image"
|
||||||
| "openai-image"
|
| "openai-image"
|
||||||
|
| "bananaimage"
|
||||||
|
| "visionary"
|
||||||
|
| "atlascloud"
|
||||||
|
| "vip98k"
|
||||||
| "stepfun-image"
|
| "stepfun-image"
|
||||||
| "midjourney"
|
| "midjourney"
|
||||||
| "fal-video"
|
| "fal-video"
|
||||||
@ -167,6 +174,9 @@ export type AiProviderSettingsConfig = {
|
|||||||
|
|
||||||
export type WorldLabsKeyPoolConfig = {
|
export type WorldLabsKeyPoolConfig = {
|
||||||
importToken: string;
|
importToken: string;
|
||||||
|
providerName: string;
|
||||||
|
baseUrl: string;
|
||||||
|
isEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & {
|
export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & {
|
||||||
@ -239,7 +249,7 @@ export type EnhanceProKeyRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type RmbgConfig = {
|
export type RmbgConfig = {
|
||||||
provider: "rmbg" | "photoroom" | "aoscdn";
|
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
photoroomApiKey: string;
|
photoroomApiKey: string;
|
||||||
@ -251,6 +261,7 @@ export type RmbgConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type EnhanceConfig = {
|
export type EnhanceConfig = {
|
||||||
|
provider: "vip98k" | "legacy";
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
type: string;
|
type: string;
|
||||||
@ -263,6 +274,7 @@ export type EnhanceConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExtendConfig = {
|
export type ExtendConfig = {
|
||||||
|
provider: "vip98k" | "dashscope";
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
xScale: string;
|
xScale: string;
|
||||||
yScale: string;
|
yScale: string;
|
||||||
@ -322,12 +334,19 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
|||||||
aiWashConfig: AiWashConfig;
|
aiWashConfig: AiWashConfig;
|
||||||
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
||||||
aiWashModeIconUploading: boolean;
|
aiWashModeIconUploading: boolean;
|
||||||
|
canvasLocalEditConfig: CanvasLocalEditConfig;
|
||||||
|
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
|
||||||
extendConfig: ExtendConfig;
|
extendConfig: ExtendConfig;
|
||||||
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
||||||
watermarkRemoveConfig: WatermarkRemoveConfig;
|
watermarkRemoveConfig: WatermarkRemoveConfig;
|
||||||
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
|
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
|
||||||
angleEditConfig: AngleEditConfig;
|
angleEditConfig: AngleEditConfig;
|
||||||
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
|
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
|
||||||
|
pointsSystemConfig: PointsSystemConfig;
|
||||||
|
updatePointsCostField: (
|
||||||
|
key: keyof PointsSystemConfig,
|
||||||
|
value: unknown
|
||||||
|
) => void;
|
||||||
layerSplitConfig: LayerSplitConfig;
|
layerSplitConfig: LayerSplitConfig;
|
||||||
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
|
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
|
||||||
renderEnhanceProPresetEditor: (
|
renderEnhanceProPresetEditor: (
|
||||||
@ -359,6 +378,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
|||||||
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
||||||
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
||||||
handleSaveAiWashConfig: AsyncAction;
|
handleSaveAiWashConfig: AsyncAction;
|
||||||
|
handleSaveCanvasLocalEditConfig: AsyncAction;
|
||||||
handleSaveExtendConfig: AsyncAction;
|
handleSaveExtendConfig: AsyncAction;
|
||||||
handleSaveWatermarkRemoveConfig: AsyncAction;
|
handleSaveWatermarkRemoveConfig: AsyncAction;
|
||||||
handleSaveAngleEditConfig: AsyncAction;
|
handleSaveAngleEditConfig: AsyncAction;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1926,7 +1926,7 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record<AgentWorkflowSettingsField, s
|
|||||||
agentWorkflowDefaultModelId: "风格化专用默认模型",
|
agentWorkflowDefaultModelId: "风格化专用默认模型",
|
||||||
agentPresetLibraryConfig: "预设库",
|
agentPresetLibraryConfig: "预设库",
|
||||||
agentWorkflowUiConfig: "工作流步骤",
|
agentWorkflowUiConfig: "工作流步骤",
|
||||||
agentWorkflowMaterialApiConfig: "素材搜索接口",
|
agentWorkflowMaterialApiConfig: "第三方图片素材",
|
||||||
agentWorkflowGoodsInfoApiConfig: "商品信息接口",
|
agentWorkflowGoodsInfoApiConfig: "商品信息接口",
|
||||||
agentWorkflowBrandModelConfig: "品牌模特",
|
agentWorkflowBrandModelConfig: "品牌模特",
|
||||||
agentWorkflowShowcaseConfig: "页面展示",
|
agentWorkflowShowcaseConfig: "页面展示",
|
||||||
@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="settings-workspace container mx-auto p-4">
|
||||||
<div className="flex h-[60vh] items-center justify-center">
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
<div className="settings-workspace min-h-screen bg-muted/10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container mx-auto px-6 py-4">
|
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-violet-500/20">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||||
<Sparkles className="h-5 w-5 text-white" />
|
<Sparkles className="h-4 w-4 text-background" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
|
<h1 className="text-base font-semibold">风格化工作流配置</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
|
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -6006,7 +6006,6 @@ export function AgentWorkflowSettingsClient({
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
|
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@ -6142,8 +6141,8 @@ export function AgentWorkflowSettingsClient({
|
|||||||
|
|
||||||
{/* Stats Bar */}
|
{/* Stats Bar */}
|
||||||
<div className="border-b bg-muted/30">
|
<div className="border-b bg-muted/30">
|
||||||
<div className="container mx-auto px-6 py-3">
|
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||||
<div className="flex flex-wrap items-center gap-6">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="font-mono">
|
<Badge variant="secondary" className="font-mono">
|
||||||
{enabledStepsCount}
|
{enabledStepsCount}
|
||||||
@ -6197,8 +6196,8 @@ export function AgentWorkflowSettingsClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto p-6">
|
<main className="container mx-auto p-4 sm:px-5">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||||
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
|
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
|
||||||
<TabsTrigger value="showcase" className="gap-2">
|
<TabsTrigger value="showcase" className="gap-2">
|
||||||
<MonitorPlay className="h-4 w-4" />
|
<MonitorPlay className="h-4 w-4" />
|
||||||
@ -6859,17 +6858,19 @@ export function AgentWorkflowSettingsClient({
|
|||||||
<Search className="h-4 w-4 text-emerald-600" />
|
<Search className="h-4 w-4 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">素材搜索接口</CardTitle>
|
<CardTitle className="text-base">第三方图片素材配置</CardTitle>
|
||||||
<CardDescription>配置白底图、详情参考图、SKU 参考图共用接口</CardDescription>
|
<CardDescription>
|
||||||
|
配置白底图、任务-详情整套预览、详情参考图和 SKU 参考图共用接口
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">启用素材搜索</div>
|
<div className="text-sm font-medium">启用第三方图片素材</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
开启后支持搜索白底素材图、详情参考图和 SKU 参考图
|
开启后前台和预留接口都可以查询标准模型白底图与任务-详情整套素材
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@ -6890,6 +6891,22 @@ export function AgentWorkflowSettingsClient({
|
|||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">统一接口路径</Label>
|
||||||
|
<Input
|
||||||
|
value={materialApiConfig.endpointPath || "/standard-model-white-background"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialApiConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
endpointPath: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="/standard-model-white-background"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
按文档默认调用 /api/external/standard-model-white-background;也支持填写完整 URL。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">API Key</Label>
|
<Label className="text-xs">API Key</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -6902,6 +6919,104 @@ export function AgentWorkflowSettingsClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">标准模型白底图</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
对应 resourceType=white-background
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={materialApiConfig.whiteBackgroundEnabled !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setMaterialApiConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
whiteBackgroundEnabled: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">任务-详情整套预览</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
对应 resourceType=task-detail
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={materialApiConfig.taskDetailEnabled !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setMaterialApiConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
taskDetailEnabled: checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">任务详情资源主域名</Label>
|
||||||
|
<Input
|
||||||
|
value={materialApiConfig.resourceBaseUrl || "https://s3.jiansun.vip"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialApiConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
resourceBaseUrl: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="https://s3.jiansun.vip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">备用资源域名</Label>
|
||||||
|
<Input
|
||||||
|
value={materialApiConfig.fallbackResourceBaseUrl || "https://s3.yydd.ai"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialApiConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fallbackResourceBaseUrl: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="https://s3.yydd.ai"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">请求超时 ms</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={3000}
|
||||||
|
max={120000}
|
||||||
|
step={1000}
|
||||||
|
value={materialApiConfig.requestTimeoutMs || 30000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMaterialApiConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
requestTimeoutMs: Number(e.target.value) || 30000,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
已预留前端接口:
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">
|
||||||
|
/api/third-party-image-materials/search
|
||||||
|
</code>
|
||||||
|
,支持
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">
|
||||||
|
resourceType=white-background|task-detail
|
||||||
|
</code>
|
||||||
|
与
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">
|
||||||
|
sku/spu
|
||||||
|
</code>
|
||||||
|
查询。
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||||
|
|||||||
@ -2430,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
<div className="settings-workspace min-h-screen bg-muted/10">
|
||||||
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container mx-auto px-6 py-4">
|
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-600 shadow-lg shadow-orange-500/20">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||||
<Sparkles className="h-5 w-5 text-white" />
|
<Sparkles className="h-4 w-4 text-background" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1>
|
<h1 className="text-base font-semibold">PSD 模板配置</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
|
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -2457,7 +2457,6 @@ export function PsdTemplatesSettingsClient({
|
|||||||
onClick={handleSaveTemplate}
|
onClick={handleSaveTemplate}
|
||||||
disabled={!draftTemplate || isSaving}
|
disabled={!draftTemplate || isSaving}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
|
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@ -2472,8 +2471,8 @@ export function PsdTemplatesSettingsClient({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="border-b bg-muted/30">
|
<div className="border-b bg-muted/30">
|
||||||
<div className="container mx-auto px-6 py-3">
|
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Badge variant="secondary" className="font-mono">
|
<Badge variant="secondary" className="font-mono">
|
||||||
{templates.length}
|
{templates.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -2509,8 +2508,8 @@ export function PsdTemplatesSettingsClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto px-6 py-6">
|
<div className="container mx-auto px-4 py-4 sm:px-5">
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">品类标签</CardTitle>
|
<CardTitle className="text-base">品类标签</CardTitle>
|
||||||
@ -2581,7 +2580,10 @@ export function PsdTemplatesSettingsClient({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">上传新模板</CardTitle>
|
<CardTitle className="text-base">上传新模板</CardTitle>
|
||||||
<CardDescription>支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。</CardDescription>
|
<CardDescription>
|
||||||
|
支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。复杂 PSB
|
||||||
|
如提示不兼容,请先在 Photoshop 中另存为 PSD 后上传。
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||||||
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -25,6 +25,7 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { fetcher } from "@/lib/utils";
|
import { fetcher } from "@/lib/utils";
|
||||||
|
import { AdminSettingsNavigation } from "../_components/admin-settings-navigation";
|
||||||
import {
|
import {
|
||||||
STYLE_PRO_CONFIG_KEY,
|
STYLE_PRO_CONFIG_KEY,
|
||||||
defaultStyleProConfig,
|
defaultStyleProConfig,
|
||||||
@ -322,6 +323,7 @@ function CustomFieldEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StyleProSettingsClient() {
|
export function StyleProSettingsClient() {
|
||||||
|
const router = useRouter();
|
||||||
const { data, mutate } = useSWR<SettingsResponse>(
|
const { data, mutate } = useSWR<SettingsResponse>(
|
||||||
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
||||||
fetcher
|
fetcher
|
||||||
@ -561,19 +563,17 @@ export function StyleProSettingsClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
|
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<AdminSettingsNavigation
|
||||||
|
activeTab="style-pro"
|
||||||
|
onTabChange={(value) => router.push(`/settings?tab=${value}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="mt-6 flex w-full flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3">
|
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
|
||||||
<Button asChild variant="outline">
|
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
|
||||||
<Link href="/settings">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
返回设置
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
|
|
||||||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
|
||||||
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -592,7 +592,7 @@ export function StyleProSettingsClient() {
|
|||||||
{saving ? "保存中" : "保存配置"}
|
{saving ? "保存中" : "保存配置"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -1193,6 +1193,7 @@ export function StyleProSettingsClient() {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -12,6 +14,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -30,6 +33,9 @@ import {
|
|||||||
getVideoCanvasFunctionBillingCopy,
|
getVideoCanvasFunctionBillingCopy,
|
||||||
type VideoCanvasFunctionConfig,
|
type VideoCanvasFunctionConfig,
|
||||||
type VideoCanvasFunctionDefinition,
|
type VideoCanvasFunctionDefinition,
|
||||||
|
type VideoCanvasEffectPreset,
|
||||||
|
type VideoCanvasMotionPreset,
|
||||||
|
type VideoCanvasRolePreset,
|
||||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
defaultVideoCanvasFunctionConfig,
|
defaultVideoCanvasFunctionConfig,
|
||||||
normalizeVideoCanvasFunctionConfig,
|
normalizeVideoCanvasFunctionConfig,
|
||||||
@ -199,8 +205,9 @@ export function VideoCanvasSettingsClient() {
|
|||||||
const functions = useMemo(
|
const functions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(config.functions).sort((a, b) => {
|
Object.values(config.functions).sort((a, b) => {
|
||||||
const kindCompare = a.kind.localeCompare(b.kind);
|
const compare = (left: string, right: string) =>
|
||||||
return kindCompare || a.label.localeCompare(b.label);
|
left === right ? 0 : left < right ? -1 : 1;
|
||||||
|
return compare(a.kind, b.kind) || compare(a.label, b.label);
|
||||||
}),
|
}),
|
||||||
[config.functions],
|
[config.functions],
|
||||||
);
|
);
|
||||||
@ -225,6 +232,135 @@ export function VideoCanvasSettingsClient() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@ -255,11 +391,17 @@ export function VideoCanvasSettingsClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
|
<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-4 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1>
|
<Button asChild className="mb-2" size="sm" variant="outline">
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -321,6 +463,354 @@ export function VideoCanvasSettingsClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
|
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
requiresSingleActiveSession,
|
requiresSingleActiveSession,
|
||||||
revokeSingleActiveSession,
|
revokeSingleActiveSession,
|
||||||
} from "@/lib/auth/session-control";
|
} from "@/lib/auth/session-control";
|
||||||
|
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||||
|
|
||||||
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
||||||
|
|
||||||
@ -200,7 +201,10 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextAuth = NextAuth(async () => {
|
const nextAuth = NextAuth(async () => {
|
||||||
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
|
const runtimeAuthSettings =
|
||||||
|
process.env.CANVAS_E2E_LOCAL === "true"
|
||||||
|
? null
|
||||||
|
: await getAuthSettings().catch(() => null as any);
|
||||||
const resolvedAuthUrl =
|
const resolvedAuthUrl =
|
||||||
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
||||||
const googleClientId =
|
const googleClientId =
|
||||||
@ -283,6 +287,32 @@ const nextAuth = NextAuth(async () => {
|
|||||||
delete token.sessionKey;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
session({ session, token }) {
|
session({ session, token }) {
|
||||||
|
|||||||
@ -501,11 +501,6 @@ export default function Page() {
|
|||||||
const displayLogo = mounted
|
const displayLogo = mounted
|
||||||
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
||||||
: siteLogo;
|
: siteLogo;
|
||||||
const shouldInvertLogo =
|
|
||||||
mounted &&
|
|
||||||
resolvedTheme === "dark" &&
|
|
||||||
!siteLogoDark?.trim() &&
|
|
||||||
Boolean(siteLogo?.trim());
|
|
||||||
const hasThirdPartyLogin =
|
const hasThirdPartyLogin =
|
||||||
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
||||||
(isGoogleEnabled && isGoogleConfigured);
|
(isGoogleEnabled && isGoogleConfigured);
|
||||||
@ -545,7 +540,7 @@ export default function Page() {
|
|||||||
<img
|
<img
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt={siteName}
|
alt={siteName}
|
||||||
className={`mb-2 h-12 w-auto object-contain ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
|
className="mb-2 h-12 w-auto object-contain"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
|
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
|
||||||
|
|||||||
@ -1,7 +1,44 @@
|
|||||||
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
|
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 dynamic = "force-dynamic";
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "HiCAD",
|
||||||
|
};
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export default function AiCadPage() {
|
export default async function AiCadPage() {
|
||||||
return <AiCadWorkbench />;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,8 @@ export function normalizeAgentReportHeadingLine(line: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const knownTitles = [
|
const knownTitles = [
|
||||||
|
"意图分析",
|
||||||
|
"任务规划",
|
||||||
"需求理解",
|
"需求理解",
|
||||||
"图片分析",
|
"图片分析",
|
||||||
"执行计划",
|
"执行计划",
|
||||||
|
|||||||
@ -1286,6 +1286,28 @@ function applyDetailReplacementScenePlanGuards(
|
|||||||
next.routeReason = appendUniqueInstruction(next.routeReason, params.visualProfile.reason, 220);
|
next.routeReason = appendUniqueInstruction(next.routeReason, params.visualProfile.reason, 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.hasModelImage &&
|
||||||
|
!isProductOnly &&
|
||||||
|
!next.useModelImage &&
|
||||||
|
(next.peoplePolicy !== "none" ||
|
||||||
|
next.generationRoute === "people_relation_locked" ||
|
||||||
|
next.pageKind === "people_scene" ||
|
||||||
|
next.pageKind === "action_poster")
|
||||||
|
) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
useModelImage: true,
|
||||||
|
modelImageReason:
|
||||||
|
next.modelImageReason ||
|
||||||
|
"本屏已判定为人物/动作详情页,必须使用用户提供的模特图锁定人物身份。",
|
||||||
|
generationPrompt: appendUniqueInstruction(
|
||||||
|
next.generationPrompt,
|
||||||
|
"本屏出现人物时必须使用用户提供的同一模特:脸型五官、发型发色、体型肤色和穿搭风格保持一致;不得换成其他人物。",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1723,19 +1745,31 @@ export function normalizeAgentModeReferences(
|
|||||||
): AgentReplacementModePayload["references"] {
|
): AgentReplacementModePayload["references"] {
|
||||||
if (!payload?.references?.length) return [];
|
if (!payload?.references?.length) return [];
|
||||||
return payload.references
|
return payload.references
|
||||||
.map((item) => ({
|
.map((item) => {
|
||||||
id: String(item.id || "").trim(),
|
const rerunReferenceImages = Array.isArray(item.rerunReferenceImages)
|
||||||
label: String(item.label || "").trim() || "参考图",
|
? item.rerunReferenceImages
|
||||||
imageUrl: String(item.imageUrl || "").trim(),
|
.map((image) => normalizeAgentModeSourceImage(image))
|
||||||
candidateId: String(item.candidateId || "").trim() || null,
|
.filter(
|
||||||
sortOrder: Number.isFinite(Number(item.sortOrder))
|
(image): image is NonNullable<ReturnType<typeof normalizeAgentModeSourceImage>> =>
|
||||||
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
|
Boolean(image)
|
||||||
: 0,
|
)
|
||||||
passthrough: item.passthrough === true,
|
.slice(0, 6)
|
||||||
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
|
: [];
|
||||||
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
|
return {
|
||||||
: null,
|
id: String(item.id || "").trim(),
|
||||||
}))
|
label: String(item.label || "").trim() || "参考图",
|
||||||
|
imageUrl: String(item.imageUrl || "").trim(),
|
||||||
|
candidateId: String(item.candidateId || "").trim() || null,
|
||||||
|
sortOrder: Number.isFinite(Number(item.sortOrder))
|
||||||
|
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
|
||||||
|
: 0,
|
||||||
|
passthrough: item.passthrough === true,
|
||||||
|
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
|
||||||
|
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
|
||||||
|
: null,
|
||||||
|
...(rerunReferenceImages.length > 0 ? { rerunReferenceImages } : {}),
|
||||||
|
};
|
||||||
|
})
|
||||||
.filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl))
|
.filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl))
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder);
|
.sort((left, right) => left.sortOrder - right.sortOrder);
|
||||||
}
|
}
|
||||||
@ -2210,7 +2244,7 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
isDetailReplacement
|
isDetailReplacement
|
||||||
? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。"
|
? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。"
|
||||||
: "",
|
: "",
|
||||||
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。",
|
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级;如果画面里有产品主体/展示区,它也决定产品的拍摄角度、朝向、透视、可见面数量、占比和标注指向。",
|
||||||
isProductOnlyDetailReplacement
|
isProductOnlyDetailReplacement
|
||||||
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
|
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
|
||||||
: isDetailReplacement
|
: isDetailReplacement
|
||||||
@ -2248,12 +2282,12 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
|
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
|
||||||
: "",
|
: "",
|
||||||
isDetailReplacement
|
isDetailReplacement
|
||||||
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。"
|
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
|
||||||
: "",
|
: "",
|
||||||
isProductOnlyDetailReplacement
|
isProductOnlyDetailReplacement
|
||||||
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
|
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
|
||||||
: isDetailReplacement
|
: isDetailReplacement
|
||||||
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。"
|
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。只要参考图有产品主体,generationPrompt 必须明确产品拍摄角度、朝向、透视和标注指向以参考图为准。"
|
||||||
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
|
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
|
||||||
isProductOnlyDetailReplacement
|
isProductOnlyDetailReplacement
|
||||||
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
|
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
|
||||||
@ -2421,6 +2455,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
hasModelImage: boolean;
|
hasModelImage: boolean;
|
||||||
productImageCount: number;
|
productImageCount: number;
|
||||||
originalProductImageCount: number;
|
originalProductImageCount: number;
|
||||||
|
rerunReferenceCount?: number;
|
||||||
templateReferenceCount: number;
|
templateReferenceCount: number;
|
||||||
pairedProductIndex?: number;
|
pairedProductIndex?: number;
|
||||||
scenePlan?: ReplacementScenePlan | null;
|
scenePlan?: ReplacementScenePlan | null;
|
||||||
@ -2455,14 +2490,14 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
? `图片${nextImageIndex}`
|
? `图片${nextImageIndex}`
|
||||||
: `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`;
|
: `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`;
|
||||||
imageOrderLines.push(
|
imageOrderLines.push(
|
||||||
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略。`
|
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略;如结果中需要生成商品主体,商品拍摄角度、朝向、透视和画面占比必须以最后一张参考图为准,不得直接沿用这些上传图的拍摄角度。`
|
||||||
);
|
);
|
||||||
nextImageIndex += params.productImageCount;
|
nextImageIndex += params.productImageCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.hasModelImage) {
|
if (params.hasModelImage) {
|
||||||
imageOrderLines.push(
|
imageOrderLines.push(
|
||||||
`图片${nextImageIndex}:模特图,仅在当前参考图本身包含人物展示时参考;如果参考图是多人,只把这张模特图作为主人物身份/气质参考,其余人物按参考图的人数、关系和风格补齐。`
|
`图片${nextImageIndex}:用户提供的固定模特图,是人物身份锁定图;当前屏需要人物时,必须复用这张图里的同一人物(脸型五官、发型发色、体型肤色、穿搭风格保持一致)。如果参考图是多人,只把这张模特图作为主人物身份参考,其余人物按参考图的人数、关系和风格补齐。`
|
||||||
);
|
);
|
||||||
nextImageIndex += 1;
|
nextImageIndex += 1;
|
||||||
}
|
}
|
||||||
@ -2478,8 +2513,20 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
nextImageIndex += params.templateReferenceCount;
|
nextImageIndex += params.templateReferenceCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rerunReferenceCount = Math.max(0, Math.round(Number(params.rerunReferenceCount || 0)));
|
||||||
|
if (rerunReferenceCount > 0) {
|
||||||
|
const rerunRangeLabel =
|
||||||
|
rerunReferenceCount === 1
|
||||||
|
? `图片${nextImageIndex}`
|
||||||
|
: `图片${nextImageIndex}-${nextImageIndex + rerunReferenceCount - 1}`;
|
||||||
|
imageOrderLines.push(
|
||||||
|
`${rerunRangeLabel}:本次重新生图的补充参考图,只用于理解用户新增的风格、构图、细节或局部调整方向;不得覆盖最终参考图底板的版式、文字层级和页面结构。`
|
||||||
|
);
|
||||||
|
nextImageIndex += rerunReferenceCount;
|
||||||
|
}
|
||||||
|
|
||||||
imageOrderLines.push(
|
imageOrderLines.push(
|
||||||
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构。`
|
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构;如果它包含产品主体/展示区,它同时决定产品姿态、朝向、透视、可见面数量、占比和标注指向。`
|
||||||
);
|
);
|
||||||
const marketplaceBrandRemovalLines =
|
const marketplaceBrandRemovalLines =
|
||||||
params.modeType === "detail_replacement" && params.marketplaceProductDetailReference
|
params.modeType === "detail_replacement" && params.marketplaceProductDetailReference
|
||||||
@ -2489,6 +2536,12 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
"如果参考图标题、标签或画面中出现可识别的外部品牌字样,不要保留该品牌名,不要换成相似字,不要生成伪 Logo;需要文字时只保留非品牌卖点语义或改成无品牌通用描述。",
|
"如果参考图标题、标签或画面中出现可识别的外部品牌字样,不要保留该品牌名,不要换成相似字,不要生成伪 Logo;需要文字时只保留非品牌卖点语义或改成无品牌通用描述。",
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
const productAngleLockLines = [
|
||||||
|
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。",
|
||||||
|
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。",
|
||||||
|
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
|
||||||
|
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
|
||||||
|
];
|
||||||
const normalizedBasePrompt = String(params.basePrompt || "").trim();
|
const normalizedBasePrompt = String(params.basePrompt || "").trim();
|
||||||
const promptPackInstructionLines =
|
const promptPackInstructionLines =
|
||||||
params.modeType === "detail_replacement" &&
|
params.modeType === "detail_replacement" &&
|
||||||
@ -2555,7 +2608,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
params.scenePlan.useModelImage
|
params.scenePlan.useModelImage
|
||||||
? params.scenePlan.modelImageReason
|
? params.scenePlan.modelImageReason
|
||||||
? `本屏允许使用模特图:${params.scenePlan.modelImageReason}`
|
? `本屏允许使用模特图:${params.scenePlan.modelImageReason}`
|
||||||
: "本屏允许使用模特图,但仅在参考图当前这一屏确实存在人物展示需求时使用。"
|
: "本屏允许使用模特图;只要最终画面出现人物,就必须使用用户提供的同一模特身份。"
|
||||||
: params.hasModelImage
|
: params.hasModelImage
|
||||||
? params.scenePlan.modelImageReason
|
? params.scenePlan.modelImageReason
|
||||||
? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}`
|
? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}`
|
||||||
@ -2588,6 +2641,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
...promptPackInstructionLines,
|
...promptPackInstructionLines,
|
||||||
...scenePlanLines,
|
...scenePlanLines,
|
||||||
...marketplaceBrandRemovalLines,
|
...marketplaceBrandRemovalLines,
|
||||||
|
...productAngleLockLines,
|
||||||
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
|
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
|
||||||
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
|
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
|
||||||
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
|
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
|
||||||
@ -2616,6 +2670,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
...promptPackInstructionLines,
|
...promptPackInstructionLines,
|
||||||
...scenePlanLines,
|
...scenePlanLines,
|
||||||
...marketplaceBrandRemovalLines,
|
...marketplaceBrandRemovalLines,
|
||||||
|
...productAngleLockLines,
|
||||||
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
|
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
|
||||||
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
|
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
|
||||||
isReferencePosterActionPlan
|
isReferencePosterActionPlan
|
||||||
@ -2730,6 +2785,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
"【图片顺序规则】",
|
"【图片顺序规则】",
|
||||||
...imageOrderLines,
|
...imageOrderLines,
|
||||||
...scenePlanLines,
|
...scenePlanLines,
|
||||||
|
...productAngleLockLines,
|
||||||
params.pairedProductIndex !== undefined
|
params.pairedProductIndex !== undefined
|
||||||
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
||||||
: "请使用输入商品图作为本张参考图的一对一替换主体。",
|
: "请使用输入商品图作为本张参考图的一对一替换主体。",
|
||||||
@ -2754,7 +2810,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
|
|
||||||
if (params.hasModelImage) {
|
if (params.hasModelImage) {
|
||||||
instructionLines.push(
|
instructionLines.push(
|
||||||
"如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致;多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
|
"固定模特身份硬约束:如果本屏最终画面出现人物,必须使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换脸、换人、换发型或改成无关人物。多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
|
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
|
||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
|
||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertMessagesWithImages } from "./image-media";
|
import { convertMessagesWithImages } from "./image-media";
|
||||||
|
|
||||||
@ -12,7 +13,12 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
|||||||
"bfl-image",
|
"bfl-image",
|
||||||
"ruxa-image",
|
"ruxa-image",
|
||||||
"fal-image",
|
"fal-image",
|
||||||
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
|
"visionary",
|
||||||
|
"atlascloud",
|
||||||
|
"vip98k",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"runway",
|
"runway",
|
||||||
"krea",
|
"krea",
|
||||||
@ -25,6 +31,29 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
|||||||
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
|
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
|
||||||
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>();
|
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 {
|
export function normalizeFailoverErrorMessage(error: unknown): string {
|
||||||
if (!error) return "";
|
if (!error) return "";
|
||||||
if (typeof error === "string") return error;
|
if (typeof error === "string") return error;
|
||||||
@ -47,7 +76,16 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
|
|||||||
msg.includes("api key is missing") ||
|
msg.includes("api key is missing") ||
|
||||||
msg.includes("invalid api key") ||
|
msg.includes("invalid api key") ||
|
||||||
msg.includes("unauthorized") ||
|
msg.includes("unauthorized") ||
|
||||||
msg.includes("cannot convert argument to a bytestring")
|
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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,14 +115,142 @@ export type ImageFallbackCandidate = {
|
|||||||
provider: any;
|
provider: any;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
sameProvider: 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) {
|
export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||||
switch (providerType) {
|
switch (providerType) {
|
||||||
case "bfl-image":
|
case "bfl-image":
|
||||||
case "ruxa-image":
|
case "ruxa-image":
|
||||||
case "fal-image":
|
case "fal-image":
|
||||||
|
case "snapgen-image":
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
|
case "bananaimage":
|
||||||
|
case "visionary":
|
||||||
|
case "atlascloud":
|
||||||
|
case "vip98k":
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
case "midjourney":
|
case "midjourney":
|
||||||
case "runway":
|
case "runway":
|
||||||
@ -111,6 +277,10 @@ export function buildImageFallbackCandidates(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
|
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
|
||||||
|
const currentEntry = resolveCurrentImageModelEntry(allModels, [
|
||||||
|
currentModelId || "",
|
||||||
|
currentModelKey || "",
|
||||||
|
]);
|
||||||
const configuredRefs = configuredFallbackIds
|
const configuredRefs = configuredFallbackIds
|
||||||
.map((item) => String(item || "").trim())
|
.map((item) => String(item || "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -150,6 +320,7 @@ export function buildImageFallbackCandidates(
|
|||||||
|
|
||||||
const candidates: ImageFallbackCandidate[] = [];
|
const candidates: ImageFallbackCandidate[] = [];
|
||||||
const seenModelIds = new Set<string>();
|
const seenModelIds = new Set<string>();
|
||||||
|
const candidateOrder = new Map<string, number>();
|
||||||
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
|
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
|
||||||
if (!item?.model) return;
|
if (!item?.model) return;
|
||||||
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
|
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
|
||||||
@ -157,11 +328,15 @@ export function buildImageFallbackCandidates(
|
|||||||
const modelId = String(item.model.id || "");
|
const modelId = String(item.model.id || "");
|
||||||
if (!modelId || seenModelIds.has(modelId)) return;
|
if (!modelId || seenModelIds.has(modelId)) return;
|
||||||
seenModelIds.add(modelId);
|
seenModelIds.add(modelId);
|
||||||
|
candidateOrder.set(modelId, candidateOrder.size);
|
||||||
|
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
model: item.model,
|
model: item.model,
|
||||||
provider: item.provider,
|
provider: item.provider,
|
||||||
configured,
|
configured,
|
||||||
sameProvider,
|
sameProvider,
|
||||||
|
nameSimilarity,
|
||||||
|
sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -175,7 +350,24 @@ export function buildImageFallbackCandidates(
|
|||||||
.forEach((item) => pushCandidate(item, false));
|
.forEach((item) => pushCandidate(item, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates;
|
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: {
|
export async function generateImageWithFailoverCandidate(params: {
|
||||||
@ -186,14 +378,25 @@ export async function generateImageWithFailoverCandidate(params: {
|
|||||||
}) {
|
}) {
|
||||||
const { candidate, messages, imageParams, fallbackImageParams } = params;
|
const { candidate, messages, imageParams, fallbackImageParams } = params;
|
||||||
const providerType = String(candidate.provider?.type || "");
|
const providerType = String(candidate.provider?.type || "");
|
||||||
const candidateRuntimeModelId = String(candidate.model?.modelId || candidate.model?.id || "");
|
const candidateRuntimeModelId = resolveImageModelRuntimeId(candidate.model);
|
||||||
|
if (
|
||||||
|
chatMessagesHaveImageInput(messages) &&
|
||||||
|
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`备用模型不支持参考图/图生图输入:${candidate.model.displayName || candidate.model.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
providerType === "fal-image" &&
|
providerType === "fal-image" &&
|
||||||
isFalGptImage2EditModelId(candidateRuntimeModelId)
|
isFalGptImage2EditModelId(candidateRuntimeModelId)
|
||||||
) {
|
) {
|
||||||
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
|
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
|
||||||
}
|
}
|
||||||
const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
|
const candidateLanguageModel = await getLanguageModelById(
|
||||||
|
candidate.model.id,
|
||||||
|
candidateRuntimeModelId
|
||||||
|
);
|
||||||
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
|
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
|
||||||
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
|
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
|
||||||
}
|
}
|
||||||
@ -202,11 +405,24 @@ export async function generateImageWithFailoverCandidate(params: {
|
|||||||
messages,
|
messages,
|
||||||
getImageFailoverConvertProvider(providerType)
|
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({
|
const candidateResult = await (candidateLanguageModel as any).doGenerate({
|
||||||
messages: candidateMessages,
|
messages: candidateMessages,
|
||||||
imageParams: imageParams || fallbackImageParams,
|
imageParams: effectiveImageParams,
|
||||||
|
imageGenConfig: candidate.model.imageGenConfig || null,
|
||||||
modelDisplayName: candidate.model.displayName,
|
modelDisplayName: candidate.model.displayName,
|
||||||
overrideModelId: candidate.model.modelId,
|
overrideModelId: candidateRuntimeModelId || candidate.model.modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (candidateResult?.finishReason === "error") {
|
if (candidateResult?.finishReason === "error") {
|
||||||
@ -214,7 +430,7 @@ export async function generateImageWithFailoverCandidate(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: candidateResult.text || "图片生成失败",
|
text: candidateResult.text || "",
|
||||||
usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
|
usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
|
||||||
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
|
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
|
||||||
};
|
};
|
||||||
@ -224,10 +440,15 @@ export function getImageCrossProviderCandidates(
|
|||||||
allModels: any[],
|
allModels: any[],
|
||||||
options: {
|
options: {
|
||||||
currentModelId: string;
|
currentModelId: string;
|
||||||
|
currentModelKey?: string;
|
||||||
currentProviderId?: string;
|
currentProviderId?: string;
|
||||||
providerPriority: Record<string, number>;
|
providerPriority: Record<string, number>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
const currentEntry = resolveCurrentImageModelEntry(allModels, [
|
||||||
|
options.currentModelId,
|
||||||
|
options.currentModelKey || "",
|
||||||
|
]);
|
||||||
return allModels
|
return allModels
|
||||||
.filter((m) => {
|
.filter((m) => {
|
||||||
if (
|
if (
|
||||||
@ -250,10 +471,37 @@ export function getImageCrossProviderCandidates(
|
|||||||
}
|
}
|
||||||
return true;
|
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) => {
|
.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 left = options.providerPriority[a.provider.type as string] ?? 99;
|
||||||
const right = options.providerPriority[b.provider.type as string] ?? 99;
|
const right = options.providerPriority[b.provider.type as string] ?? 99;
|
||||||
if (left !== right) return left - right;
|
if (left !== right) return left - right;
|
||||||
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,14 +138,14 @@ export function buildAutoModeFallbackMessage(params: {
|
|||||||
: "基于当前上传图片处理。";
|
: "基于当前上传图片处理。";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"### 需求理解",
|
"### 意图分析",
|
||||||
`- ${demand}`,
|
`- ${demand}`,
|
||||||
"### 图片分析",
|
"### 图片分析",
|
||||||
params.imageSummary
|
params.imageSummary
|
||||||
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
|
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
|
||||||
: `- ${sourceLine}`,
|
: `- ${sourceLine}`,
|
||||||
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
|
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
|
||||||
"### 执行计划",
|
"### 任务规划",
|
||||||
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
|
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
|
||||||
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
|
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
|
||||||
"### 输出设置",
|
"### 输出设置",
|
||||||
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"### 需求理解",
|
"### 意图分析",
|
||||||
`- ${demand}`,
|
`- ${demand}`,
|
||||||
"### 执行计划",
|
"### 任务规划",
|
||||||
"- 先锁定主体与场景,再细化风格、材质与光影。",
|
"- 先锁定主体与场景,再细化风格、材质与光影。",
|
||||||
"### 输出设置",
|
"### 输出设置",
|
||||||
"- 默认单图输出,优先稳定命中。",
|
"- 默认单图输出,优先稳定命中。",
|
||||||
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
|
|||||||
|
|
||||||
export function hasStructuredAgentSections(message?: string): boolean {
|
export function hasStructuredAgentSections(message?: string): boolean {
|
||||||
if (!message) return false;
|
if (!message) return false;
|
||||||
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
|
return /(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -253,6 +253,13 @@ export function ensureStructuredAgentFriendlyMessage(params: {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallback.includes("### 任务规划\n")) {
|
||||||
|
return fallback.replace(
|
||||||
|
"### 任务规划\n",
|
||||||
|
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return fallback.replace(
|
return fallback.replace(
|
||||||
"### 执行计划\n",
|
"### 执行计划\n",
|
||||||
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
|
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
|
||||||
@ -515,7 +522,12 @@ export async function convertMessagesWithImages(
|
|||||||
normalizedProviderType === "bfl-image" ||
|
normalizedProviderType === "bfl-image" ||
|
||||||
normalizedProviderType === "ruxa-image" ||
|
normalizedProviderType === "ruxa-image" ||
|
||||||
normalizedProviderType === "fal-image" ||
|
normalizedProviderType === "fal-image" ||
|
||||||
|
normalizedProviderType === "snapgen-image" ||
|
||||||
normalizedProviderType === "openai-image" ||
|
normalizedProviderType === "openai-image" ||
|
||||||
|
normalizedProviderType === "bananaimage" ||
|
||||||
|
normalizedProviderType === "visionary" ||
|
||||||
|
normalizedProviderType === "atlascloud" ||
|
||||||
|
normalizedProviderType === "vip98k" ||
|
||||||
normalizedProviderType === "stepfun-image" ||
|
normalizedProviderType === "stepfun-image" ||
|
||||||
normalizedProviderType === "midjourney" ||
|
normalizedProviderType === "midjourney" ||
|
||||||
normalizedProviderType === "runway" ||
|
normalizedProviderType === "runway" ||
|
||||||
@ -581,45 +593,71 @@ export async function convertMessagesWithImages(
|
|||||||
if (hasAnnotation) {
|
if (hasAnnotation) {
|
||||||
chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
|
chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对于 Gemini,需要特殊处理图片
|
// 对于 Gemini,需要特殊处理图片
|
||||||
const convertedMessages = [];
|
const convertedMessages = [];
|
||||||
|
|
||||||
for (const message of preparedMessages) {
|
for (const message of preparedMessages) {
|
||||||
const content: any[] = [];
|
const content: any[] = [];
|
||||||
|
|
||||||
// 🆕 如果是用户消息且有标注图片,在文本前添加系统提示
|
// 🆕 如果是用户消息且有标注图片,在文本前添加系统提示
|
||||||
let hasAddedAnnotationPrompt = false;
|
let hasAddedAnnotationPrompt = false;
|
||||||
|
|
||||||
// 处理消息的各个部分
|
// 处理消息的各个部分
|
||||||
for (const part of message.parts) {
|
for (const part of message.parts) {
|
||||||
if (part.type === "text" && part.text) {
|
if (part.type === "text" && part.text) {
|
||||||
let textContent = part.text;
|
let textContent = part.text;
|
||||||
|
|
||||||
// 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示
|
// 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示
|
||||||
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) {
|
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) {
|
||||||
textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent;
|
textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent;
|
||||||
hasAddedAnnotationPrompt = true;
|
hasAddedAnnotationPrompt = true;
|
||||||
chatApiLogger.info("[Message Convert] 🎨 已注入标注提示词");
|
chatApiLogger.info("[Message Convert] 🎨 已注入标注提示词");
|
||||||
}
|
}
|
||||||
|
|
||||||
content.push({
|
content.push({
|
||||||
type: "text",
|
type: "text",
|
||||||
text: textContent,
|
text: textContent,
|
||||||
});
|
});
|
||||||
} else if (part.type === "file" && part.url) {
|
continue;
|
||||||
// 直接使用 URL 格式(不再转换为 Base64)
|
}
|
||||||
// gemini-image 和 huanke-gemini provider 内部会处理图片上传
|
|
||||||
|
const typedPart = part as {
|
||||||
|
type: string;
|
||||||
|
url?: string;
|
||||||
|
image?: string;
|
||||||
|
image_url?: { url?: string };
|
||||||
|
data?: string;
|
||||||
|
};
|
||||||
|
const candidateImageUrl =
|
||||||
|
typedPart.type === "file"
|
||||||
|
? typedPart.url || typedPart.data
|
||||||
|
: typedPart.type === "image"
|
||||||
|
? typedPart.image || typedPart.url
|
||||||
|
: typedPart.type === "image_url"
|
||||||
|
? typedPart.image_url?.url || typedPart.url
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (typeof candidateImageUrl !== "string" || !candidateImageUrl.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedImageUrl = candidateImageUrl.trim();
|
||||||
|
|
||||||
|
// 兼容 file/image/image_url 三类输入;Gemini 内部会将图片交给 S3 后再请求生图网关。
|
||||||
|
if (typedPart.type === "file" || typedPart.type === "image" || typedPart.type === "image_url") {
|
||||||
content.push({
|
content.push({
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: {
|
image_url: {
|
||||||
url: part.url,
|
url: normalizedImageUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
chatApiLogger.info("[Message Convert] Image kept as URL format:", part.url.substring(0, 80) + "...");
|
chatApiLogger.info(
|
||||||
|
"[Message Convert] Image kept as URL format:",
|
||||||
|
normalizedImageUrl.substring(0, 80) + "..."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 如果有标注图片但消息中没有文本部分,添加一个纯提示文本
|
// 🆕 如果有标注图片但消息中没有文本部分,添加一个纯提示文本
|
||||||
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt && content.length > 0) {
|
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt && content.length > 0) {
|
||||||
content.unshift({
|
content.unshift({
|
||||||
@ -628,12 +666,10 @@ export async function convertMessagesWithImages(
|
|||||||
});
|
});
|
||||||
chatApiLogger.info("[Message Convert] 🎨 已在消息开头注入标注提示词");
|
chatApiLogger.info("[Message Convert] 🎨 已在消息开头注入标注提示词");
|
||||||
}
|
}
|
||||||
|
|
||||||
convertedMessages.push({
|
convertedMessages.push({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: content.length === 1 && content[0].type === "text"
|
content: content.length === 1 && content[0].type === "text" ? content[0].text : content,
|
||||||
? content[0].text
|
|
||||||
: content,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import type {
|
|||||||
ResolvedImageRef,
|
ResolvedImageRef,
|
||||||
ResolvedTargetAssets,
|
ResolvedTargetAssets,
|
||||||
} from "@/lib/ai/context-memory/types";
|
} 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";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.stream");
|
const chatApiLogger = createLogger("chat.stream");
|
||||||
@ -66,6 +68,104 @@ export function writeStructuredDataChunk(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export function writeProtocolDebugChunk(
|
||||||
writer: StreamWriter,
|
writer: StreamWriter,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@ -86,7 +186,12 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
|||||||
normalized === "bfl-image" ||
|
normalized === "bfl-image" ||
|
||||||
normalized === "ruxa-image" ||
|
normalized === "ruxa-image" ||
|
||||||
normalized === "fal-image" ||
|
normalized === "fal-image" ||
|
||||||
|
normalized === "snapgen-image" ||
|
||||||
normalized === "openai-image" ||
|
normalized === "openai-image" ||
|
||||||
|
normalized === "bananaimage" ||
|
||||||
|
normalized === "visionary" ||
|
||||||
|
normalized === "atlascloud" ||
|
||||||
|
normalized === "vip98k" ||
|
||||||
normalized === "stepfun-image" ||
|
normalized === "stepfun-image" ||
|
||||||
normalized === "midjourney" ||
|
normalized === "midjourney" ||
|
||||||
normalized === "runway" ||
|
normalized === "runway" ||
|
||||||
@ -197,29 +302,25 @@ export function writeCanvasMediaChunk(
|
|||||||
agentCategoryId?: string | null;
|
agentCategoryId?: string | null;
|
||||||
agentCategoryName?: string | null;
|
agentCategoryName?: string | null;
|
||||||
agentExecutionSnapshotId?: string | null;
|
agentExecutionSnapshotId?: string | null;
|
||||||
|
mediaItemIds?: string[] | null;
|
||||||
|
handledCanvasMediaItemIds?: string[] | null;
|
||||||
},
|
},
|
||||||
label: string
|
label: string
|
||||||
) {
|
) {
|
||||||
const imageUrls = Array.isArray(params.imageUrls)
|
const inputImageUrls = Array.isArray(params.imageUrls)
|
||||||
? params.imageUrls
|
? params.imageUrls
|
||||||
: extractImageUrlsFromText(String(params.text || ""));
|
: extractImageUrlsFromText(String(params.text || ""));
|
||||||
const videoUrls = Array.isArray(params.videoUrls) ? params.videoUrls : [];
|
const normalizedPayload = normalizeCanvasMediaStreamPayload({
|
||||||
const normalizedImageUrls = Array.from(
|
imageUrls: inputImageUrls,
|
||||||
new Set(
|
videoUrls: params.videoUrls,
|
||||||
imageUrls
|
mediaItemIds: params.mediaItemIds,
|
||||||
.map((url) => String(url || "").trim())
|
handledCanvasMediaItemIds: params.handledCanvasMediaItemIds,
|
||||||
.filter((url) => /^https?:\/\//i.test(url))
|
});
|
||||||
)
|
|
||||||
);
|
|
||||||
const normalizedVideoUrls = Array.from(
|
|
||||||
new Set(
|
|
||||||
videoUrls
|
|
||||||
.map((url) => String(url || "").trim())
|
|
||||||
.filter((url) => /^https?:\/\//i.test(url))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (normalizedImageUrls.length === 0 && normalizedVideoUrls.length === 0) {
|
if (
|
||||||
|
normalizedPayload.imageUrls.length === 0 &&
|
||||||
|
normalizedPayload.videoUrls.length === 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,8 +329,8 @@ export function writeCanvasMediaChunk(
|
|||||||
"data-canvasMedia",
|
"data-canvasMedia",
|
||||||
{
|
{
|
||||||
messageId: params.messageId,
|
messageId: params.messageId,
|
||||||
imageUrls: normalizedImageUrls,
|
imageUrls: normalizedPayload.imageUrls,
|
||||||
videoUrls: normalizedVideoUrls,
|
videoUrls: normalizedPayload.videoUrls,
|
||||||
prompt: String(params.prompt || "").trim() || undefined,
|
prompt: String(params.prompt || "").trim() || undefined,
|
||||||
sourceImageUrls: Array.isArray(params.sourceImageUrls)
|
sourceImageUrls: Array.isArray(params.sourceImageUrls)
|
||||||
? params.sourceImageUrls
|
? params.sourceImageUrls
|
||||||
@ -242,11 +343,102 @@ export function writeCanvasMediaChunk(
|
|||||||
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
|
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
|
||||||
agentExecutionSnapshotId:
|
agentExecutionSnapshotId:
|
||||||
String(params.agentExecutionSnapshotId || "").trim() || undefined,
|
String(params.agentExecutionSnapshotId || "").trim() || undefined,
|
||||||
|
mediaItemIds: normalizedPayload.mediaItemIds,
|
||||||
|
handledCanvasMediaItemIds: normalizedPayload.handledCanvasMediaItemIds,
|
||||||
},
|
},
|
||||||
label
|
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 = {
|
type AgentTargetLockRefPreview = {
|
||||||
url: string;
|
url: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
||||||
|
|
||||||
const textPartSchema = z.object({
|
const textPartSchema = z.object({
|
||||||
type: z.enum(["text"]),
|
type: z.enum(["text"]),
|
||||||
@ -11,6 +12,8 @@ const filePartSchema = z.object({
|
|||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
assetId: z.string().uuid().optional(),
|
assetId: z.string().uuid().optional(),
|
||||||
|
width: z.number().positive().optional(),
|
||||||
|
height: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const partSchema = z.union([textPartSchema, filePartSchema]);
|
const partSchema = z.union([textPartSchema, filePartSchema]);
|
||||||
@ -30,11 +33,22 @@ const videoParamsSchema = z.object({
|
|||||||
returnLastFrame: z.boolean().optional(),
|
returnLastFrame: z.boolean().optional(),
|
||||||
}).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
|
// 🆕 图片生成参数 schema
|
||||||
const imageParamsSchema = z.object({
|
const imageParamsSchema = z.object({
|
||||||
aspectRatio: z.string().optional(),
|
aspectRatio: z.string().optional(),
|
||||||
imageSize: z.string().optional(),
|
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
|
||||||
imageCount: z.number().min(1).max(10).optional(),
|
imageCount: z.number().min(1).max(10).optional(),
|
||||||
|
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
|
||||||
// 阿里生图专用参数
|
// 阿里生图专用参数
|
||||||
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
||||||
negativePrompt: z.string().optional(), // 反向提示词
|
negativePrompt: z.string().optional(), // 反向提示词
|
||||||
@ -50,7 +64,8 @@ const contextPayloadSchema = z
|
|||||||
projectId: z.string().uuid().optional(),
|
projectId: z.string().uuid().optional(),
|
||||||
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
|
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
|
||||||
selectedAssetIds: z.array(z.string().uuid()).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
|
canvasImageNodes: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -71,6 +86,13 @@ const contextPayloadSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.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({
|
const agentModeReferenceSchema = z.object({
|
||||||
id: z.string().min(1).max(128),
|
id: z.string().min(1).max(128),
|
||||||
label: z.string().min(1).max(255),
|
label: z.string().min(1).max(255),
|
||||||
@ -79,13 +101,7 @@ const agentModeReferenceSchema = z.object({
|
|||||||
sortOrder: z.number().min(0).max(999).optional(),
|
sortOrder: z.number().min(0).max(999).optional(),
|
||||||
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
|
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
|
||||||
passthrough: z.boolean().nullable().optional(),
|
passthrough: z.boolean().nullable().optional(),
|
||||||
});
|
rerunReferenceImages: z.array(agentModeSourceImageSchema).max(6).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 agentModePayloadSchema = z
|
const agentModePayloadSchema = z
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
CompleteMultipartUploadCommand,
|
CompleteMultipartUploadCommand,
|
||||||
CreateMultipartUploadCommand,
|
CreateMultipartUploadCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
|
ListPartsCommand,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
UploadPartCommand,
|
UploadPartCommand,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
@ -21,20 +22,41 @@ import {
|
|||||||
maybeEnqueueStorageMigrationTask,
|
maybeEnqueueStorageMigrationTask,
|
||||||
resolveS3UploadTarget,
|
resolveS3UploadTarget,
|
||||||
} from "@/lib/storage/service";
|
} 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 logger = createLogger("direct-upload-api");
|
||||||
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
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 MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
|
||||||
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
||||||
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
|
const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
|
||||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||||
|
|
||||||
|
function elapsedMs(startedAt: number) {
|
||||||
|
return Date.now() - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
const allowedDirectUploadTypes = new Set([
|
const allowedDirectUploadTypes = new Set([
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
]);
|
]);
|
||||||
|
const allowedOpenClawVideoUploadTypes = new Set([
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/webm",
|
||||||
|
"video/x-m4v",
|
||||||
|
"video/x-matroska",
|
||||||
|
]);
|
||||||
|
|
||||||
const InitiateSchema = z.object({
|
const InitiateSchema = z.object({
|
||||||
action: z.literal("initiate"),
|
action: z.literal("initiate"),
|
||||||
@ -64,6 +86,18 @@ const CompleteSchema = z.object({
|
|||||||
clientUploadId: z.string().max(128).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({
|
const AbortSchema = z.object({
|
||||||
action: z.literal("abort"),
|
action: z.literal("abort"),
|
||||||
key: z.string().min(1).max(512),
|
key: z.string().min(1).max(512),
|
||||||
@ -73,6 +107,7 @@ const AbortSchema = z.object({
|
|||||||
|
|
||||||
const RequestSchema = z.discriminatedUnion("action", [
|
const RequestSchema = z.discriminatedUnion("action", [
|
||||||
InitiateSchema,
|
InitiateSchema,
|
||||||
|
ResumeSchema,
|
||||||
CompleteSchema,
|
CompleteSchema,
|
||||||
AbortSchema,
|
AbortSchema,
|
||||||
]);
|
]);
|
||||||
@ -100,19 +135,49 @@ function assertUserOwnedKey(key: string, userId: string) {
|
|||||||
const normalized = String(key || "").replace(/^\/+/, "");
|
const normalized = String(key || "").replace(/^\/+/, "");
|
||||||
const prefix = `${userUploadPrefix(userId)}/`;
|
const prefix = `${userUploadPrefix(userId)}/`;
|
||||||
if (!normalized.startsWith(prefix)) {
|
if (!normalized.startsWith(prefix)) {
|
||||||
throw new Error("Invalid upload object key");
|
const error = new Error("Invalid upload object key");
|
||||||
|
error.name = "InvalidUploadObjectKeyError";
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedDirectUpload(contentType: string) {
|
function isAllowedDirectUpload(
|
||||||
return allowedDirectUploadTypes.has(contentType);
|
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) {
|
function computePartSize(size: number) {
|
||||||
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
|
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
|
||||||
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
|
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
|
||||||
return 16 * 1024 * 1024;
|
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
|
||||||
|
return 64 * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPartUrls(params: {
|
async function createPartUrls(params: {
|
||||||
@ -120,27 +185,28 @@ async function createPartUrls(params: {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
key: string;
|
key: string;
|
||||||
uploadId: string;
|
uploadId: string;
|
||||||
partCount: number;
|
partNumbers: number[];
|
||||||
}) {
|
}) {
|
||||||
const urls = [];
|
return Promise.all(
|
||||||
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
|
params.partNumbers.map(async (partNumber) => {
|
||||||
const command = new UploadPartCommand({
|
const command = new UploadPartCommand({
|
||||||
Bucket: params.bucket,
|
Bucket: params.bucket,
|
||||||
Key: params.key,
|
Key: params.key,
|
||||||
UploadId: params.uploadId,
|
UploadId: params.uploadId,
|
||||||
PartNumber: partNumber,
|
PartNumber: partNumber,
|
||||||
});
|
});
|
||||||
urls.push({
|
return {
|
||||||
partNumber,
|
partNumber,
|
||||||
url: await getSignedUrl(params.client, command, {
|
url: await getSignedUrl(params.client, command, {
|
||||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
}
|
})
|
||||||
return urls;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const requestStartedAt = Date.now();
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@ -159,6 +225,16 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const target = await resolveS3UploadTarget().catch((error) => {
|
||||||
logger.error("resolve_storage_target_failed", error);
|
logger.error("resolve_storage_target_failed", error);
|
||||||
return null;
|
return null;
|
||||||
@ -170,9 +246,17 @@ export async function POST(request: Request) {
|
|||||||
{ status: 409 }
|
{ 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 client = createS3Client(target.config);
|
||||||
const bucket = target.config.bucket;
|
const bucket = target.config.bucket;
|
||||||
|
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (payload.action === "initiate") {
|
if (payload.action === "initiate") {
|
||||||
@ -181,7 +265,7 @@ export async function POST(request: Request) {
|
|||||||
payload.filename
|
payload.filename
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isAllowedDirectUpload(contentType)) {
|
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||||
logger.warn("unsupported_direct_upload_type", {
|
logger.warn("unsupported_direct_upload_type", {
|
||||||
contentType,
|
contentType,
|
||||||
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
||||||
@ -190,10 +274,19 @@ export async function POST(request: Request) {
|
|||||||
clientUploadId: payload.clientUploadId || null,
|
clientUploadId: payload.clientUploadId || null,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
{
|
||||||
|
error:
|
||||||
|
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ 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 displayName = sanitizeDisplayName(payload.filename);
|
||||||
const key = buildStorageObjectKey(
|
const key = buildStorageObjectKey(
|
||||||
@ -201,39 +294,57 @@ export async function POST(request: Request) {
|
|||||||
userUploadPrefix(session.user.id)
|
userUploadPrefix(session.user.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
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({
|
const command = new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
CacheControl: "public, max-age=31536000, immutable",
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
ACL: "public-read",
|
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
|
||||||
});
|
});
|
||||||
const url = await getSignedUrl(client, command, {
|
const url = await getSignedUrl(client, command, {
|
||||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
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({
|
return NextResponse.json({
|
||||||
mode: "single",
|
mode: "single",
|
||||||
key,
|
key,
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": contentType,
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
"x-amz-acl": "public-read",
|
|
||||||
},
|
|
||||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||||
clientUploadId: payload.clientUploadId || null,
|
clientUploadId: payload.clientUploadId || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const multipartStartedAt = Date.now();
|
||||||
const createResult = await client.send(
|
const createResult = await client.send(
|
||||||
new CreateMultipartUploadCommand({
|
new CreateMultipartUploadCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
CacheControl: "public, max-age=31536000, immutable",
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
ACL: "public-read",
|
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -249,7 +360,16 @@ export async function POST(request: Request) {
|
|||||||
bucket,
|
bucket,
|
||||||
key,
|
key,
|
||||||
uploadId,
|
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,
|
partCount,
|
||||||
|
partSize,
|
||||||
|
contentType,
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -264,8 +384,101 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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") {
|
if (payload.action === "abort") {
|
||||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||||
|
const abortStartedAt = Date.now();
|
||||||
await client.send(
|
await client.send(
|
||||||
new AbortMultipartUploadCommand({
|
new AbortMultipartUploadCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@ -273,19 +486,31 @@ export async function POST(request: Request) {
|
|||||||
UploadId: payload.uploadId,
|
UploadId: payload.uploadId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
logger.info("direct_upload_multipart_aborted", {
|
||||||
|
elapsedMs: elapsedMs(abortStartedAt),
|
||||||
|
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||||
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||||
if (!isAllowedDirectUpload(contentType)) {
|
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
{
|
||||||
|
error:
|
||||||
|
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||||
|
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.uploadId) {
|
if (payload.uploadId) {
|
||||||
|
const completeMultipartStartedAt = Date.now();
|
||||||
const parts = [...(payload.parts || [])].sort(
|
const parts = [...(payload.parts || [])].sort(
|
||||||
(a, b) => a.PartNumber - b.PartNumber
|
(a, b) => a.PartNumber - b.PartNumber
|
||||||
);
|
);
|
||||||
@ -306,35 +531,77 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
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(
|
const head = await client.send(
|
||||||
new HeadObjectCommand({
|
new HeadObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
logger.info("direct_upload_head_object_completed", {
|
||||||
|
elapsedMs: elapsedMs(headStartedAt),
|
||||||
|
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
|
|
||||||
const actualSize =
|
const actualSize =
|
||||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||||
if (actualSize !== null && actualSize <= 0) {
|
if (actualSize !== null && actualSize !== payload.size) {
|
||||||
throw new Error("Uploaded object is empty");
|
throw new Error(
|
||||||
|
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildS3PublicUrl(target.config, key);
|
const url = buildS3PublicUrl(target.config, key);
|
||||||
const resolvedContentType =
|
const resolvedContentType =
|
||||||
head.ContentType || contentType || getContentType(payload.filename);
|
head.ContentType || contentType || getContentType(payload.filename);
|
||||||
|
const assetStartedAt = Date.now();
|
||||||
const asset = await upsertMediaAsset({
|
const asset = await upsertMediaAsset({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
mediaType: "image",
|
mediaType:
|
||||||
sourceType: "upload",
|
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",
|
status: "ready",
|
||||||
originalUrl: url,
|
originalUrl: url,
|
||||||
previewUrl: url,
|
previewUrl:
|
||||||
thumbnailUrl: url,
|
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,
|
mimeType: resolvedContentType,
|
||||||
displayName: payload.filename,
|
displayName: payload.filename,
|
||||||
sourceTable: "direct_upload",
|
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: {
|
metadata: {
|
||||||
pathname: key,
|
pathname: key,
|
||||||
size: actualSize ?? payload.size,
|
size: actualSize ?? payload.size,
|
||||||
@ -344,6 +611,13 @@ export async function POST(request: Request) {
|
|||||||
clientUploadId: payload.clientUploadId || 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({
|
await maybeEnqueueStorageMigrationTask({
|
||||||
uploaded: { url, pathname: key },
|
uploaded: { url, pathname: key },
|
||||||
@ -374,7 +648,13 @@ export async function POST(request: Request) {
|
|||||||
error: "Direct upload failed",
|
error: "Direct upload failed",
|
||||||
details: error instanceof Error ? error.message : String(error),
|
details: error instanceof Error ? error.message : String(error),
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{
|
||||||
|
status:
|
||||||
|
error instanceof Error &&
|
||||||
|
error.name === "InvalidUploadObjectKeyError"
|
||||||
|
? 403
|
||||||
|
: 500,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { upsertMediaAsset } from "@/lib/db/queries";
|
|||||||
import { isSiteAdminType } from "@/lib/middleware/admin";
|
import { isSiteAdminType } from "@/lib/middleware/admin";
|
||||||
import { getContentType, uploadFile } from "@/lib/storage/service";
|
import { getContentType, uploadFile } from "@/lib/storage/service";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
|
||||||
const logger = createLogger("upload-api");
|
const logger = createLogger("upload-api");
|
||||||
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||||
@ -14,12 +15,32 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
|||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
|
"image/avif",
|
||||||
|
"image/tiff",
|
||||||
|
"image/x-tiff",
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
"image/heic",
|
"image/heic",
|
||||||
"image/heif",
|
"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 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) {
|
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||||
const normalized = contentType.trim().toLowerCase();
|
const normalized = contentType.trim().toLowerCase();
|
||||||
@ -35,8 +56,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
|
|||||||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||||||
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
||||||
|
extension === "avif" ||
|
||||||
|
extension === "tif" ||
|
||||||
|
extension === "tiff"
|
||||||
|
) {
|
||||||
|
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
|
||||||
|
}
|
||||||
|
|
||||||
return "File type should be JPEG, PNG, WebP or SVG";
|
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHeicUpload(contentType: string, filename: string) {
|
function isHeicUpload(contentType: string, filename: string) {
|
||||||
@ -48,10 +77,20 @@ function isHeicUpload(contentType: string, filename: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function toJpegFilename(filename: string) {
|
||||||
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
||||||
if (/\.(heic|heif)$/i.test(trimmed)) {
|
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
|
||||||
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
|
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
|
||||||
}
|
}
|
||||||
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
||||||
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
||||||
@ -71,6 +110,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
return Buffer.from(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智能优化图片(保持原格式)
|
* 使用Sharp智能优化图片(保持原格式)
|
||||||
* 策略:避免双重压缩,只在需要时优化
|
* 策略:避免双重压缩,只在需要时优化
|
||||||
@ -197,6 +251,12 @@ export async function POST(request: Request) {
|
|||||||
let filename = originalFilename;
|
let filename = originalFilename;
|
||||||
let contentType = originalContentType;
|
let contentType = originalContentType;
|
||||||
const uploadPurpose = String(formData.get("purpose") || "");
|
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 =
|
const clientUploadId =
|
||||||
String(formData.get("clientUploadId") || "").trim() || null;
|
String(formData.get("clientUploadId") || "").trim() || null;
|
||||||
const isSvgUpload = contentType === "image/svg+xml";
|
const isSvgUpload = contentType === "image/svg+xml";
|
||||||
@ -227,7 +287,11 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
|
if (
|
||||||
|
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
||||||
|
!isAtlasCloudReferenceVideo &&
|
||||||
|
!isOpenClawChatVideoUpload
|
||||||
|
) {
|
||||||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||||||
logger.warn("[Upload API] Unsupported upload content type", {
|
logger.warn("[Upload API] Unsupported upload content type", {
|
||||||
contentType,
|
contentType,
|
||||||
@ -277,6 +341,38 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ 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") {
|
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
|
||||||
@ -361,18 +457,32 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const asset = await upsertMediaAsset({
|
const asset = await upsertMediaAsset({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
mediaType: "image",
|
mediaType:
|
||||||
sourceType: "upload",
|
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||||
|
? "video"
|
||||||
|
: "image",
|
||||||
|
sourceType: isOpenClawChatVideoUpload
|
||||||
|
? "openclaw_chat_video_upload"
|
||||||
|
: "upload",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
originalUrl: data.url,
|
originalUrl: data.url,
|
||||||
previewUrl: data.url,
|
previewUrl:
|
||||||
thumbnailUrl: data.url,
|
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||||
|
? null
|
||||||
|
: data.url,
|
||||||
|
thumbnailUrl:
|
||||||
|
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||||
|
? null
|
||||||
|
: data.url,
|
||||||
mimeType: data.contentType || contentType || null,
|
mimeType: data.contentType || contentType || null,
|
||||||
displayName: filename,
|
displayName: filename,
|
||||||
sourceTable: "upload",
|
sourceTable: isOpenClawChatVideoUpload
|
||||||
|
? "openclaw_chat_video_upload"
|
||||||
|
: "upload",
|
||||||
metadata: {
|
metadata: {
|
||||||
pathname: data.pathname,
|
pathname: data.pathname,
|
||||||
size: optimizedBuffer.length,
|
size: optimizedBuffer.length,
|
||||||
|
purpose: uploadPurpose || null,
|
||||||
clientUploadId,
|
clientUploadId,
|
||||||
convertedFrom,
|
convertedFrom,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -72,6 +72,73 @@ function isWorkflowImageGenerationModel(model: {
|
|||||||
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
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();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
95
app/(chat)/pascal-editor/page.tsx
Normal file
95
app/(chat)/pascal-editor/page.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
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";
|
||||||
|
import { PascalEditorFrame } from "@/components/pascal-editor/pascal-editor-frame";
|
||||||
|
import { getSiteSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
const LOCAL_PASCAL_EDITOR_SRC = "/vendor/pascal-editor/index.html";
|
||||||
|
const DEV_SELF_HOSTED_PASCAL_EDITOR_SRC = "http://localhost:3002";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "建筑编辑",
|
||||||
|
};
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export default async function PascalEditorPage() {
|
||||||
|
const [session, siteSettings] = await Promise.all([auth(), getSiteSettings()]);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect("/login?callbackUrl=%2Fpascal-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredSrc = normalizePascalEditorUrl(
|
||||||
|
process.env.NEXT_PUBLIC_PASCAL_EDITOR_URL ||
|
||||||
|
process.env.PASCAL_EDITOR_URL ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const hasLocalEditor = existsSync(
|
||||||
|
path.join(process.cwd(), "public/vendor/pascal-editor/index.html")
|
||||||
|
);
|
||||||
|
const shouldUseDevSelfHosted =
|
||||||
|
process.env.NODE_ENV === "development" && !configuredSrc && !hasLocalEditor;
|
||||||
|
const hasDevSelfHostedEditor =
|
||||||
|
shouldUseDevSelfHosted &&
|
||||||
|
(await isHttpUrlReachable(DEV_SELF_HOSTED_PASCAL_EDITOR_SRC));
|
||||||
|
const src =
|
||||||
|
configuredSrc ||
|
||||||
|
(hasLocalEditor ? LOCAL_PASCAL_EDITOR_SRC : "") ||
|
||||||
|
(hasDevSelfHostedEditor ? DEV_SELF_HOSTED_PASCAL_EDITOR_SRC : "") ||
|
||||||
|
LOCAL_PASCAL_EDITOR_SRC;
|
||||||
|
const isReady = Boolean(configuredSrc || hasLocalEditor || hasDevSelfHostedEditor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PascalEditorFrame
|
||||||
|
fallbackSrc={LOCAL_PASCAL_EDITOR_SRC}
|
||||||
|
isReady={isReady}
|
||||||
|
sourceLabel={
|
||||||
|
hasDevSelfHostedEditor
|
||||||
|
? "本机 Pascal Editor"
|
||||||
|
: configuredSrc
|
||||||
|
? "Pascal Editor"
|
||||||
|
: "本地 Pascal Editor"
|
||||||
|
}
|
||||||
|
selfHostedSrc={DEV_SELF_HOSTED_PASCAL_EDITOR_SRC}
|
||||||
|
siteLogo={siteSettings.siteLogo}
|
||||||
|
siteLogoDark={siteSettings.siteLogoDark}
|
||||||
|
siteName={siteSettings.siteName}
|
||||||
|
src={src}
|
||||||
|
user={session.user}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePascalEditorUrl(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:"
|
||||||
|
? url.toString()
|
||||||
|
: "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isHttpUrlReachable(value: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(value, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
207
app/api/admin/access-blocklist/route.ts
Normal file
207
app/api/admin/access-blocklist/route.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { desc, eq, sql } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { accessIpBlocklist, user } from "@/lib/db/schema";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||||
|
import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
ip: z.string().trim().min(1).max(128),
|
||||||
|
reason: z.string().trim().max(500).optional().nullable(),
|
||||||
|
expiresAt: z.string().trim().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
reason: z.string().trim().max(500).optional().nullable(),
|
||||||
|
expiresAt: z.string().trim().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeOptionalDate(value?: string | null) {
|
||||||
|
const normalized = String(value || "").trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
const date = new Date(normalized);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const entries = await db
|
||||||
|
.select({
|
||||||
|
id: accessIpBlocklist.id,
|
||||||
|
ip: accessIpBlocklist.ip,
|
||||||
|
reason: accessIpBlocklist.reason,
|
||||||
|
isActive: accessIpBlocklist.isActive,
|
||||||
|
expiresAt: accessIpBlocklist.expiresAt,
|
||||||
|
createdAt: accessIpBlocklist.createdAt,
|
||||||
|
updatedAt: accessIpBlocklist.updatedAt,
|
||||||
|
createdBy: accessIpBlocklist.createdBy,
|
||||||
|
createdByEmail: user.email,
|
||||||
|
})
|
||||||
|
.from(accessIpBlocklist)
|
||||||
|
.leftJoin(user, eq(accessIpBlocklist.createdBy, user.id))
|
||||||
|
.orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt))
|
||||||
|
.limit(300);
|
||||||
|
|
||||||
|
return NextResponse.json({ entries });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
console.error("[AccessIpBlocklist] failed to list entries", error);
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const body = createSchema.parse(await request.json());
|
||||||
|
const normalizedIp = normalizeManualBlockIp(body.ip);
|
||||||
|
|
||||||
|
if (!normalizedIp) {
|
||||||
|
return new ChatSDKError(
|
||||||
|
"bad_request:api",
|
||||||
|
"请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24"
|
||||||
|
).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
||||||
|
const reason = body.reason?.trim() || null;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: accessIpBlocklist.id })
|
||||||
|
.from(accessIpBlocklist)
|
||||||
|
.where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [entry] = existing
|
||||||
|
? await db
|
||||||
|
.update(accessIpBlocklist)
|
||||||
|
.set({
|
||||||
|
reason,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(accessIpBlocklist.id, existing.id))
|
||||||
|
.returning()
|
||||||
|
: await db
|
||||||
|
.insert(accessIpBlocklist)
|
||||||
|
.values({
|
||||||
|
ip: normalizedIp,
|
||||||
|
reason,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt,
|
||||||
|
createdBy: session.user.id,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await recordAccessIpEventFromHeaders({
|
||||||
|
eventType: "manual_block_added",
|
||||||
|
userId: session.user.id,
|
||||||
|
userEmail: session.user.email || null,
|
||||||
|
userRole: session.user.type,
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/admin/access-blocklist",
|
||||||
|
routeGroup: "admin-api",
|
||||||
|
statusCode: 200,
|
||||||
|
outcome: "manual_block_added",
|
||||||
|
severity: "high",
|
||||||
|
reasons: ["管理员手动拉黑 IP"],
|
||||||
|
metadata: {
|
||||||
|
targetIp: normalizedIp,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[AccessIpBlocklist] failed to record block action", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ entry });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
console.error("[AccessIpBlocklist] failed to create entry", error);
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const body = patchSchema.parse(await request.json());
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ ip: accessIpBlocklist.ip })
|
||||||
|
.from(accessIpBlocklist)
|
||||||
|
.where(eq(accessIpBlocklist.id, body.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [entry] = await db
|
||||||
|
.update(accessIpBlocklist)
|
||||||
|
.set({
|
||||||
|
isActive: body.isActive,
|
||||||
|
reason: body.reason?.trim() || undefined,
|
||||||
|
expiresAt,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(accessIpBlocklist.id, body.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await recordAccessIpEventFromHeaders({
|
||||||
|
eventType: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
||||||
|
userId: session.user.id,
|
||||||
|
userEmail: session.user.email || null,
|
||||||
|
userRole: session.user.type,
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/api/admin/access-blocklist",
|
||||||
|
routeGroup: "admin-api",
|
||||||
|
statusCode: 200,
|
||||||
|
outcome: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
||||||
|
severity: body.isActive ? "high" : "watch",
|
||||||
|
reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"],
|
||||||
|
metadata: {
|
||||||
|
targetIp: existing.ip,
|
||||||
|
blocklistId: body.id,
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[AccessIpBlocklist] failed to record update action", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ entry });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||||
|
}
|
||||||
|
console.error("[AccessIpBlocklist] failed to update entry", error);
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
271
app/api/admin/access-logs/route.ts
Normal file
271
app/api/admin/access-logs/route.ts
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import { and, desc, gte, sql, type SQL } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { accessIpEvent } from "@/lib/db/schema";
|
||||||
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
type Severity = "info" | "watch" | "high" | "critical";
|
||||||
|
|
||||||
|
const severityByRank: Record<number, Severity> = {
|
||||||
|
0: "info",
|
||||||
|
1: "watch",
|
||||||
|
2: "high",
|
||||||
|
3: "critical",
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampInt(value: string | null, fallback: number, min: number, max: number) {
|
||||||
|
const parsed = Number.parseInt(value || "", 10);
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.min(Math.max(parsed, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(value: string | null) {
|
||||||
|
return String(value || "").trim().slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeverityFromAggregate(params: {
|
||||||
|
eventSeverityRank: number;
|
||||||
|
total: number;
|
||||||
|
failureCount: number;
|
||||||
|
rateLimitedCount: number;
|
||||||
|
authFailureCount: number;
|
||||||
|
suspiciousCount: number;
|
||||||
|
}) {
|
||||||
|
let severity = severityByRank[params.eventSeverityRank] || "info";
|
||||||
|
const failureRate =
|
||||||
|
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
||||||
|
|
||||||
|
if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) {
|
||||||
|
severity = "high";
|
||||||
|
} else if (params.authFailureCount >= 5 || params.total >= 250) {
|
||||||
|
severity = severity === "critical" ? severity : "high";
|
||||||
|
} else if (
|
||||||
|
params.failureCount >= 20 ||
|
||||||
|
(params.total >= 10 && failureRate >= 0.45)
|
||||||
|
) {
|
||||||
|
severity = severity === "critical" ? severity : "high";
|
||||||
|
} else if (params.authFailureCount > 0 || params.failureCount > 0) {
|
||||||
|
severity = severity === "info" ? "watch" : severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAggregateReasons(params: {
|
||||||
|
total: number;
|
||||||
|
failureCount: number;
|
||||||
|
rateLimitedCount: number;
|
||||||
|
authFailureCount: number;
|
||||||
|
suspiciousCount: number;
|
||||||
|
loginSuccessCount: number;
|
||||||
|
}) {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const failureRate =
|
||||||
|
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
||||||
|
|
||||||
|
if (params.rateLimitedCount > 0) reasons.push("触发限流");
|
||||||
|
if (params.authFailureCount >= 5) reasons.push("认证失败偏多");
|
||||||
|
if (params.suspiciousCount > 0) reasons.push("命中异常规则");
|
||||||
|
if (params.total >= 250) reasons.push("请求量偏高");
|
||||||
|
if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) {
|
||||||
|
reasons.push("失败率偏高");
|
||||||
|
}
|
||||||
|
if (params.loginSuccessCount > 0 && reasons.length === 0) {
|
||||||
|
reasons.push("有成功登录记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const hours = clampInt(searchParams.get("hours"), 24, 1, 168);
|
||||||
|
const limit = clampInt(searchParams.get("limit"), 120, 25, 500);
|
||||||
|
const severityFilter = searchParams.get("severity") || "all";
|
||||||
|
const search = normalizeSearch(searchParams.get("q"));
|
||||||
|
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||||
|
const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)];
|
||||||
|
|
||||||
|
if (severityFilter === "abnormal") {
|
||||||
|
whereParts.push(sql`${accessIpEvent.severity} <> 'info'`);
|
||||||
|
} else if (["info", "watch", "high", "critical"].includes(severityFilter)) {
|
||||||
|
whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
whereParts.push(sql`(
|
||||||
|
${accessIpEvent.ip} ILIKE ${pattern}
|
||||||
|
OR ${accessIpEvent.path} ILIKE ${pattern}
|
||||||
|
OR ${accessIpEvent.userEmail} ILIKE ${pattern}
|
||||||
|
OR ${accessIpEvent.userAgent} ILIKE ${pattern}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = and(...whereParts);
|
||||||
|
|
||||||
|
const [overview] = await db
|
||||||
|
.select({
|
||||||
|
totalEvents: sql<number>`count(*)::int`,
|
||||||
|
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
|
||||||
|
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||||
|
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||||
|
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
||||||
|
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
|
||||||
|
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
||||||
|
unauthorizedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`,
|
||||||
|
})
|
||||||
|
.from(accessIpEvent)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const recentEvents = await db
|
||||||
|
.select({
|
||||||
|
id: accessIpEvent.id,
|
||||||
|
eventType: accessIpEvent.eventType,
|
||||||
|
ip: accessIpEvent.ip,
|
||||||
|
userId: accessIpEvent.userId,
|
||||||
|
userEmail: accessIpEvent.userEmail,
|
||||||
|
userRole: accessIpEvent.userRole,
|
||||||
|
method: accessIpEvent.method,
|
||||||
|
path: accessIpEvent.path,
|
||||||
|
routeGroup: accessIpEvent.routeGroup,
|
||||||
|
statusCode: accessIpEvent.statusCode,
|
||||||
|
outcome: accessIpEvent.outcome,
|
||||||
|
severity: accessIpEvent.severity,
|
||||||
|
reasons: accessIpEvent.reasons,
|
||||||
|
userAgent: accessIpEvent.userAgent,
|
||||||
|
referer: accessIpEvent.referer,
|
||||||
|
occurredAt: accessIpEvent.occurredAt,
|
||||||
|
createdAt: accessIpEvent.createdAt,
|
||||||
|
})
|
||||||
|
.from(accessIpEvent)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(accessIpEvent.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const topIpRows = await db
|
||||||
|
.select({
|
||||||
|
ip: accessIpEvent.ip,
|
||||||
|
total: sql<number>`count(*)::int`,
|
||||||
|
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
|
||||||
|
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
|
||||||
|
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
||||||
|
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
|
||||||
|
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
||||||
|
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
|
||||||
|
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
|
||||||
|
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
|
||||||
|
eventSeverityRank: sql<number>`max(case ${accessIpEvent.severity}
|
||||||
|
when 'critical' then 3
|
||||||
|
when 'high' then 2
|
||||||
|
when 'watch' then 1
|
||||||
|
else 0
|
||||||
|
end)::int`,
|
||||||
|
})
|
||||||
|
.from(accessIpEvent)
|
||||||
|
.where(whereClause)
|
||||||
|
.groupBy(accessIpEvent.ip)
|
||||||
|
.orderBy(
|
||||||
|
desc(sql`(
|
||||||
|
count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8
|
||||||
|
+ count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12
|
||||||
|
+ count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3
|
||||||
|
+ count(*)
|
||||||
|
)`)
|
||||||
|
)
|
||||||
|
.limit(24);
|
||||||
|
|
||||||
|
const topIps = topIpRows.map((row) => {
|
||||||
|
const severity = buildSeverityFromAggregate({
|
||||||
|
eventSeverityRank: Number(row.eventSeverityRank || 0),
|
||||||
|
total: Number(row.total || 0),
|
||||||
|
failureCount: Number(row.failureCount || 0),
|
||||||
|
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
||||||
|
authFailureCount: Number(row.authFailureCount || 0),
|
||||||
|
suspiciousCount: Number(row.suspiciousCount || 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
severity,
|
||||||
|
failureRate:
|
||||||
|
Number(row.total || 0) > 0
|
||||||
|
? Number(row.failureCount || 0) / Number(row.total || 1)
|
||||||
|
: 0,
|
||||||
|
reasons: buildAggregateReasons({
|
||||||
|
total: Number(row.total || 0),
|
||||||
|
failureCount: Number(row.failureCount || 0),
|
||||||
|
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
||||||
|
authFailureCount: Number(row.authFailureCount || 0),
|
||||||
|
suspiciousCount: Number(row.suspiciousCount || 0),
|
||||||
|
loginSuccessCount: Number(row.loginSuccessCount || 0),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hourlyStats = await db
|
||||||
|
.select({
|
||||||
|
hour: sql<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||||
|
})
|
||||||
|
.from(accessIpEvent)
|
||||||
|
.where(whereClause)
|
||||||
|
.groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`)
|
||||||
|
.orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`);
|
||||||
|
|
||||||
|
const pathStats = await db
|
||||||
|
.select({
|
||||||
|
path: accessIpEvent.path,
|
||||||
|
routeGroup: accessIpEvent.routeGroup,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||||
|
})
|
||||||
|
.from(accessIpEvent)
|
||||||
|
.where(whereClause)
|
||||||
|
.groupBy(accessIpEvent.path, accessIpEvent.routeGroup)
|
||||||
|
.orderBy(desc(sql`count(*)`))
|
||||||
|
.limit(12);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
window: {
|
||||||
|
hours,
|
||||||
|
since: since.toISOString(),
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
totalEvents: overview?.totalEvents || 0,
|
||||||
|
uniqueIps: overview?.uniqueIps || 0,
|
||||||
|
abnormalEvents: overview?.abnormalEvents || 0,
|
||||||
|
abnormalIps: overview?.abnormalIps || 0,
|
||||||
|
rateLimitedEvents: overview?.rateLimitedEvents || 0,
|
||||||
|
manualBlockedEvents: overview?.manualBlockedEvents || 0,
|
||||||
|
loginSuccessEvents: overview?.loginSuccessEvents || 0,
|
||||||
|
unauthorizedEvents: overview?.unauthorizedEvents || 0,
|
||||||
|
},
|
||||||
|
topIps,
|
||||||
|
hourlyStats,
|
||||||
|
pathStats,
|
||||||
|
recentEvents,
|
||||||
|
rules: [
|
||||||
|
"短时间请求量偏高",
|
||||||
|
"4xx/5xx 或失败率偏高",
|
||||||
|
"认证失败偏多",
|
||||||
|
"触发限流",
|
||||||
|
"命中敏感路径或异常 User-Agent",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatSDKError) {
|
||||||
|
return error.toResponse();
|
||||||
|
}
|
||||||
|
console.error("[AccessIpEvent] failed to load admin analytics", error);
|
||||||
|
return new ChatSDKError("offline:admin").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,14 @@ const imageGenConfigSchema = z.object({
|
|||||||
supportsNegativePrompt: z.boolean().optional(),
|
supportsNegativePrompt: z.boolean().optional(),
|
||||||
supportsWatermark: z.boolean().optional(),
|
supportsWatermark: z.boolean().optional(),
|
||||||
supportsSeed: z.boolean().optional(),
|
supportsSeed: z.boolean().optional(),
|
||||||
|
supportsQuality: z.boolean().optional(),
|
||||||
|
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||||
|
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||||
|
maxReferenceImages: z.number().min(0).max(64).optional(),
|
||||||
|
maxVideoReferences: z.number().min(0).max(16).optional(),
|
||||||
|
ruxaTaskInputMode: z
|
||||||
|
.enum(["size-image", "resolution-based"])
|
||||||
|
.optional(),
|
||||||
creditCostPerImage: z.number().min(0).max(100000).optional(),
|
creditCostPerImage: z.number().min(0).max(100000).optional(),
|
||||||
creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(),
|
creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(),
|
||||||
creditDiscountRate: z.number().min(0.1).max(10).optional(),
|
creditDiscountRate: z.number().min(0.1).max(10).optional(),
|
||||||
@ -42,6 +50,7 @@ const imageGenConfigSchema = z.object({
|
|||||||
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
||||||
enableLoadingPlaceholder: z.boolean().optional(),
|
enableLoadingPlaceholder: z.boolean().optional(),
|
||||||
loadingColor: z.string().optional(),
|
loadingColor: z.string().optional(),
|
||||||
|
vip98k: z.record(z.string(), z.unknown()).optional(),
|
||||||
}).optional();
|
}).optional();
|
||||||
|
|
||||||
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
||||||
|
import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
|
||||||
|
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
|
||||||
|
|
||||||
const providerTypes = [
|
const providerTypes = [
|
||||||
"alibaba",
|
"alibaba",
|
||||||
@ -29,7 +31,12 @@ const providerTypes = [
|
|||||||
"bfl-image",
|
"bfl-image",
|
||||||
"ruxa-image",
|
"ruxa-image",
|
||||||
"fal-image",
|
"fal-image",
|
||||||
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
|
"visionary",
|
||||||
|
"atlascloud",
|
||||||
|
"vip98k",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"fal-video",
|
"fal-video",
|
||||||
@ -69,12 +76,17 @@ const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
|||||||
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
||||||
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
|
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
|
||||||
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
||||||
|
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
||||||
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||||
|
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||||
|
const VISIONARY_BASE_URL = "https://api.visionary.beer";
|
||||||
|
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
|
||||||
|
const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
|
||||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||||
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
|
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
|
||||||
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
||||||
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
||||||
const KREA_BASE_URL = "https://api.krea.ai";
|
const KREA_BASE_URL = "https://api.krea.ai";
|
||||||
@ -230,8 +242,18 @@ export async function POST(request: Request) {
|
|||||||
? data.baseUrl || BFL_IMAGE_BASE_URL
|
? data.baseUrl || BFL_IMAGE_BASE_URL
|
||||||
: data.type === "ruxa-image"
|
: data.type === "ruxa-image"
|
||||||
? data.baseUrl || RUXA_IMAGE_BASE_URL
|
? data.baseUrl || RUXA_IMAGE_BASE_URL
|
||||||
|
: data.type === "snapgen-image"
|
||||||
|
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
|
||||||
: data.type === "openai-image"
|
: data.type === "openai-image"
|
||||||
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
||||||
|
: data.type === "bananaimage"
|
||||||
|
? data.baseUrl || BANANAIMAGE_BASE_URL
|
||||||
|
: data.type === "visionary"
|
||||||
|
? data.baseUrl || VISIONARY_BASE_URL
|
||||||
|
: data.type === "atlascloud"
|
||||||
|
? data.baseUrl || ATLASCLOUD_BASE_URL
|
||||||
|
: data.type === "vip98k"
|
||||||
|
? data.baseUrl || VIP98K_PROVIDER_BASE_URL
|
||||||
: data.type === "stepfun-image"
|
: data.type === "stepfun-image"
|
||||||
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||||
: data.type === "midjourney"
|
: data.type === "midjourney"
|
||||||
@ -255,6 +277,7 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("[Admin API] Provider created successfully:", provider[0].id);
|
console.log("[Admin API] Provider created successfully:", provider[0].id);
|
||||||
|
clearScopedSiteSettingsCache();
|
||||||
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
|
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Admin API] Create provider error:", error);
|
console.error("[Admin API] Create provider error:", error);
|
||||||
@ -293,6 +316,12 @@ export async function PUT(request: Request) {
|
|||||||
...data,
|
...data,
|
||||||
apiKey: normalizedApiKey,
|
apiKey: normalizedApiKey,
|
||||||
type: normalizeProviderTypeForStorage(data.type),
|
type: normalizeProviderTypeForStorage(data.type),
|
||||||
|
baseUrl:
|
||||||
|
data.baseUrl !== undefined
|
||||||
|
? data.baseUrl
|
||||||
|
: data.type === "vip98k"
|
||||||
|
? VIP98K_PROVIDER_BASE_URL
|
||||||
|
: undefined,
|
||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -300,6 +329,7 @@ export async function PUT(request: Request) {
|
|||||||
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
|
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearScopedSiteSettingsCache();
|
||||||
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
|
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
@ -325,6 +355,7 @@ export async function DELETE(request: Request) {
|
|||||||
|
|
||||||
await deleteAiProvider({ id });
|
await deleteAiProvider({ id });
|
||||||
|
|
||||||
|
clearScopedSiteSettingsCache();
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatSDKError) {
|
if (error instanceof ChatSDKError) {
|
||||||
|
|||||||
182
app/api/admin/atlascloud-models/route.ts
Normal file
182
app/api/admin/atlascloud-models/route.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
fetchAtlasCloudModelPresets,
|
||||||
|
resolveAtlasCloudDisplayName,
|
||||||
|
shouldInsertMissingAtlasCloudPreset,
|
||||||
|
} from "@/lib/ai/atlascloud-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const result = await fetchAtlasCloudModelPresets();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: result.presets,
|
||||||
|
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||||
|
counts: {
|
||||||
|
total: result.catalogCount,
|
||||||
|
available: result.presets.length,
|
||||||
|
failed: result.errors.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Atlas Cloud Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to fetch Atlas Cloud models",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const requestedModelIds = Array.isArray(body?.modelIds)
|
||||||
|
? new Set(
|
||||||
|
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "atlascloud") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be an Atlas Cloud provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await fetchAtlasCloudModelPresets();
|
||||||
|
const presets = requestedModelIds
|
||||||
|
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||||
|
: catalog.presets;
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const errors = [...catalog.errors];
|
||||||
|
const providerModels = await db.query.aiModel.findMany({
|
||||||
|
where: eq(aiModel.providerId, providerId),
|
||||||
|
});
|
||||||
|
const existingByModelId = new Map(
|
||||||
|
providerModels.map((model) => [model.modelId, model])
|
||||||
|
);
|
||||||
|
const existingMediaKinds = new Set(
|
||||||
|
providerModels.map((model) => {
|
||||||
|
const capabilities = model.capabilities as Record<string, unknown> | null;
|
||||||
|
return String(capabilities?.mediaType || "image");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let preservedDeletedCount = 0;
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = existingByModelId.get(preset.modelId);
|
||||||
|
const presetCapabilities = preset.capabilities as Record<string, unknown>;
|
||||||
|
const mediaKind = String(presetCapabilities.mediaType || "image");
|
||||||
|
if (
|
||||||
|
!existing &&
|
||||||
|
!shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
|
||||||
|
) {
|
||||||
|
preservedDeletedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values = {
|
||||||
|
displayName: resolveAtlasCloudDisplayName(
|
||||||
|
existing?.displayName,
|
||||||
|
preset.displayName,
|
||||||
|
preset.modelId
|
||||||
|
),
|
||||||
|
avatarUrl: preset.avatarUrl || null,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
sortOrder: existing?.sortOrder ?? preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities,
|
||||||
|
defaultParams: preset.defaultParams,
|
||||||
|
imageGenConfig: preset.imageGenConfig,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[Atlas Cloud Models API] Failed to sync ${preset.modelId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error instanceof Error ? error.message : "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presets.length,
|
||||||
|
total: catalog.catalogCount,
|
||||||
|
available: catalog.presets.length,
|
||||||
|
skippedUnsupported: catalog.skipped.length,
|
||||||
|
preservedDeleted: preservedDeletedCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Atlas Cloud Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to sync Atlas Cloud models",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/api/admin/bananaimage-models/route.ts
Normal file
128
app/api/admin/bananaimage-models/route.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: BANANAIMAGE_MODEL_PRESETS,
|
||||||
|
counts: { total: BANANAIMAGE_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[BananaImage Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to fetch BananaImage model presets" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const modelIds = Array.isArray(body?.modelIds)
|
||||||
|
? body.modelIds
|
||||||
|
.map((item: unknown) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "bananaimage") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a BananaImage provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = modelIds
|
||||||
|
? BANANAIMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
|
||||||
|
: BANANAIMAGE_MODEL_PRESETS;
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const errors: { modelId: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[BananaImage Models API] Failed to sync ${preset.modelId}:`, error);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error.message || "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: { requested: presets.length, total: BANANAIMAGE_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[BananaImage Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to sync BananaImage models" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,12 @@ import {
|
|||||||
} from "@/lib/agents/psd-template-parser";
|
} from "@/lib/agents/psd-template-parser";
|
||||||
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
|
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
|
||||||
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill";
|
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill";
|
||||||
|
import {
|
||||||
|
AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE,
|
||||||
|
AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE,
|
||||||
|
assertSupportedAgentPsdTemplateFile,
|
||||||
|
type AgentPsdTemplateFileInspection,
|
||||||
|
} from "@/lib/agents/psd-template-format";
|
||||||
import {
|
import {
|
||||||
createAgentPsdTemplateParseHeartbeat,
|
createAgentPsdTemplateParseHeartbeat,
|
||||||
updateAgentPsdTemplateById,
|
updateAgentPsdTemplateById,
|
||||||
@ -51,6 +57,22 @@ function getErrorMessage(error: unknown) {
|
|||||||
return "PSD 解析失败";
|
return "PSD 解析失败";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPsdTemplateParseErrorMessage(params: {
|
||||||
|
error: unknown;
|
||||||
|
fileInspection?: AgentPsdTemplateFileInspection;
|
||||||
|
}) {
|
||||||
|
const message = getErrorMessage(params.error);
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.fileInspection?.format === "psb" &&
|
||||||
|
/invalid signature/i.test(message)
|
||||||
|
) {
|
||||||
|
return AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
function pickPrimaryImageLayerId(params: {
|
function pickPrimaryImageLayerId(params: {
|
||||||
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
|
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
@ -181,6 +203,12 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
|
let fileInspection: AgentPsdTemplateFileInspection | undefined;
|
||||||
|
let latestProgress = {
|
||||||
|
progress: 0,
|
||||||
|
stage: "准备解析 PSD",
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({
|
const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({
|
||||||
@ -204,10 +232,10 @@ export async function POST(
|
|||||||
return jsonError(409, "该模板正在解析中,请稍后刷新");
|
return jsonError(409, "该模板正在解析中,请稍后刷新");
|
||||||
}
|
}
|
||||||
|
|
||||||
let latestProgress = {
|
latestProgress = {
|
||||||
progress: 5,
|
progress: 5,
|
||||||
stage: "准备解析 PSD",
|
stage: "准备解析 PSD",
|
||||||
error: undefined as string | undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
const setParseProgress = async (progress: number, stage: string, error?: string) => {
|
const setParseProgress = async (progress: number, stage: string, error?: string) => {
|
||||||
latestProgress = {
|
latestProgress = {
|
||||||
@ -279,6 +307,9 @@ export async function POST(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await setParseProgress(22, "正在校验 PSD 文件格式");
|
||||||
|
fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
|
||||||
|
|
||||||
await setParseProgress(24, "正在解析 PSD 图层结构与预览");
|
await setParseProgress(24, "正在解析 PSD 图层结构与预览");
|
||||||
const parsedTemplate = await parsePsdTemplateFile(fileBuffer, {
|
const parsedTemplate = await parsePsdTemplateFile(fileBuffer, {
|
||||||
onProgress: async (event) => {
|
onProgress: async (event) => {
|
||||||
@ -349,14 +380,19 @@ export async function POST(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getPsdTemplateParseErrorMessage({
|
||||||
|
error,
|
||||||
|
fileInspection,
|
||||||
|
});
|
||||||
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
|
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
|
||||||
|
|
||||||
if (existingTemplate) {
|
if (existingTemplate) {
|
||||||
const fallbackStatus =
|
const hasUsablePreviousParse =
|
||||||
existingTemplate.parseStatus === "completed" && existingTemplate.layers.length > 0
|
existingTemplate.layers.length > 0 &&
|
||||||
? "completed"
|
existingTemplate.width > 1 &&
|
||||||
: "failed";
|
existingTemplate.height > 1 &&
|
||||||
|
Boolean(existingTemplate.previewUrl);
|
||||||
|
const fallbackStatus = hasUsablePreviousParse ? "completed" : "failed";
|
||||||
|
|
||||||
await updateAgentPsdTemplateById({
|
await updateAgentPsdTemplateById({
|
||||||
templateId: id,
|
templateId: id,
|
||||||
@ -364,9 +400,14 @@ export async function POST(
|
|||||||
updater: (template) => ({
|
updater: (template) => ({
|
||||||
...template,
|
...template,
|
||||||
parseStatus: fallbackStatus,
|
parseStatus: fallbackStatus,
|
||||||
parseProgress: fallbackStatus === "completed" ? 100 : 0,
|
parseProgress:
|
||||||
|
fallbackStatus === "completed"
|
||||||
|
? 100
|
||||||
|
: Math.max(0, Math.round(Number(latestProgress.progress || 0))),
|
||||||
parseStage:
|
parseStage:
|
||||||
fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined,
|
fallbackStatus === "completed"
|
||||||
|
? "沿用上次成功解析结果"
|
||||||
|
: latestProgress.stage || "PSD 解析失败",
|
||||||
parseError:
|
parseError:
|
||||||
fallbackStatus === "completed"
|
fallbackStatus === "completed"
|
||||||
? `重新解析失败:${message}`
|
? `重新解析失败:${message}`
|
||||||
@ -376,7 +417,20 @@ export async function POST(
|
|||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("[PSD Templates][Admin] parse failed:", error);
|
console.error("[PSD Templates][Admin] parse failed:", {
|
||||||
return jsonError(502, message);
|
templateId: id,
|
||||||
|
fileName: existingTemplate?.fileName,
|
||||||
|
progress: latestProgress.progress,
|
||||||
|
stage: latestProgress.stage,
|
||||||
|
fileFormat: fileInspection?.format,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return jsonError(
|
||||||
|
message === AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE ||
|
||||||
|
message === AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE
|
||||||
|
? 400
|
||||||
|
: 502,
|
||||||
|
message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,10 @@ import {
|
|||||||
type AgentPsdTemplateMutationResponse,
|
type AgentPsdTemplateMutationResponse,
|
||||||
} from "@/lib/agents/psd-template-config";
|
} from "@/lib/agents/psd-template-config";
|
||||||
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
|
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
|
||||||
|
import {
|
||||||
|
AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE,
|
||||||
|
assertSupportedAgentPsdTemplateFile,
|
||||||
|
} from "@/lib/agents/psd-template-format";
|
||||||
import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache";
|
import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache";
|
||||||
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
||||||
import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout";
|
import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout";
|
||||||
@ -222,7 +226,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const extension = getFileExtension(file.name);
|
const extension = getFileExtension(file.name);
|
||||||
if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) {
|
if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) {
|
||||||
return jsonMutationError(400, "模板文件仅支持 PSD / PSB");
|
return jsonMutationError(400, "模板文件仅支持 Photoshop PSD / PSB");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size <= 0) {
|
if (file.size <= 0) {
|
||||||
@ -235,11 +239,15 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const fileBuffer = await file.arrayBuffer();
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
const fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
|
||||||
const templateId = randomUUID();
|
const templateId = randomUUID();
|
||||||
console.log("[PSD Templates][Admin] received PSD template upload:", {
|
console.log("[PSD Templates][Admin] received PSD template upload:", {
|
||||||
templateId,
|
templateId,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
|
fileFormat: fileInspection.format,
|
||||||
|
width: fileInspection.width,
|
||||||
|
height: fileInspection.height,
|
||||||
});
|
});
|
||||||
await cacheAgentPsdSourceLocally({
|
await cacheAgentPsdSourceLocally({
|
||||||
templateId,
|
templateId,
|
||||||
@ -311,6 +319,13 @@ export async function POST(request: NextRequest) {
|
|||||||
} satisfies AgentPsdTemplateMutationResponse);
|
} satisfies AgentPsdTemplateMutationResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[PSD Templates][Admin] create failed:", error);
|
console.error("[PSD Templates][Admin] create failed:", error);
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
if (
|
||||||
|
error.message === AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE
|
||||||
|
) {
|
||||||
|
return jsonMutationError(400, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
return jsonMutationError(502, "PSD 模板上传或解析失败");
|
return jsonMutationError(502, "PSD 模板上传或解析失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
157
app/api/admin/ruxa-image-models/route.ts
Normal file
157
app/api/admin/ruxa-image-models/route.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { RUXA_IMAGE_MODEL_PRESETS } from "@/lib/ai/ruxa-image-models";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: RUXA_IMAGE_MODEL_PRESETS,
|
||||||
|
counts: {
|
||||||
|
total: RUXA_IMAGE_MODEL_PRESETS.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[RUXA Image Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to fetch RUXA image model presets" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const modelIds = Array.isArray(body?.modelIds)
|
||||||
|
? body.modelIds
|
||||||
|
.map((item: unknown) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "ruxa-image") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a RUXA image provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetsToAdd = modelIds
|
||||||
|
? RUXA_IMAGE_MODEL_PRESETS.filter((preset) =>
|
||||||
|
modelIds.includes(preset.modelId)
|
||||||
|
)
|
||||||
|
: RUXA_IMAGE_MODEL_PRESETS;
|
||||||
|
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const errors: { modelId: string; error: string }[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
|
||||||
|
for (const preset of presetsToAdd) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set({
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
updatedModels.push(updated);
|
||||||
|
} else {
|
||||||
|
errors.push({ modelId: preset.modelId, error: "Model exists but failed to sync" });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newModel] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
isDefault: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
addedModels.push(newModel);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[RUXA Image Models API] Failed to add ${preset.modelId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error.message || "Insert failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presetsToAdd.length,
|
||||||
|
total: RUXA_IMAGE_MODEL_PRESETS.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[RUXA Image Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to add RUXA image models" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ import {
|
|||||||
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
|
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
|
||||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||||
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
||||||
|
isDetailAgentLocalMoonshotBaseUrl,
|
||||||
|
normalizeDetailAgentLocalMoonshotBaseUrl,
|
||||||
} from "@/lib/ai/detail-agent-model-config";
|
} from "@/lib/ai/detail-agent-model-config";
|
||||||
import {
|
import {
|
||||||
normalizeProductPlanningApiConfig,
|
normalizeProductPlanningApiConfig,
|
||||||
@ -51,6 +53,10 @@ import {
|
|||||||
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
||||||
normalizeCanvasImageTaskQueueConfig,
|
normalizeCanvasImageTaskQueueConfig,
|
||||||
} from "@/lib/canvas/image-task-config";
|
} from "@/lib/canvas/image-task-config";
|
||||||
|
import {
|
||||||
|
CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||||
|
normalizeCanvasLocalEditConfig,
|
||||||
|
} from "@/lib/canvas/local-edit-config";
|
||||||
import {
|
import {
|
||||||
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||||
normalizeDingTalkRobotNotificationConfig,
|
normalizeDingTalkRobotNotificationConfig,
|
||||||
@ -67,6 +73,15 @@ import {
|
|||||||
EXTERNAL_IMAGE_API_CONFIG_KEY,
|
EXTERNAL_IMAGE_API_CONFIG_KEY,
|
||||||
normalizeExternalImageApiConfig,
|
normalizeExternalImageApiConfig,
|
||||||
} from "@/lib/external-image-api/config";
|
} from "@/lib/external-image-api/config";
|
||||||
|
import {
|
||||||
|
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
||||||
|
normalizeAgentWorkflowMaterialApiConfig,
|
||||||
|
} from "@/lib/agents/workflow-material-config";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatConfig,
|
||||||
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
toAdminOpenClawChatConfig,
|
||||||
|
} from "@/lib/openclaw-chat/config";
|
||||||
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
|
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
|
||||||
|
|
||||||
const settingSchema = z.object({
|
const settingSchema = z.object({
|
||||||
@ -86,7 +101,7 @@ const moonshotDetailAgentConfigSchema = z.object({
|
|||||||
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||||
|
|
||||||
function isMoonshotOfficialBaseUrl(value: string) {
|
function isMoonshotOfficialBaseUrl(value: string) {
|
||||||
return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL;
|
return isDetailAgentLocalMoonshotBaseUrl(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
||||||
@ -115,6 +130,12 @@ export async function GET(request: Request) {
|
|||||||
setting: setting ? { ...setting, value } : { key, value },
|
setting: setting ? { ...setting, value } : { key, value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
|
||||||
|
const value = normalizeCanvasLocalEditConfig(setting?.value);
|
||||||
|
return Response.json({
|
||||||
|
setting: setting ? { ...setting, value } : { key, value },
|
||||||
|
});
|
||||||
|
}
|
||||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||||
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
|
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@ -139,6 +160,18 @@ export async function GET(request: Request) {
|
|||||||
setting: setting ? { ...setting, value } : { key, value },
|
setting: setting ? { ...setting, value } : { key, value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
|
||||||
|
const value = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
|
||||||
|
return Response.json({
|
||||||
|
setting: setting ? { ...setting, value } : { key, value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
|
||||||
|
const value = toAdminOpenClawChatConfig(setting?.value);
|
||||||
|
return Response.json({
|
||||||
|
setting: setting ? { ...setting, value } : { key, value },
|
||||||
|
});
|
||||||
|
}
|
||||||
return Response.json({ setting });
|
return Response.json({ setting });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,19 +203,27 @@ export async function POST(request: Request) {
|
|||||||
? (existingSetting.value as Record<string, unknown>)
|
? (existingSetting.value as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const config = moonshotDetailAgentConfigSchema.parse(value || {});
|
const config = moonshotDetailAgentConfigSchema.parse(value || {});
|
||||||
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL;
|
const normalizedBaseUrl = normalizeDetailAgentLocalMoonshotBaseUrl(config.baseUrl);
|
||||||
const rawModelId = String(config.modelId || "").trim();
|
const rawModelId = String(config.modelId || "").trim();
|
||||||
const rawDisplayName = String(config.displayName || "").trim();
|
const rawDisplayName = String(config.displayName || "").trim();
|
||||||
const normalizedModelId =
|
const normalizedModelId =
|
||||||
!rawModelId || rawModelId === "kimi-k2.5"
|
!rawModelId ||
|
||||||
|
rawModelId === "kimi-k2.5" ||
|
||||||
|
rawModelId === "kimi-k2.6" ||
|
||||||
|
rawModelId === "kimi-k2.7-code" ||
|
||||||
|
rawModelId === "kimi-k2.7-code-highspeed"
|
||||||
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
|
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
|
||||||
: rawModelId;
|
: rawModelId;
|
||||||
const normalizedDisplayName =
|
const normalizedDisplayName =
|
||||||
!rawDisplayName || rawDisplayName === "官方 Kimi K2.5"
|
!rawDisplayName ||
|
||||||
|
rawDisplayName === "官方 Kimi K2.5" ||
|
||||||
|
rawDisplayName === "官方 Kimi K2.6" ||
|
||||||
|
rawDisplayName === "官方 Kimi K2.7 Code Highspeed" ||
|
||||||
|
rawDisplayName === "Kimi Code 编程套餐 K2.7 Highspeed"
|
||||||
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
|
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
|
||||||
: rawDisplayName;
|
: rawDisplayName;
|
||||||
const normalizedProviderName =
|
const normalizedProviderName =
|
||||||
String(config.providerName || "").trim() || "Moonshot AI (Official)";
|
String(config.providerName || "").trim() || "Kimi Code 编程套餐";
|
||||||
const normalizedApiKey =
|
const normalizedApiKey =
|
||||||
String(config.apiKey || "").trim() ||
|
String(config.apiKey || "").trim() ||
|
||||||
String(existingValue.apiKey || "").trim();
|
String(existingValue.apiKey || "").trim();
|
||||||
@ -191,12 +232,12 @@ export async function POST(request: Request) {
|
|||||||
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
|
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
|
||||||
return new ChatSDKError(
|
return new ChatSDKError(
|
||||||
"bad_request:api",
|
"bad_request:api",
|
||||||
`Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
|
`Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedApiKey) {
|
if (!normalizedApiKey) {
|
||||||
return new ChatSDKError("bad_request:api", "缺少 Moonshot 官方 API Key").toResponse();
|
return new ChatSDKError("bad_request:api", "缺少 Kimi Code 控制台 API Key").toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = await upsertSystemSetting({
|
const saved = await upsertSystemSetting({
|
||||||
@ -278,6 +319,58 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
|
||||||
|
const saved = await upsertSystemSetting({
|
||||||
|
key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
||||||
|
value: normalizeAgentWorkflowMaterialApiConfig(value),
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
clearScopedSiteSettingsCache();
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
setting: saved[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
|
||||||
|
const existingSetting = await getSystemSetting({
|
||||||
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const existingValue =
|
||||||
|
existingSetting?.value && typeof existingSetting.value === "object"
|
||||||
|
? (existingSetting.value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const normalizedValue = normalizeOpenClawChatConfig(
|
||||||
|
value,
|
||||||
|
String(existingValue.token || "").trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedValue.enabled) {
|
||||||
|
if (!normalizedValue.baseUrl) {
|
||||||
|
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口地址").toResponse();
|
||||||
|
}
|
||||||
|
if (!normalizedValue.token) {
|
||||||
|
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口 Token").toResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await upsertSystemSetting({
|
||||||
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
value: normalizedValue,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
clearScopedSiteSettingsCache();
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
setting: saved[0]
|
||||||
|
? {
|
||||||
|
...saved[0],
|
||||||
|
value: toAdminOpenClawChatConfig(saved[0].value),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
|
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
|
||||||
nextValue = normalizeVideoCanvasFunctionConfig(value);
|
nextValue = normalizeVideoCanvasFunctionConfig(value);
|
||||||
}
|
}
|
||||||
@ -286,6 +379,10 @@ export async function POST(request: Request) {
|
|||||||
nextValue = normalizeCanvasImageTaskQueueConfig(value);
|
nextValue = normalizeCanvasImageTaskQueueConfig(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
|
||||||
|
nextValue = normalizeCanvasLocalEditConfig(value);
|
||||||
|
}
|
||||||
|
|
||||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||||
nextValue = normalizeDingTalkRobotNotificationConfig(value);
|
nextValue = normalizeDingTalkRobotNotificationConfig(value);
|
||||||
}
|
}
|
||||||
|
|||||||
155
app/api/admin/snapgen-image-models/route.ts
Normal file
155
app/api/admin/snapgen-image-models/route.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: SNAPGEN_IMAGE_MODEL_PRESETS,
|
||||||
|
counts: {
|
||||||
|
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[SnapGen Image Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to fetch SnapGen image model presets" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const modelIds = Array.isArray(body?.modelIds)
|
||||||
|
? body.modelIds
|
||||||
|
.map((item: unknown) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "snapgen-image") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a SnapGen image provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetsToAdd = modelIds
|
||||||
|
? SNAPGEN_IMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
|
||||||
|
: SNAPGEN_IMAGE_MODEL_PRESETS;
|
||||||
|
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const errors: { modelId: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const preset of presetsToAdd) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set({
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
updatedModels.push(updated);
|
||||||
|
} else {
|
||||||
|
errors.push({ modelId: preset.modelId, error: "Model exists but failed to sync" });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newModel] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
isDefault: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
addedModels.push(newModel);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[SnapGen Image Models API] Failed to sync ${preset.modelId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error.message || "Insert failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presetsToAdd.length,
|
||||||
|
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[SnapGen Image Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to add SnapGen image models" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
213
app/api/admin/vip98k-models/route.ts
Normal file
213
app/api/admin/vip98k-models/route.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
fetchVip98kModelPresets,
|
||||||
|
type Vip98kModelPreset,
|
||||||
|
} from "@/lib/ai/vip98k-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { aiModel, type AiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
function summarizeByType(presets: Vip98kModelPreset[]) {
|
||||||
|
return presets.reduce<Record<string, number>>((acc, preset) => {
|
||||||
|
const type = String(preset.type || "model");
|
||||||
|
acc[type] = (acc[type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const providerId = String(searchParams.get("providerId") || "").trim();
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider ID is required to fetch VIP98K catalog" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "vip98k") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a VIP98K provider" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = await fetchVip98kModelPresets({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
presets,
|
||||||
|
counts: {
|
||||||
|
total: presets.length,
|
||||||
|
byType: summarizeByType(presets),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[VIP98K Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to fetch VIP98K models",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const requestedModelIds = Array.isArray(body?.modelIds)
|
||||||
|
? new Set(
|
||||||
|
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const preserveDeletedModels = body?.preserveDeletedModels !== false;
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "vip98k") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a VIP98K provider" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogPresets = await fetchVip98kModelPresets({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
const existingModels = await db.query.aiModel.findMany({
|
||||||
|
where: eq(aiModel.providerId, providerId),
|
||||||
|
});
|
||||||
|
const existingModelIds = new Set(existingModels.map((model) => model.modelId));
|
||||||
|
const preserveExistingCatalog =
|
||||||
|
requestedModelIds === null &&
|
||||||
|
preserveDeletedModels &&
|
||||||
|
existingModels.length > 0;
|
||||||
|
const presets = requestedModelIds
|
||||||
|
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||||
|
: preserveExistingCatalog
|
||||||
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
|
: catalogPresets;
|
||||||
|
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const disabledModels: AiModel[] = [];
|
||||||
|
const errors: Array<{ modelId: string; error: string }> = [];
|
||||||
|
|
||||||
|
if (requestedModelIds === null) {
|
||||||
|
const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
|
||||||
|
for (const existing of existingModels) {
|
||||||
|
if (catalogModelIds.has(existing.modelId)) continue;
|
||||||
|
const [disabled] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
|
||||||
|
.where(eq(aiModel.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
if (disabled) disabledModels.push(disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
avatarUrl: null,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled:
|
||||||
|
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
|
||||||
|
isHidden: preset.isHidden,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities,
|
||||||
|
defaultParams: preset.defaultParams,
|
||||||
|
imageGenConfig: preset.imageGenConfig,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error instanceof Error ? error.message : "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
disabledCount: disabledModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
disabledModelIds: disabledModels.map((model) => model.modelId),
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presets.length,
|
||||||
|
total: catalogPresets.length,
|
||||||
|
byType: summarizeByType(catalogPresets),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[VIP98K Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to sync VIP98K models",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/api/admin/visionary-models/route.ts
Normal file
139
app/api/admin/visionary-models/route.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: VISIONARY_MODEL_PRESETS,
|
||||||
|
counts: { total: VISIONARY_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[Visionary Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to fetch Visionary model presets" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const modelIds = Array.isArray(body?.modelIds)
|
||||||
|
? body.modelIds
|
||||||
|
.map((item: unknown) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "visionary") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a Visionary provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = modelIds
|
||||||
|
? VISIONARY_MODEL_PRESETS.filter((preset) =>
|
||||||
|
modelIds.includes(preset.modelId)
|
||||||
|
)
|
||||||
|
: VISIONARY_MODEL_PRESETS;
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const errors: { modelId: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Visionary Models API] Failed to sync ${preset.modelId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error.message || "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presets.length,
|
||||||
|
total: VISIONARY_MODEL_PRESETS.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[Visionary Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to sync Visionary models" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
|||||||
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
||||||
import {
|
import {
|
||||||
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
||||||
|
buildAgentWorkflowMaterialApiUrl,
|
||||||
|
isAgentWorkflowMaterialResourceEnabled,
|
||||||
normalizeAgentWorkflowMaterialApiConfig,
|
normalizeAgentWorkflowMaterialApiConfig,
|
||||||
} from "@/lib/agents/workflow-material-config";
|
} from "@/lib/agents/workflow-material-config";
|
||||||
import {
|
import {
|
||||||
@ -677,6 +679,12 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "白底图素材接口未开启,请先在系统设置中启用" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
|
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
|
||||||
@ -696,15 +704,18 @@ export async function GET(request: NextRequest) {
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
const upstreamUrl = `${normalizeBaseUrl(
|
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, {
|
||||||
config.baseUrl
|
resourceType: "white-background",
|
||||||
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`;
|
queryType,
|
||||||
|
keyword: normalizedKeyword,
|
||||||
|
});
|
||||||
const upstreamResponse = await fetch(upstreamUrl, {
|
const upstreamResponse = await fetch(upstreamUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"X-API-Key": config.apiKey,
|
"X-API-Key": config.apiKey,
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
|
||||||
});
|
});
|
||||||
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;
|
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { auth } from "@/app/(auth)/auth";
|
|||||||
import { getSystemSetting } from "@/lib/db/queries";
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
import {
|
import {
|
||||||
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
||||||
|
buildAgentWorkflowMaterialApiUrl,
|
||||||
|
isAgentWorkflowMaterialResourceEnabled,
|
||||||
normalizeAgentWorkflowMaterialApiConfig,
|
normalizeAgentWorkflowMaterialApiConfig,
|
||||||
} from "@/lib/agents/workflow-material-config";
|
} from "@/lib/agents/workflow-material-config";
|
||||||
import {
|
import {
|
||||||
@ -469,13 +471,11 @@ async function requestTaskDetailAttempt(params: {
|
|||||||
verifiedGoodsBrandName?: string | null;
|
verifiedGoodsBrandName?: string | null;
|
||||||
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
|
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
|
||||||
}) {
|
}) {
|
||||||
const upstreamParams = new URLSearchParams({
|
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(params.config, {
|
||||||
resourceType: "task-detail",
|
resourceType: "task-detail",
|
||||||
[params.queryType]: params.keyword,
|
queryType: params.queryType,
|
||||||
|
keyword: params.keyword,
|
||||||
});
|
});
|
||||||
const upstreamUrl = `${normalizeBaseUrl(
|
|
||||||
params.config.baseUrl
|
|
||||||
)}/standard-model-white-background?${upstreamParams.toString()}`;
|
|
||||||
|
|
||||||
const upstreamResponse = await fetch(upstreamUrl, {
|
const upstreamResponse = await fetch(upstreamUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -483,6 +483,7 @@ async function requestTaskDetailAttempt(params: {
|
|||||||
"X-API-Key": params.config.apiKey,
|
"X-API-Key": params.config.apiKey,
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(params.config.requestTimeoutMs || 30_000),
|
||||||
});
|
});
|
||||||
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
|
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
|
||||||
const upstreamBrandName = getExternalBrandName(upstreamResult.data || {});
|
const upstreamBrandName = getExternalBrandName(upstreamResult.data || {});
|
||||||
@ -936,6 +937,12 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!isAgentWorkflowMaterialResourceEnabled(config, "task-detail")) {
|
||||||
|
return jsonNoStore(
|
||||||
|
{ error: "任务-详情整套素材接口未开启,请先在系统设置中启用" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
return jsonNoStore(
|
return jsonNoStore(
|
||||||
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
|
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import {
|
|
||||||
getKittyCadModelingCommandError,
|
|
||||||
isKittyCadModelingCommandSuccessful,
|
|
||||||
sendKittyCadModelingCommand,
|
|
||||||
takeKittyCadModelingSnapshot,
|
|
||||||
} from "@/lib/kittycad/modeling-session";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 60;
|
|
||||||
|
|
||||||
type ModelingCommandRequest = {
|
|
||||||
captureSnapshot?: boolean;
|
|
||||||
cmd?: Record<string, unknown>;
|
|
||||||
cmdId?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
snapshotFormat?: "jpeg" | "png";
|
|
||||||
};
|
|
||||||
|
|
||||||
function readOptionalRecord(value: unknown) {
|
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
|
||||||
? (value as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json()) as ModelingCommandRequest;
|
|
||||||
const sessionId = String(body.sessionId || "").trim();
|
|
||||||
if (!sessionId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "缺少官方建模会话 ID" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = readOptionalRecord(body.cmd);
|
|
||||||
const cmdType = String(cmd?.type || "").trim();
|
|
||||||
if (!cmd || !cmdType) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "缺少有效的官方建模命令" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmdId = String(body.cmdId || "").trim() || randomUUID();
|
|
||||||
const response = await sendKittyCadModelingCommand({
|
|
||||||
cmd: {
|
|
||||||
...cmd,
|
|
||||||
type: cmdType,
|
|
||||||
},
|
|
||||||
cmdId,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isKittyCadModelingCommandSuccessful(response)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: getKittyCadModelingCommandError(response),
|
|
||||||
payload: response.errors || null,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshotUrl: string | null = null;
|
|
||||||
let snapshotError: string | null = null;
|
|
||||||
|
|
||||||
const shouldCaptureSnapshot =
|
|
||||||
body.captureSnapshot === true && cmdType !== "take_snapshot";
|
|
||||||
|
|
||||||
if (cmdType === "take_snapshot") {
|
|
||||||
const contents = String(response.resp?.data?.contents || "").trim();
|
|
||||||
const snapshotFormat = body.snapshotFormat || "png";
|
|
||||||
snapshotUrl = contents
|
|
||||||
? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}`
|
|
||||||
: null;
|
|
||||||
} else if (shouldCaptureSnapshot) {
|
|
||||||
try {
|
|
||||||
const snapshot = await takeKittyCadModelingSnapshot({
|
|
||||||
format: body.snapshotFormat || "png",
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
snapshotUrl = snapshot.snapshotUrl;
|
|
||||||
} catch (error) {
|
|
||||||
snapshotError =
|
|
||||||
error instanceof Error ? error.message : "获取官方建模快照失败";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
raw: response,
|
|
||||||
requestId: response.request_id || cmdId,
|
|
||||||
responseData: response.resp?.data || null,
|
|
||||||
responseType: response.resp?.type || cmdType,
|
|
||||||
snapshotError,
|
|
||||||
snapshotUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "发送官方建模命令失败",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 60;
|
|
||||||
|
|
||||||
type RouteParams = {
|
|
||||||
params: Promise<{
|
|
||||||
sessionId: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sessionId } = await params;
|
|
||||||
const normalizedSessionId = String(sessionId || "").trim();
|
|
||||||
if (!normalizedSessionId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "缺少 sessionId" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await closeKittyCadModelingSession(normalizedSessionId);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "关闭官方建模会话失败",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 60;
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelingSession = await createKittyCadModelingSession();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: modelingSession,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "创建官方建模会话失败",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await findKittyCadProviderConfig();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
configured: Boolean(config),
|
|
||||||
source: config?.source || "none",
|
|
||||||
providerName: config?.providerName || null,
|
|
||||||
baseUrl: config?.baseUrl || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,391 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
|
||||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 60;
|
|
||||||
|
|
||||||
type KittyCadTaskResponse = {
|
|
||||||
id?: string;
|
|
||||||
status?: string;
|
|
||||||
outputs?: Record<string, string> | null;
|
|
||||||
error?: string | null;
|
|
||||||
code?: string | null;
|
|
||||||
prompt?: string | null;
|
|
||||||
conversation_id?: string | null;
|
|
||||||
model_version?: string | null;
|
|
||||||
output_format?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ArchivedFile = {
|
|
||||||
sourceHash: string;
|
|
||||||
archivedUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RouteParams = {
|
|
||||||
params: Promise<{ taskId: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getArchiveCache() {
|
|
||||||
const globalState = globalThis as Record<string, unknown>;
|
|
||||||
if (!globalState.__kittyCadAssetArchiveCache) {
|
|
||||||
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
|
|
||||||
}
|
|
||||||
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getArchiveInflight() {
|
|
||||||
const globalState = globalThis as Record<string, unknown>;
|
|
||||||
if (!globalState.__kittyCadAssetArchiveInflight) {
|
|
||||||
globalState.__kittyCadAssetArchiveInflight = new Map<
|
|
||||||
string,
|
|
||||||
Promise<string | null>
|
|
||||||
>();
|
|
||||||
}
|
|
||||||
return globalState.__kittyCadAssetArchiveInflight as Map<
|
|
||||||
string,
|
|
||||||
Promise<string | null>
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapKittyCadStatus(status: unknown) {
|
|
||||||
const normalized = String(status || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return "queued" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "queued" ||
|
|
||||||
normalized === "pending" ||
|
|
||||||
normalized === "created" ||
|
|
||||||
normalized === "submitted"
|
|
||||||
) {
|
|
||||||
return "queued" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "running" ||
|
|
||||||
normalized === "processing" ||
|
|
||||||
normalized === "in_progress" ||
|
|
||||||
normalized === "working" ||
|
|
||||||
normalized === "generating"
|
|
||||||
) {
|
|
||||||
return "running" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "completed" ||
|
|
||||||
normalized === "success" ||
|
|
||||||
normalized === "succeeded" ||
|
|
||||||
normalized === "done" ||
|
|
||||||
normalized === "finished"
|
|
||||||
) {
|
|
||||||
return "success" as const;
|
|
||||||
}
|
|
||||||
if (normalized === "cancelled" || normalized === "canceled") {
|
|
||||||
return "cancelled" as const;
|
|
||||||
}
|
|
||||||
if (normalized === "expired" || normalized === "timed_out") {
|
|
||||||
return "expired" as const;
|
|
||||||
}
|
|
||||||
if (normalized === "banned" || normalized === "blocked") {
|
|
||||||
return "banned" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "failed" ||
|
|
||||||
normalized === "error" ||
|
|
||||||
normalized === "errored"
|
|
||||||
) {
|
|
||||||
return "failed" as const;
|
|
||||||
}
|
|
||||||
return "unknown" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileExtension(filename: string) {
|
|
||||||
const normalized = String(filename || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
|
||||||
const segments = cleanName.split(".");
|
|
||||||
return segments.length > 1 ? segments.at(-1) || "" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImageExtension(extension: string) {
|
|
||||||
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
|
|
||||||
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
|
|
||||||
for (const extension of preferredOrder) {
|
|
||||||
const matched = files.find(
|
|
||||||
(item) => getFileExtension(item.name) === extension
|
|
||||||
);
|
|
||||||
if (matched) {
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
|
|
||||||
return (
|
|
||||||
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveOutputIfNeeded(params: {
|
|
||||||
taskId: string;
|
|
||||||
filename: string;
|
|
||||||
base64: string;
|
|
||||||
}) {
|
|
||||||
const normalizedFilename = String(params.filename || "").trim();
|
|
||||||
const normalizedBase64 = String(params.base64 || "").trim();
|
|
||||||
if (!normalizedFilename || !normalizedBase64) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
|
||||||
const cache = getArchiveCache();
|
|
||||||
const cached = cache.get(cacheKey);
|
|
||||||
if (cached && cached.sourceHash === normalizedBase64) {
|
|
||||||
return cached.archivedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = getArchiveInflight();
|
|
||||||
const existingTask = inflight.get(cacheKey);
|
|
||||||
if (existingTask) {
|
|
||||||
return (await existingTask) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveTask = (async () => {
|
|
||||||
try {
|
|
||||||
const binary = Buffer.from(normalizedBase64, "base64");
|
|
||||||
if (binary.byteLength <= 0) {
|
|
||||||
throw new Error("KittyCAD 输出文件为空");
|
|
||||||
}
|
|
||||||
const arrayBuffer = Uint8Array.from(binary).buffer;
|
|
||||||
|
|
||||||
const safeFilename =
|
|
||||||
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
|
|
||||||
const uploaded = await uploadFile(
|
|
||||||
`kittycad-${params.taskId}-${safeFilename}`,
|
|
||||||
arrayBuffer
|
|
||||||
);
|
|
||||||
cache.set(cacheKey, {
|
|
||||||
sourceHash: normalizedBase64,
|
|
||||||
archivedUrl: uploaded.url,
|
|
||||||
});
|
|
||||||
return uploaded.url;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[KittyCADArchive] failed", {
|
|
||||||
taskId: params.taskId,
|
|
||||||
filename: normalizedFilename,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
inflight.delete(cacheKey);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
inflight.set(cacheKey, archiveTask);
|
|
||||||
return await archiveTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveTextOutputIfNeeded(params: {
|
|
||||||
taskId: string;
|
|
||||||
filename: string;
|
|
||||||
content: string;
|
|
||||||
}) {
|
|
||||||
const normalizedFilename = String(params.filename || "").trim();
|
|
||||||
const normalizedContent = String(params.content || "");
|
|
||||||
if (!normalizedFilename || !normalizedContent.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
|
||||||
const cache = getArchiveCache();
|
|
||||||
const cached = cache.get(cacheKey);
|
|
||||||
if (cached && cached.sourceHash === normalizedContent) {
|
|
||||||
return cached.archivedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = getArchiveInflight();
|
|
||||||
const existingTask = inflight.get(cacheKey);
|
|
||||||
if (existingTask) {
|
|
||||||
return (await existingTask) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveTask = (async () => {
|
|
||||||
try {
|
|
||||||
const arrayBuffer = Uint8Array.from(
|
|
||||||
new TextEncoder().encode(normalizedContent)
|
|
||||||
).buffer;
|
|
||||||
const safeFilename =
|
|
||||||
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
|
|
||||||
const uploaded = await uploadFile(
|
|
||||||
`kittycad-${params.taskId}-${safeFilename}`,
|
|
||||||
arrayBuffer
|
|
||||||
);
|
|
||||||
cache.set(cacheKey, {
|
|
||||||
sourceHash: normalizedContent,
|
|
||||||
archivedUrl: uploaded.url,
|
|
||||||
});
|
|
||||||
return uploaded.url;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[KittyCADArchive:text] failed", {
|
|
||||||
taskId: params.taskId,
|
|
||||||
filename: normalizedFilename,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
inflight.delete(cacheKey);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
inflight.set(cacheKey, archiveTask);
|
|
||||||
return await archiveTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: RouteParams) {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { taskId } = await params;
|
|
||||||
const normalizedTaskId = String(taskId || "").trim();
|
|
||||||
if (!normalizedTaskId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "缺少 taskId" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await getKittyCadProviderConfig();
|
|
||||||
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
|
|
||||||
method: "GET",
|
|
||||||
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = mapKittyCadStatus(result.status);
|
|
||||||
if (status !== "success") {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
taskId: normalizedTaskId,
|
|
||||||
type: "cad",
|
|
||||||
status,
|
|
||||||
progress: status === "running" ? 65 : null,
|
|
||||||
errorMessage: result.error || null,
|
|
||||||
conversationId: result.conversation_id || null,
|
|
||||||
modelVersion: result.model_version || null,
|
|
||||||
outputFormat: result.output_format || null,
|
|
||||||
output: result,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputEntries = Object.entries(result.outputs || {}).filter(
|
|
||||||
(entry): entry is [string, string] =>
|
|
||||||
typeof entry[0] === "string" &&
|
|
||||||
entry[0].trim().length > 0 &&
|
|
||||||
typeof entry[1] === "string" &&
|
|
||||||
entry[1].trim().length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const archivedEntries = await Promise.all(
|
|
||||||
outputEntries.map(async ([name, base64]) => {
|
|
||||||
const archivedUrl = await archiveOutputIfNeeded({
|
|
||||||
taskId: normalizedTaskId,
|
|
||||||
filename: name,
|
|
||||||
base64,
|
|
||||||
});
|
|
||||||
return archivedUrl ? { name, url: archivedUrl } : null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const archivedFiles = archivedEntries.filter(
|
|
||||||
(item): item is { name: string; url: string } =>
|
|
||||||
Boolean(item?.name && item?.url)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasArchivedKclFile = archivedFiles.some(
|
|
||||||
(item) => getFileExtension(item.name) === "kcl"
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!hasArchivedKclFile &&
|
|
||||||
typeof result.code === "string" &&
|
|
||||||
result.code.trim()
|
|
||||||
) {
|
|
||||||
const archivedCodeUrl = await archiveTextOutputIfNeeded({
|
|
||||||
taskId: normalizedTaskId,
|
|
||||||
filename: "main.kcl",
|
|
||||||
content: result.code,
|
|
||||||
});
|
|
||||||
if (archivedCodeUrl) {
|
|
||||||
archivedFiles.push({
|
|
||||||
name: "main.kcl",
|
|
||||||
url: archivedCodeUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelFile = pickPreferredModelFile(archivedFiles);
|
|
||||||
const previewImageFile = pickPreviewImageFile(archivedFiles);
|
|
||||||
const downloadUrls = Object.fromEntries(
|
|
||||||
archivedFiles.map((item) => [item.name, item.url])
|
|
||||||
);
|
|
||||||
const codeFile =
|
|
||||||
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
taskId: normalizedTaskId,
|
|
||||||
type: "cad",
|
|
||||||
status: "success",
|
|
||||||
progress: 100,
|
|
||||||
modelUrl:
|
|
||||||
modelFile &&
|
|
||||||
["glb", "gltf"].includes(getFileExtension(modelFile.name))
|
|
||||||
? modelFile.url
|
|
||||||
: null,
|
|
||||||
previewImageUrl: previewImageFile?.url || null,
|
|
||||||
errorMessage: result.error || null,
|
|
||||||
conversationId: result.conversation_id || null,
|
|
||||||
modelVersion: result.model_version || null,
|
|
||||||
outputFormat: result.output_format || null,
|
|
||||||
codeUrl: codeFile?.url || null,
|
|
||||||
downloadUrls,
|
|
||||||
output: result,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof KittyCadApiError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
payload: error.payload,
|
|
||||||
},
|
|
||||||
{ status: error.httpStatus || 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,331 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import {
|
|
||||||
consumeUserPoints,
|
|
||||||
getPointsSystemConfig,
|
|
||||||
getUserById,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
|
||||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 60;
|
|
||||||
|
|
||||||
type CreateAiCadTaskRequest = {
|
|
||||||
prompt?: string;
|
|
||||||
outputFormat?: string;
|
|
||||||
projectName?: string;
|
|
||||||
iterationSourceTaskId?: string;
|
|
||||||
sourceCode?: string;
|
|
||||||
sourceRanges?: Record<string, unknown>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type KittyCadCreateResponse = {
|
|
||||||
id?: string;
|
|
||||||
status?: string;
|
|
||||||
conversation_id?: string | null;
|
|
||||||
model_version?: string | null;
|
|
||||||
output_format?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KittyCadSourceTaskResponse = {
|
|
||||||
code?: string | null;
|
|
||||||
outputs?: Record<string, string> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KittyCadIterationResponse = {
|
|
||||||
id?: string;
|
|
||||||
status?: string;
|
|
||||||
conversation_id?: string | null;
|
|
||||||
model_version?: string | null;
|
|
||||||
output_format?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getFileExtension(filename: string) {
|
|
||||||
const normalized = String(filename || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
|
||||||
const segments = cleanName.split(".");
|
|
||||||
return segments.length > 1 ? segments.at(-1) || "" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeBase64Text(value: unknown) {
|
|
||||||
const normalized = String(value || "").trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
|
|
||||||
return decoded || null;
|
|
||||||
} catch (_error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
|
|
||||||
const directCode = String(task.code || "").trim();
|
|
||||||
if (directCode) {
|
|
||||||
return directCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const kclOutputEntry = Object.entries(task.outputs || {}).find(
|
|
||||||
(entry): entry is [string, string] =>
|
|
||||||
typeof entry[0] === "string" &&
|
|
||||||
entry[0].trim().length > 0 &&
|
|
||||||
getFileExtension(entry[0]) === "kcl" &&
|
|
||||||
typeof entry[1] === "string" &&
|
|
||||||
entry[1].trim().length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!kclOutputEntry) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodeBase64Text(kclOutputEntry[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapKittyCadStatus(status: unknown) {
|
|
||||||
const normalized = String(status || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return "queued" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "queued" ||
|
|
||||||
normalized === "pending" ||
|
|
||||||
normalized === "created" ||
|
|
||||||
normalized === "submitted"
|
|
||||||
) {
|
|
||||||
return "queued" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "running" ||
|
|
||||||
normalized === "processing" ||
|
|
||||||
normalized === "in_progress" ||
|
|
||||||
normalized === "working" ||
|
|
||||||
normalized === "generating"
|
|
||||||
) {
|
|
||||||
return "running" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "completed" ||
|
|
||||||
normalized === "success" ||
|
|
||||||
normalized === "succeeded" ||
|
|
||||||
normalized === "done" ||
|
|
||||||
normalized === "finished"
|
|
||||||
) {
|
|
||||||
return "success" as const;
|
|
||||||
}
|
|
||||||
if (normalized === "cancelled" || normalized === "canceled") {
|
|
||||||
return "cancelled" as const;
|
|
||||||
}
|
|
||||||
if (normalized === "expired" || normalized === "timed_out") {
|
|
||||||
return "expired" as const;
|
|
||||||
}
|
|
||||||
if (normalized === "banned" || normalized === "blocked") {
|
|
||||||
return "banned" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
normalized === "failed" ||
|
|
||||||
normalized === "error" ||
|
|
||||||
normalized === "errored"
|
|
||||||
) {
|
|
||||||
return "failed" as const;
|
|
||||||
}
|
|
||||||
return "unknown" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json()) as CreateAiCadTaskRequest;
|
|
||||||
const prompt = String(body.prompt || "").trim();
|
|
||||||
|
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
|
||||||
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
|
|
||||||
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (
|
|
||||||
pointsConfig.enabled &&
|
|
||||||
unitCost > 0 &&
|
|
||||||
pointsConfig.blockOnInsufficient !== false
|
|
||||||
) {
|
|
||||||
const userData = await getUserById({ id: session.user.id });
|
|
||||||
if (!userData?.isHighVip) {
|
|
||||||
const currentBalance = Math.max(
|
|
||||||
0,
|
|
||||||
Number(userData?.pointsBalance || 0)
|
|
||||||
);
|
|
||||||
if (currentBalance < unitCost) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "积分不足",
|
|
||||||
code: "insufficient_points",
|
|
||||||
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
|
|
||||||
balance: currentBalance,
|
|
||||||
expectedCost: unitCost,
|
|
||||||
unitCost,
|
|
||||||
},
|
|
||||||
{ status: 402 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputFormat =
|
|
||||||
String(body.outputFormat || "glb")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase() || "glb";
|
|
||||||
const normalizedProjectName = String(body.projectName || "").trim();
|
|
||||||
const iterationSourceTaskId = String(
|
|
||||||
body.iterationSourceTaskId || ""
|
|
||||||
).trim();
|
|
||||||
const sourceCode = String(body.sourceCode || "");
|
|
||||||
const normalizedSourceCode = sourceCode.trim();
|
|
||||||
const sourceRanges = Array.isArray(body.sourceRanges)
|
|
||||||
? body.sourceRanges.filter(
|
|
||||||
(item): item is Record<string, unknown> =>
|
|
||||||
Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const config = await getKittyCadProviderConfig();
|
|
||||||
const generationMode =
|
|
||||||
iterationSourceTaskId || normalizedSourceCode ? "iterate" : "create";
|
|
||||||
|
|
||||||
if (generationMode === "create" && !prompt) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "请输入 CAD 描述" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iterationPrompt =
|
|
||||||
prompt || "Apply the edited KCL source and rebuild the CAD model.";
|
|
||||||
const result =
|
|
||||||
generationMode === "iterate"
|
|
||||||
? await (async () => {
|
|
||||||
const originalSourceCode =
|
|
||||||
normalizedSourceCode ||
|
|
||||||
(await (async () => {
|
|
||||||
if (!iterationSourceTaskId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceTask =
|
|
||||||
await kittyCadRequest<KittyCadSourceTaskResponse>(config, {
|
|
||||||
method: "GET",
|
|
||||||
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
|
|
||||||
});
|
|
||||||
return extractSourceCodeFromTask(sourceTask);
|
|
||||||
})());
|
|
||||||
|
|
||||||
if (!originalSourceCode) {
|
|
||||||
throw new KittyCadApiError({
|
|
||||||
message:
|
|
||||||
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
|
|
||||||
httpStatus: 409,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return kittyCadRequest<KittyCadIterationResponse>(config, {
|
|
||||||
method: "POST",
|
|
||||||
path: "/ml/text-to-cad/iteration",
|
|
||||||
json: {
|
|
||||||
original_source_code: originalSourceCode,
|
|
||||||
prompt: iterationPrompt,
|
|
||||||
source_ranges: sourceRanges,
|
|
||||||
...(normalizedProjectName
|
|
||||||
? { project_name: normalizedProjectName }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})()
|
|
||||||
: await kittyCadRequest<KittyCadCreateResponse>(config, {
|
|
||||||
method: "POST",
|
|
||||||
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
|
|
||||||
json: {
|
|
||||||
prompt,
|
|
||||||
...(normalizedProjectName
|
|
||||||
? { project_name: normalizedProjectName }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const taskId = String(result.id || "").trim();
|
|
||||||
if (!taskId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "KittyCAD 返回缺少任务 ID",
|
|
||||||
raw: result,
|
|
||||||
},
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pointsConfig.enabled && unitCost > 0) {
|
|
||||||
try {
|
|
||||||
await consumeUserPoints({
|
|
||||||
userId: session.user.id,
|
|
||||||
amount: unitCost,
|
|
||||||
reason: "model3d_generation",
|
|
||||||
modelId: "kittycad-cad",
|
|
||||||
note:
|
|
||||||
generationMode === "iterate"
|
|
||||||
? "AI-CAD 连续迭代消耗积分"
|
|
||||||
: "AI-CAD 生成消耗积分",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Points] Failed to consume AI-CAD points:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
taskId,
|
|
||||||
status: mapKittyCadStatus(result.status),
|
|
||||||
conversationId: result.conversation_id || null,
|
|
||||||
modelVersion: result.model_version || null,
|
|
||||||
outputFormat:
|
|
||||||
generationMode === "iterate"
|
|
||||||
? result.output_format || null
|
|
||||||
: result.output_format || outputFormat,
|
|
||||||
generationMode,
|
|
||||||
sourceTaskId: iterationSourceTaskId || null,
|
|
||||||
raw: result,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof KittyCadApiError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
payload: error.payload,
|
|
||||||
},
|
|
||||||
{ status: error.httpStatus || 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
import type { AiCadWorkspace } from "@/lib/db/schema";
|
|
||||||
|
|
||||||
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
|
|
||||||
|
|
||||||
const FINAL_TASK_STATUS = new Set([
|
|
||||||
"success",
|
|
||||||
"failed",
|
|
||||||
"banned",
|
|
||||||
"expired",
|
|
||||||
"cancelled",
|
|
||||||
"unknown",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const DEFAULT_VIEWER_SETTINGS = {
|
|
||||||
cameraPreset: "iso",
|
|
||||||
envPreset: "studio",
|
|
||||||
lightIntensity: 1.1,
|
|
||||||
autoRotate: true,
|
|
||||||
showGrid: true,
|
|
||||||
wireframe: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type WorkspaceVersionLike = {
|
|
||||||
status?: unknown;
|
|
||||||
prompt?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateAiCadWorkspaceRequest = {
|
|
||||||
name?: string;
|
|
||||||
prompt?: string;
|
|
||||||
generationProvider?: string;
|
|
||||||
modelVersion?: string;
|
|
||||||
viewerSettings?: unknown;
|
|
||||||
messages?: unknown;
|
|
||||||
versions?: unknown;
|
|
||||||
activeVersionId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeViewerSettings(
|
|
||||||
value: unknown
|
|
||||||
): Record<string, unknown> {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return { ...DEFAULT_VIEWER_SETTINGS };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cameraPreset:
|
|
||||||
value.cameraPreset === "top" ||
|
|
||||||
value.cameraPreset === "front" ||
|
|
||||||
value.cameraPreset === "right" ||
|
|
||||||
value.cameraPreset === "iso"
|
|
||||||
? value.cameraPreset
|
|
||||||
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
|
|
||||||
envPreset:
|
|
||||||
typeof value.envPreset === "string" && value.envPreset.trim()
|
|
||||||
? value.envPreset
|
|
||||||
: DEFAULT_VIEWER_SETTINGS.envPreset,
|
|
||||||
lightIntensity:
|
|
||||||
typeof value.lightIntensity === "number" &&
|
|
||||||
Number.isFinite(value.lightIntensity)
|
|
||||||
? value.lightIntensity
|
|
||||||
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
|
|
||||||
autoRotate:
|
|
||||||
typeof value.autoRotate === "boolean"
|
|
||||||
? value.autoRotate
|
|
||||||
: DEFAULT_VIEWER_SETTINGS.autoRotate,
|
|
||||||
showGrid:
|
|
||||||
typeof value.showGrid === "boolean"
|
|
||||||
? value.showGrid
|
|
||||||
: DEFAULT_VIEWER_SETTINGS.showGrid,
|
|
||||||
wireframe:
|
|
||||||
typeof value.wireframe === "boolean"
|
|
||||||
? value.wireframe
|
|
||||||
: DEFAULT_VIEWER_SETTINGS.wireframe,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeJsonRecordArray(
|
|
||||||
value: unknown
|
|
||||||
): Record<string, unknown>[] {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.filter((item): item is Record<string, unknown> =>
|
|
||||||
isRecord(item)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeWorkspaceName(
|
|
||||||
value: unknown,
|
|
||||||
fallbackName = "AI-CAD 工作台"
|
|
||||||
) {
|
|
||||||
return typeof value === "string" && value.trim()
|
|
||||||
? value.trim()
|
|
||||||
: fallbackName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
|
|
||||||
name?: string;
|
|
||||||
prompt: string;
|
|
||||||
generationProvider: CadGenerationProvider;
|
|
||||||
modelVersion: string;
|
|
||||||
viewerSettings: Record<string, unknown>;
|
|
||||||
messages: Record<string, unknown>[];
|
|
||||||
versions: Record<string, unknown>[];
|
|
||||||
activeVersionId: string | null;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
...(typeof body.name === "string" && body.name.trim()
|
|
||||||
? { name: body.name.trim() }
|
|
||||||
: {}),
|
|
||||||
prompt: String(body.prompt || ""),
|
|
||||||
generationProvider:
|
|
||||||
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
|
|
||||||
modelVersion:
|
|
||||||
typeof body.modelVersion === "string" && body.modelVersion.trim()
|
|
||||||
? body.modelVersion
|
|
||||||
: "Turbo-v1.0-20250506",
|
|
||||||
viewerSettings: normalizeViewerSettings(body.viewerSettings),
|
|
||||||
messages: normalizeJsonRecordArray(body.messages),
|
|
||||||
versions: normalizeJsonRecordArray(body.versions),
|
|
||||||
activeVersionId:
|
|
||||||
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
|
|
||||||
? body.activeVersionId
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDefaultWorkspaceName(index: number) {
|
|
||||||
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
|
|
||||||
const versions = getWorkspaceVersions(workspace.versions);
|
|
||||||
const versionCount = versions.length;
|
|
||||||
const completedCount = versions.filter(
|
|
||||||
(version) => version.status === "success"
|
|
||||||
).length;
|
|
||||||
const hasPendingTasks = versions.some(
|
|
||||||
(version) =>
|
|
||||||
typeof version.status !== "string" ||
|
|
||||||
!FINAL_TASK_STATUS.has(version.status)
|
|
||||||
);
|
|
||||||
const latestPrompt =
|
|
||||||
typeof workspace.prompt === "string" && workspace.prompt.trim()
|
|
||||||
? workspace.prompt.trim()
|
|
||||||
: versions.find(
|
|
||||||
(version) =>
|
|
||||||
typeof version.prompt === "string" && version.prompt.trim()
|
|
||||||
)?.prompt || null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: workspace.id,
|
|
||||||
name: workspace.name,
|
|
||||||
generationProvider: workspace.generationProvider,
|
|
||||||
versionCount,
|
|
||||||
completedCount,
|
|
||||||
hasPendingTasks,
|
|
||||||
latestPrompt:
|
|
||||||
typeof latestPrompt === "string" && latestPrompt.trim()
|
|
||||||
? latestPrompt.trim()
|
|
||||||
: null,
|
|
||||||
createdAt: workspace.createdAt?.toISOString() || null,
|
|
||||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
|
|
||||||
return {
|
|
||||||
id: workspace.id,
|
|
||||||
name: workspace.name,
|
|
||||||
prompt: workspace.prompt,
|
|
||||||
generationProvider: workspace.generationProvider,
|
|
||||||
modelVersion: workspace.modelVersion,
|
|
||||||
viewerSettings: workspace.viewerSettings,
|
|
||||||
messages: workspace.messages,
|
|
||||||
versions: workspace.versions,
|
|
||||||
activeVersionId: workspace.activeVersionId,
|
|
||||||
createdAt: workspace.createdAt?.toISOString() || null,
|
|
||||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import {
|
|
||||||
createWorkspaceDocument,
|
|
||||||
normalizeWorkspacePayload,
|
|
||||||
type UpdateAiCadWorkspaceRequest,
|
|
||||||
} from "@/app/api/ai-cad/workspace-utils";
|
|
||||||
import {
|
|
||||||
createAiCadWorkspace,
|
|
||||||
listAiCadWorkspacesByUserId,
|
|
||||||
updateAiCadWorkspace,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaces = await listAiCadWorkspacesByUserId({
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
const workspace = workspaces[0] ?? null;
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: createWorkspaceDocument(workspace),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
|
||||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
const targetWorkspace =
|
|
||||||
existingWorkspaces[0] ??
|
|
||||||
(await createAiCadWorkspace({
|
|
||||||
userId: session.user.id,
|
|
||||||
name: "AI-CAD 工作台",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!targetWorkspace) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "初始化 AI-CAD 工作台失败" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedWorkspace = await updateAiCadWorkspace({
|
|
||||||
workspaceId: targetWorkspace.id,
|
|
||||||
userId: session.user.id,
|
|
||||||
...normalizeWorkspacePayload(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!savedWorkspace) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "保存 AI-CAD 工作台失败" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import {
|
|
||||||
createWorkspaceDocument,
|
|
||||||
createWorkspaceSummary,
|
|
||||||
normalizeWorkspacePayload,
|
|
||||||
type UpdateAiCadWorkspaceRequest,
|
|
||||||
} from "@/app/api/ai-cad/workspace-utils";
|
|
||||||
import {
|
|
||||||
deleteAiCadWorkspaceById,
|
|
||||||
getAiCadWorkspaceById,
|
|
||||||
updateAiCadWorkspace,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
type RouteContext = {
|
|
||||||
params: Promise<{
|
|
||||||
workspaceId: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function GET(_request: Request, context: RouteContext) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { workspaceId } = await context.params;
|
|
||||||
const workspace = await getAiCadWorkspaceById({
|
|
||||||
userId: session.user.id,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: createWorkspaceDocument(workspace),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request, context: RouteContext) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { workspaceId } = await context.params;
|
|
||||||
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
|
||||||
const savedWorkspace = await updateAiCadWorkspace({
|
|
||||||
workspaceId,
|
|
||||||
userId: session.user.id,
|
|
||||||
...normalizeWorkspacePayload(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!savedWorkspace) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
id: savedWorkspace.id,
|
|
||||||
name: savedWorkspace.name,
|
|
||||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
|
||||||
summary: createWorkspaceSummary(savedWorkspace),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(_request: Request, context: RouteContext) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { workspaceId } = await context.params;
|
|
||||||
const deletedWorkspace = await deleteAiCadWorkspaceById({
|
|
||||||
workspaceId,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!deletedWorkspace) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
id: deletedWorkspace.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import {
|
|
||||||
buildDefaultWorkspaceName,
|
|
||||||
createWorkspaceSummary,
|
|
||||||
normalizeWorkspaceName,
|
|
||||||
} from "@/app/api/ai-cad/workspace-utils";
|
|
||||||
import {
|
|
||||||
createAiCadWorkspace,
|
|
||||||
listAiCadWorkspacesByUserId,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaces = await listAiCadWorkspacesByUserId({
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: workspaces.map(createWorkspaceSummary),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "未登录" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
|
|
||||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
const workspace = await createAiCadWorkspace({
|
|
||||||
userId: session.user.id,
|
|
||||||
name: normalizeWorkspaceName(
|
|
||||||
body.name,
|
|
||||||
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "创建 AI-CAD 工作台失败" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
data: createWorkspaceSummary(workspace),
|
|
||||||
},
|
|
||||||
{ status: 201 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal file
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const ALLOWED_WEBSOCKET_PATHS = new Set([
|
||||||
|
"/api/ai-cad/zoo-ws/ml/copilot",
|
||||||
|
"/api/ai-cad/zoo-ws/modeling/commands",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hasValidInternalSecret(request: Request) {
|
||||||
|
const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
|
||||||
|
const received = String(
|
||||||
|
request.headers.get("x-hicad-ws-proxy-secret") || ""
|
||||||
|
).trim();
|
||||||
|
if (!expected || !received) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
const receivedBuffer = Buffer.from(received);
|
||||||
|
return (
|
||||||
|
expectedBuffer.length === receivedBuffer.length &&
|
||||||
|
timingSafeEqual(expectedBuffer, receivedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!hasValidInternalSecret(request)) {
|
||||||
|
return NextResponse.json({ error: "禁止访问" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalUri = String(
|
||||||
|
request.headers.get("x-hicad-original-uri") || ""
|
||||||
|
).split("?", 1)[0];
|
||||||
|
if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
|
||||||
|
return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Zoo-Authorization": `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal file
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const HOP_BY_HOP_HEADERS = new Set([
|
||||||
|
"connection",
|
||||||
|
"content-length",
|
||||||
|
"host",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BLOCKED_PATH_PREFIXES = [
|
||||||
|
"/admin",
|
||||||
|
"/internal",
|
||||||
|
"/logout",
|
||||||
|
"/oauth2",
|
||||||
|
"/org/api-tokens",
|
||||||
|
"/user/api-tokens",
|
||||||
|
"/users",
|
||||||
|
];
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ path: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBlockedPath(pathname: string) {
|
||||||
|
return BLOCKED_PATH_PREFIXES.some(
|
||||||
|
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRequestHeaders(request: Request, apiKey: string) {
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [name, value] of request.headers.entries()) {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
if (
|
||||||
|
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
||||||
|
normalizedName === "authorization" ||
|
||||||
|
normalizedName === "cookie" ||
|
||||||
|
normalizedName === "origin" ||
|
||||||
|
normalizedName === "referer" ||
|
||||||
|
normalizedName.startsWith("x-forwarded-") ||
|
||||||
|
normalizedName === "x-hicad-ws-proxy-secret"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||||
|
headers.set("Accept", request.headers.get("accept") || "application/json");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyResponseHeaders(upstream: Response) {
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [name, value] of upstream.headers.entries()) {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
if (
|
||||||
|
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
||||||
|
normalizedName === "content-encoding" ||
|
||||||
|
normalizedName === "set-cookie"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set("Cache-Control", "private, no-store");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyZooRequest(request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = await context.params;
|
||||||
|
const pathname = `/${path.map(encodeURIComponent).join("/")}`;
|
||||||
|
if (isBlockedPath(pathname)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
const incomingUrl = new URL(request.url);
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
|
const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
const hasBody = method !== "GET" && method !== "HEAD";
|
||||||
|
const init: RequestInit & { duplex?: "half" } = {
|
||||||
|
body: hasBody ? request.body : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
duplex: hasBody ? "half" : undefined,
|
||||||
|
headers: copyRequestHeaders(request, config.apiKey),
|
||||||
|
method,
|
||||||
|
redirect: "manual",
|
||||||
|
signal: request.signal,
|
||||||
|
};
|
||||||
|
const upstream = await fetch(upstreamUrl, init);
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
headers: copyResponseHeaders(upstream),
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Zoo API 代理请求失败",
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DELETE = proxyZooRequest;
|
||||||
|
export const GET = proxyZooRequest;
|
||||||
|
export const HEAD = proxyZooRequest;
|
||||||
|
export const OPTIONS = proxyZooRequest;
|
||||||
|
export const PATCH = proxyZooRequest;
|
||||||
|
export const POST = proxyZooRequest;
|
||||||
|
export const PUT = proxyZooRequest;
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
findStyleProImageModelByRef,
|
findStyleProImageModelByRef,
|
||||||
getStyleProImageModelEntries,
|
getStyleProImageModelEntries,
|
||||||
} from "@/lib/style-pro/model-options";
|
} from "@/lib/style-pro/model-options";
|
||||||
|
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
prompt: z.string().min(1).max(5000),
|
prompt: z.string().min(1).max(5000),
|
||||||
@ -52,6 +53,16 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const availability = getAiModelFrontendAvailability(selectedModel.model);
|
||||||
|
if (availability.selectable === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const imageSize = body.imageSize?.trim() || "2K";
|
const imageSize = body.imageSize?.trim() || "2K";
|
||||||
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
createMediaAssetRelation,
|
createMediaAssetRelation,
|
||||||
getMediaAssetsByUserAndUrls,
|
getMediaAssetsByUserAndUrls,
|
||||||
|
getPointsSystemConfig,
|
||||||
getSystemSetting,
|
getSystemSetting,
|
||||||
upsertMediaAsset,
|
upsertMediaAsset,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
@ -10,6 +11,8 @@ import { uploadFile } from "@/lib/storage/service";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
|
||||||
const logger = createLogger("angle-edit-api");
|
const logger = createLogger("angle-edit-api");
|
||||||
|
|
||||||
@ -171,6 +174,19 @@ export async function POST(request: NextRequest) {
|
|||||||
zoom: finalZoom,
|
zoom: finalZoom,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
userId: session.user.id,
|
||||||
|
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
|
||||||
|
reason: "image_angle_edit",
|
||||||
|
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||||
|
note: "多角度编辑消耗积分",
|
||||||
|
pointsConfig,
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 准备图片输入
|
// 4. 准备图片输入
|
||||||
let imageInput: string;
|
let imageInput: string;
|
||||||
if (image_url) {
|
if (image_url) {
|
||||||
@ -256,6 +272,19 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
try {
|
||||||
|
await pointsCharge.consume({
|
||||||
|
note: `多角度编辑消耗积分(${savedImage.id})`,
|
||||||
|
});
|
||||||
|
} catch (pointsError) {
|
||||||
|
logger.error("points_consume_failed", pointsError, {
|
||||||
|
imageId: savedImage.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sourceAssets =
|
const sourceAssets =
|
||||||
typeof image_url === "string" && image_url.trim()
|
typeof image_url === "string" && image_url.trim()
|
||||||
|
|||||||
@ -4,23 +4,128 @@ import { auth } from "@/app/(auth)/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { systemSettings } from "@/lib/db/schema";
|
import { systemSettings } from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
|
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
|
||||||
|
CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
CANVAS_SKILL_TAGS_SETTING_KEY,
|
CANVAS_SKILL_TAGS_SETTING_KEY,
|
||||||
normalizeCanvasSkillTagSettings,
|
normalizeCanvasSkillTagSettings,
|
||||||
} from "@/lib/canvas-skill-tags";
|
} from "@/lib/canvas-skill-tags";
|
||||||
|
import { applyCanvasSkillTagOrder } from "@/lib/canvas-skill-tag-order";
|
||||||
|
|
||||||
|
function getUserSkillTagOrderKey(userId: string) {
|
||||||
|
return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkillTagOrder(value: unknown) {
|
||||||
|
const source =
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>).tagIds
|
||||||
|
: value;
|
||||||
|
if (!Array.isArray(source)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return source
|
||||||
|
.map((item) => String(item || "").trim())
|
||||||
|
.filter((item) => {
|
||||||
|
if (!item || seen.has(item)) return false;
|
||||||
|
seen.add(item);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth().catch(() => null);
|
||||||
|
const userId = session?.user?.id || null;
|
||||||
|
const [setting, userOrderSetting] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
|
||||||
|
.limit(1)
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
userId
|
||||||
|
? db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(eq(systemSettings.key, getUserSkillTagOrderKey(userId)))
|
||||||
|
.limit(1)
|
||||||
|
.then((rows) => rows[0])
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const settings = normalizeCanvasSkillTagSettings(setting?.value, {
|
||||||
|
requireCurrentVersion: true,
|
||||||
|
});
|
||||||
|
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
|
||||||
|
return NextResponse.json({
|
||||||
|
...settings,
|
||||||
|
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
|
||||||
|
userOrder,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CanvasSkillTags API] Get error:", error);
|
||||||
|
return NextResponse.json(normalizeCanvasSkillTagSettings(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const requestedOrder = normalizeSkillTagOrder(
|
||||||
|
body && typeof body === "object" ? (body as Record<string, unknown>).tagIds : body
|
||||||
|
);
|
||||||
|
|
||||||
const [setting] = await db
|
const [setting] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(systemSettings)
|
.from(systemSettings)
|
||||||
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
|
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
const settings = normalizeCanvasSkillTagSettings(setting?.value, {
|
||||||
|
requireCurrentVersion: true,
|
||||||
|
});
|
||||||
|
const knownIds = new Set(settings.tags.map((tag) => tag.id));
|
||||||
|
const orderedKnownIds = requestedOrder.filter((id) => knownIds.has(id));
|
||||||
|
const orderedSet = new Set(orderedKnownIds);
|
||||||
|
const tagIds = [
|
||||||
|
...orderedKnownIds,
|
||||||
|
...settings.tags.map((tag) => tag.id).filter((id) => !orderedSet.has(id)),
|
||||||
|
];
|
||||||
|
const value = {
|
||||||
|
version: 1,
|
||||||
|
tagIds,
|
||||||
|
};
|
||||||
|
const key = getUserSkillTagOrderKey(session.user.id);
|
||||||
|
|
||||||
const settings = normalizeCanvasSkillTagSettings(setting?.value);
|
await db
|
||||||
return NextResponse.json(settings);
|
.insert(systemSettings)
|
||||||
|
.values({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: systemSettings.key,
|
||||||
|
set: {
|
||||||
|
value,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
userOrder: tagIds,
|
||||||
|
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasSkillTags API] Get error:", error);
|
console.error("[CanvasSkillTags API] Save order error:", error);
|
||||||
return NextResponse.json(normalizeCanvasSkillTagSettings(null));
|
return NextResponse.json({ error: "Failed to save order" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,8 +140,15 @@ export async function PUT(request: NextRequest) {
|
|||||||
const normalized = normalizeCanvasSkillTagSettings(body);
|
const normalized = normalizeCanvasSkillTagSettings(body);
|
||||||
const enabled = normalized.enabled;
|
const enabled = normalized.enabled;
|
||||||
const title = normalized.title;
|
const title = normalized.title;
|
||||||
|
const categories = normalized.categories;
|
||||||
const tags = normalized.tags;
|
const tags = normalized.tags;
|
||||||
const nextValue = { enabled, title, tags };
|
const nextValue = {
|
||||||
|
enabled,
|
||||||
|
title,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@ -61,7 +173,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, enabled, title, tags });
|
return NextResponse.json({ success: true, enabled, title, categories, tags });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasSkillTags API] Update error:", error);
|
console.error("[CanvasSkillTags API] Update error:", error);
|
||||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||||
|
|||||||
23
app/api/canvas/[id]/agent-operations/route.ts
Normal file
23
app/api/canvas/[id]/agent-operations/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
|
||||||
|
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||||
|
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
const { id } = await params;
|
||||||
|
const response = await handleCanvasAgentOperationRouteRequest({
|
||||||
|
userId: session?.user?.id,
|
||||||
|
projectId: id,
|
||||||
|
readBody: () => request.json(),
|
||||||
|
features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
|
||||||
|
applyOperation: applyCanvasOperationPlan,
|
||||||
|
logger: console,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(response.body, { status: response.status });
|
||||||
|
}
|
||||||
91
app/api/canvas/[id]/capabilities/route.ts
Normal file
91
app/api/canvas/[id]/capabilities/route.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
||||||
|
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
||||||
|
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
||||||
|
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
||||||
|
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
||||||
|
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 600;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const requestId = new URL(request.url).searchParams.get("requestId");
|
||||||
|
if (!requestId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
capabilities: getCanvasCapabilityManifest(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const task = await getCanvasCapabilityTask({
|
||||||
|
requestId,
|
||||||
|
userId: session.user.id,
|
||||||
|
projectId: id,
|
||||||
|
});
|
||||||
|
if (!task) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "能力任务不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true, task });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
||||||
|
const result = await executeCanvasCapabilityRuntime({
|
||||||
|
command,
|
||||||
|
projectId: id,
|
||||||
|
requestHeaders: request.headers,
|
||||||
|
requestUrl: request.url,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof CanvasCapabilityRuntimeError ||
|
||||||
|
error instanceof CanvasAgentMutationError
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: error.message, code: error.code },
|
||||||
|
{ status: error.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "画布能力参数无效",
|
||||||
|
issues: error.issues,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/canvas/[id]/revision/route.ts
Normal file
53
app/api/canvas/[id]/revision/route.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
const canvasIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "画布 ID 无效" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsedId.data;
|
||||||
|
const [project] = await db
|
||||||
|
.select({ updatedAt: canvasProject.updatedAt })
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.id, id),
|
||||||
|
eq(canvasProject.userId, session.user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ success: true, data: { updatedAt: project.updatedAt.toISOString() } },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,10 @@ import { db } from '@/lib/db';
|
|||||||
import { canvasProject } from '@/lib/db/schema';
|
import { canvasProject } from '@/lib/db/schema';
|
||||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
buildStaleCanvasSaveConflictPayload,
|
||||||
|
isStaleCanvasSaveBaseRevision,
|
||||||
|
} from '@/lib/canvas/canvas-save-conflict';
|
||||||
|
|
||||||
function resolveWorkspaceType(input: unknown) {
|
function resolveWorkspaceType(input: unknown) {
|
||||||
return input === "agent" || input === "video" ? input : "canvas";
|
return input === "agent" || input === "video" ? input : "canvas";
|
||||||
@ -13,19 +17,67 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
|
|||||||
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
|
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseLocalCanvasE2E(request: NextRequest) {
|
||||||
|
const hostname = request.nextUrl.hostname;
|
||||||
|
return (
|
||||||
|
process.env.CANVAS_E2E_LOCAL === "true" &&
|
||||||
|
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalCanvasProject(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
canvasData?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
thumbnail?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
workspaceType?: unknown;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const workspaceType = resolveWorkspaceType(input.workspaceType || "video");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId: "local-video-canvas-e2e-user",
|
||||||
|
title: String(input.title || getDefaultWorkspaceTitle(workspaceType)),
|
||||||
|
description: typeof input.description === "string" ? input.description : null,
|
||||||
|
thumbnail: typeof input.thumbnail === "string" ? input.thumbnail : null,
|
||||||
|
workspaceType,
|
||||||
|
canvasData:
|
||||||
|
input.canvasData && typeof input.canvasData === "object"
|
||||||
|
? input.canvasData
|
||||||
|
: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
selectedNodeIds: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
tool: "select",
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 获取单个画布
|
// 获取单个画布
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
if (shouldUseLocalCanvasE2E(request)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: createLocalCanvasProject(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const [project] = await db
|
const [project] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(canvasProject)
|
.from(canvasProject)
|
||||||
@ -53,26 +105,26 @@ export async function PUT(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
if (shouldUseLocalCanvasE2E(request)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: createLocalCanvasProject(id, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await request.json();
|
|
||||||
const { title, description, thumbnail, canvasData } = body;
|
const { title, description, thumbnail, canvasData } = body;
|
||||||
|
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||||
const workspaceType =
|
const workspaceType =
|
||||||
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
|
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
|
||||||
? resolveWorkspaceType(body.workspaceType)
|
? resolveWorkspaceType(body.workspaceType)
|
||||||
: undefined;
|
: undefined;
|
||||||
const syncedCanvasData =
|
|
||||||
canvasData !== undefined
|
|
||||||
? await syncCanvasNodesForProject({
|
|
||||||
projectId: id,
|
|
||||||
userId: session.user.id,
|
|
||||||
canvasData,
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// 先检查画布是否存在
|
// 先检查画布是否存在
|
||||||
const [existingProject] = await db
|
const [existingProject] = await db
|
||||||
@ -86,6 +138,30 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (existingProject) {
|
if (existingProject) {
|
||||||
|
if (
|
||||||
|
canvasData !== undefined &&
|
||||||
|
baseCanvasRevision &&
|
||||||
|
isStaleCanvasSaveBaseRevision({
|
||||||
|
baseCanvasRevision: body?.baseCanvasRevision,
|
||||||
|
baseCanvasUpdatedAt: body?.baseCanvasUpdatedAt,
|
||||||
|
currentUpdatedAt: existingProject.updatedAt,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedCanvasData =
|
||||||
|
canvasData !== undefined
|
||||||
|
? await syncCanvasNodesForProject({
|
||||||
|
projectId: id,
|
||||||
|
userId: session.user.id,
|
||||||
|
canvasData,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 更新现有画布
|
// 更新现有画布
|
||||||
const updateData: any = { updatedAt: new Date() };
|
const updateData: any = { updatedAt: new Date() };
|
||||||
if (title !== undefined) updateData.title = title;
|
if (title !== undefined) updateData.title = title;
|
||||||
@ -107,23 +183,62 @@ export async function PUT(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, data: updatedProject });
|
return NextResponse.json({ success: true, data: updatedProject });
|
||||||
} else {
|
} else {
|
||||||
// 创建新画布(使用指定 ID)
|
const syncedCanvasData =
|
||||||
|
canvasData !== undefined
|
||||||
|
? await syncCanvasNodesForProject({
|
||||||
|
projectId: id,
|
||||||
|
userId: session.user.id,
|
||||||
|
canvasData,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const nextWorkspaceType = workspaceType || "canvas";
|
||||||
|
const nextCanvasData = syncedCanvasData || {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
};
|
||||||
|
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
|
||||||
|
const insertData: any = {
|
||||||
|
id, // 使用指定的 ID
|
||||||
|
userId: session.user.id,
|
||||||
|
title: nextTitle,
|
||||||
|
workspaceType: nextWorkspaceType,
|
||||||
|
canvasData: nextCanvasData,
|
||||||
|
};
|
||||||
|
const conflictUpdateData: any = {
|
||||||
|
title: nextTitle,
|
||||||
|
workspaceType: nextWorkspaceType,
|
||||||
|
canvasData: nextCanvasData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (description !== undefined) {
|
||||||
|
insertData.description = description;
|
||||||
|
conflictUpdateData.description = description;
|
||||||
|
}
|
||||||
|
if (thumbnail !== undefined) {
|
||||||
|
insertData.thumbnail = thumbnail;
|
||||||
|
conflictUpdateData.thumbnail = thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
|
||||||
const [newProject] = await db
|
const [newProject] = await db
|
||||||
.insert(canvasProject)
|
.insert(canvasProject)
|
||||||
.values({
|
.values(insertData)
|
||||||
id, // 使用指定的 ID
|
.onConflictDoUpdate({
|
||||||
userId: session.user.id,
|
target: canvasProject.id,
|
||||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
set: conflictUpdateData,
|
||||||
description,
|
where: eq(canvasProject.userId, session.user.id),
|
||||||
workspaceType: workspaceType || "canvas",
|
|
||||||
canvasData: syncedCanvasData || {
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (!newProject) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '画布ID已被占用' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: newProject });
|
return NextResponse.json({ success: true, data: newProject });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
92
app/api/canvas/clawd-chat/cancel/route.ts
Normal file
92
app/api/canvas/clawd-chat/cancel/route.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getChatById, getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
buildClawdCancelKey,
|
||||||
|
cancelClawdRequest,
|
||||||
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
|
import {
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatConfig,
|
||||||
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
deletePersistedClawdTask,
|
||||||
|
getPersistedClawdTask,
|
||||||
|
} from "@/lib/openclaw-chat/task-store";
|
||||||
|
import {
|
||||||
|
getPersistedUpstreamTaskState,
|
||||||
|
stopOpenClawDurableTask,
|
||||||
|
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||||
|
|
||||||
|
const cancelSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
userMessageId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth().catch(() => null);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
|
||||||
|
if (existingChat && existingChat.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelKey = buildClawdCancelKey({
|
||||||
|
userId: session.user.id,
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
});
|
||||||
|
const task = await getPersistedClawdTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
}).catch(() => null);
|
||||||
|
const active = cancelClawdRequest(cancelKey);
|
||||||
|
const upstreamTask = task
|
||||||
|
? getPersistedUpstreamTaskState(task.metadata)
|
||||||
|
: null;
|
||||||
|
if (upstreamTask) {
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = normalizeOpenClawChatConfig(setting?.value);
|
||||||
|
if (config.enabled && config.baseUrl && config.token) {
|
||||||
|
await stopOpenClawDurableTask({ config, upstreamTask }).catch((error) => {
|
||||||
|
console.warn("[ClawdChat Cancel] Upstream stop deferred:", {
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
taskId: upstreamTask.taskId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await deletePersistedClawdTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
}).catch(() => []);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
canceled: true,
|
||||||
|
active,
|
||||||
|
deleted: deleted.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
141
app/api/canvas/clawd-chat/folder-upload/route.ts
Normal file
141
app/api/canvas/clawd-chat/folder-upload/route.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import JSZip from "jszip";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getAllStorageConfigs } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
countOpenClawArchiveRootFolders,
|
||||||
|
sanitizeOpenClawArchiveEntryPath,
|
||||||
|
sanitizeOpenClawArchiveName,
|
||||||
|
} from "@/lib/openclaw-chat/folder-archive";
|
||||||
|
import {
|
||||||
|
OPENCLAW_CHAT_FOLDER_MAX_FILES,
|
||||||
|
} from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
toDbS3UploadConfig,
|
||||||
|
uploadFileWithS3Config,
|
||||||
|
} from "@/lib/storage/service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 600;
|
||||||
|
|
||||||
|
const logger = createLogger("openclaw-folder-upload");
|
||||||
|
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isUploadedFile(value: FormDataEntryValue): value is File {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"arrayBuffer" in value &&
|
||||||
|
"name" in value &&
|
||||||
|
"size" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(buffer: Buffer) {
|
||||||
|
return buffer.buffer.slice(
|
||||||
|
buffer.byteOffset,
|
||||||
|
buffer.byteOffset + buffer.byteLength
|
||||||
|
) as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const files = formData
|
||||||
|
.getAll("files")
|
||||||
|
.filter((entry): entry is File => isUploadedFile(entry));
|
||||||
|
const relativePaths = formData
|
||||||
|
.getAll("relativePaths")
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : ""));
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "所选文件夹中没有可上传文件" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||||
|
if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivePaths = files.map((file, index) =>
|
||||||
|
sanitizeOpenClawArchiveEntryPath(
|
||||||
|
relativePaths[index] || file.name,
|
||||||
|
file.name,
|
||||||
|
index
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const [index, file] of files.entries()) {
|
||||||
|
zip.file(archivePaths[index], await file.arrayBuffer(), {
|
||||||
|
date: new Date(file.lastModified || Date.now()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveBaseName = sanitizeOpenClawArchiveName(
|
||||||
|
String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "")
|
||||||
|
);
|
||||||
|
const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`;
|
||||||
|
const archiveBuffer = await zip.generateAsync({
|
||||||
|
type: "nodebuffer",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
compressionOptions: { level: 6 },
|
||||||
|
platform: "UNIX",
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageConfigs = await getAllStorageConfigs();
|
||||||
|
const qiniuStorage = storageConfigs
|
||||||
|
.filter((config) => config.type === "qiniu-kodo" && config.isEnabled)
|
||||||
|
.sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
|
||||||
|
.find((config) => toDbS3UploadConfig(config));
|
||||||
|
const qiniuConfig = toDbS3UploadConfig(qiniuStorage);
|
||||||
|
if (!qiniuConfig) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "系统尚未配置可用的七牛云储存桶" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await uploadFileWithS3Config(
|
||||||
|
archiveName,
|
||||||
|
toArrayBuffer(archiveBuffer),
|
||||||
|
qiniuConfig
|
||||||
|
);
|
||||||
|
if (!/^https?:\/\//i.test(uploaded.url)) {
|
||||||
|
throw new Error("七牛云未返回可访问的公网地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
url: uploaded.url,
|
||||||
|
name: archiveName,
|
||||||
|
contentType: "application/zip",
|
||||||
|
size: archiveBuffer.byteLength,
|
||||||
|
originalSize,
|
||||||
|
fileCount: files.length,
|
||||||
|
folderCount: countOpenClawArchiveRootFolders(archivePaths),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to package and upload Claw folders", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "文件夹上传失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/api/canvas/clawd-chat/history/route.ts
Normal file
52
app/api/canvas/clawd-chat/history/route.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getClawdChatsByUserId } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth().catch(() => null);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const chats = await getClawdChatsByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
chats: chats.map((chat) => ({
|
||||||
|
id: chat.id,
|
||||||
|
title: chat.title,
|
||||||
|
createdAt: chat.createdAt?.toISOString?.() || chat.createdAt,
|
||||||
|
updatedAt: chat.updatedAt?.toISOString?.() || chat.updatedAt,
|
||||||
|
messageCount: Number(chat.messageCount || 0),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store, max-age=0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
643
app/api/canvas/clawd-chat/route.ts
Normal file
643
app/api/canvas/clawd-chat/route.ts
Normal file
@ -0,0 +1,643 @@
|
|||||||
|
import { after, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
getChatById,
|
||||||
|
getSystemSetting,
|
||||||
|
getUserById,
|
||||||
|
saveChat,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
buildClawdCancelKey,
|
||||||
|
clearClawdRequestCancellation,
|
||||||
|
isClawdRequestCanceled,
|
||||||
|
registerActiveClawdRequest,
|
||||||
|
unregisterActiveClawdRequest,
|
||||||
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatConfig,
|
||||||
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
getOpenClawTaskFailureMessage,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatMediaAttachments,
|
||||||
|
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
|
||||||
|
type OpenClawChatMediaAttachment,
|
||||||
|
} from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
import {
|
||||||
|
buildOpenClawDisplayMessage,
|
||||||
|
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||||
|
buildOpenClawSpreadsheetMarkdown,
|
||||||
|
} from "@/lib/openclaw-chat/spreadsheet-markdown";
|
||||||
|
import {
|
||||||
|
appendPersistedClawdTaskProgress,
|
||||||
|
getPersistedClawdTask,
|
||||||
|
markClawdTaskCanceled,
|
||||||
|
markClawdTaskCompleted,
|
||||||
|
markClawdTaskFailed,
|
||||||
|
markClawdTaskRunning,
|
||||||
|
persistPendingClawdTask,
|
||||||
|
} from "@/lib/openclaw-chat/task-store";
|
||||||
|
import {
|
||||||
|
OpenClawTaskDetachedError,
|
||||||
|
submitOpenClawDurableTask,
|
||||||
|
waitForOpenClawDurableTask,
|
||||||
|
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DEFAULT_PROCESSING_HINT =
|
||||||
|
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
||||||
|
|
||||||
|
export const maxDuration = 1800;
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
message: z.string().trim().max(8000).optional().default(""),
|
||||||
|
displayMessage: z.string().trim().max(8000).optional(),
|
||||||
|
userMessageId: z.string().uuid().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
sensitiveInput: z
|
||||||
|
.discriminatedUnion("type", [
|
||||||
|
z.object({
|
||||||
|
type: z.literal("taobao_credentials"),
|
||||||
|
username: z.string().min(1).max(160),
|
||||||
|
password: z.string().min(1).max(256),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("taobao_verification"),
|
||||||
|
verification_code: z.string().min(4).max(12),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
attachments: z
|
||||||
|
.array(z.unknown())
|
||||||
|
.max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS)
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ParsedClawdChatRequest = Omit<
|
||||||
|
z.infer<typeof requestSchema>,
|
||||||
|
"attachments"
|
||||||
|
> & {
|
||||||
|
attachments: OpenClawChatMediaAttachment[];
|
||||||
|
tableFiles: File[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClawdSpreadsheetAttachment = {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
sheetCount: number;
|
||||||
|
rowCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecuteClawdTaskParams = {
|
||||||
|
assistantMessageId: string;
|
||||||
|
cancelKey: string;
|
||||||
|
chatId: string;
|
||||||
|
config: { baseUrl: string; token: string; timeoutMs: number };
|
||||||
|
conversationId: string;
|
||||||
|
departmentName: string;
|
||||||
|
metadata: Record<string, unknown> | undefined;
|
||||||
|
sensitiveInput?: z.infer<typeof requestSchema>["sensitiveInput"];
|
||||||
|
mediaAttachments: OpenClawChatMediaAttachment[];
|
||||||
|
skillTagId: string;
|
||||||
|
skillTagName: string;
|
||||||
|
spreadsheetFiles: unknown[];
|
||||||
|
spreadsheetMarkdownTruncated: boolean;
|
||||||
|
upstreamMessage: string;
|
||||||
|
userId: string;
|
||||||
|
userMessageId: string;
|
||||||
|
userName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildChatTitle(message: string) {
|
||||||
|
const normalized = message.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) return "Claw 对话";
|
||||||
|
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConversationId(userId: string, chatId: string) {
|
||||||
|
return `site-user-${userId}-canvas-${chatId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenClawUserName(user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}) {
|
||||||
|
return String(user.name || user.email || user.id).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadataString(
|
||||||
|
metadata: Record<string, unknown> | undefined,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
const value = metadata?.[key];
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequestFile(value: FormDataEntryValue): value is File {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"arrayBuffer" in value &&
|
||||||
|
"name" in value &&
|
||||||
|
"size" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseClawdChatRequest(
|
||||||
|
request: Request
|
||||||
|
): Promise<ParsedClawdChatRequest | null> {
|
||||||
|
const contentType = request.headers.get("content-type") || "";
|
||||||
|
if (!contentType.includes("multipart/form-data")) {
|
||||||
|
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
const attachments = normalizeOpenClawChatMediaAttachments(
|
||||||
|
parsed.data.attachments
|
||||||
|
);
|
||||||
|
if (attachments.length !== parsed.data.attachments.length) return null;
|
||||||
|
return { ...parsed.data, attachments, tableFiles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData().catch(() => null);
|
||||||
|
if (!formData) return null;
|
||||||
|
|
||||||
|
let metadata: Record<string, unknown> | undefined;
|
||||||
|
let attachments: unknown[] = [];
|
||||||
|
let sensitiveInput: unknown;
|
||||||
|
const metadataValue = formData.get("metadata");
|
||||||
|
const attachmentsValue = formData.get("attachments");
|
||||||
|
const sensitiveInputValue = formData.get("sensitiveInput");
|
||||||
|
try {
|
||||||
|
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
||||||
|
const parsedMetadata = JSON.parse(metadataValue);
|
||||||
|
if (!parsedMetadata || typeof parsedMetadata !== "object") return null;
|
||||||
|
metadata = parsedMetadata as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (typeof attachmentsValue === "string" && attachmentsValue.trim()) {
|
||||||
|
const parsedAttachments = JSON.parse(attachmentsValue);
|
||||||
|
if (!Array.isArray(parsedAttachments)) return null;
|
||||||
|
attachments = parsedAttachments;
|
||||||
|
}
|
||||||
|
if (typeof sensitiveInputValue === "string" && sensitiveInputValue.trim()) {
|
||||||
|
sensitiveInput = JSON.parse(sensitiveInputValue);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = requestSchema.safeParse({
|
||||||
|
id: formData.get("id"),
|
||||||
|
message: formData.get("message") || "",
|
||||||
|
displayMessage: formData.get("displayMessage") || undefined,
|
||||||
|
userMessageId: formData.get("userMessageId") || undefined,
|
||||||
|
metadata,
|
||||||
|
attachments,
|
||||||
|
sensitiveInput,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
const normalizedAttachments = normalizeOpenClawChatMediaAttachments(
|
||||||
|
parsed.data.attachments
|
||||||
|
);
|
||||||
|
if (normalizedAttachments.length !== parsed.data.attachments.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed.data,
|
||||||
|
attachments: normalizedAttachments,
|
||||||
|
tableFiles: formData
|
||||||
|
.getAll("tableFiles")
|
||||||
|
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOpenClawConfig() {
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
return normalizeOpenClawChatConfig(setting?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnswer(payload: unknown) {
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String((payload as Record<string, unknown>).answer || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestId(payload: unknown) {
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = (payload as Record<string, unknown>).request_id;
|
||||||
|
return value ? String(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
registerActiveClawdRequest(
|
||||||
|
params.cancelKey,
|
||||||
|
controller,
|
||||||
|
Math.max(params.config.timeoutMs, 10 * 60 * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||||
|
await markClawdTaskRunning({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const upstreamBody: Record<string, unknown> = {
|
||||||
|
conversation_id: params.conversationId,
|
||||||
|
user_id: params.userId,
|
||||||
|
user_name: params.userName,
|
||||||
|
username: params.userName,
|
||||||
|
department: params.departmentName,
|
||||||
|
department_name: params.departmentName,
|
||||||
|
user_department: params.departmentName,
|
||||||
|
skill_tag_id: params.skillTagId || undefined,
|
||||||
|
skill_tag_name: params.skillTagName || undefined,
|
||||||
|
message: params.upstreamMessage,
|
||||||
|
attachments:
|
||||||
|
params.mediaAttachments.length > 0
|
||||||
|
? params.mediaAttachments
|
||||||
|
: undefined,
|
||||||
|
metadata: {
|
||||||
|
source: "ai_chatbot_canvas",
|
||||||
|
page: "/canvas",
|
||||||
|
tenant: "default",
|
||||||
|
chat_id: params.chatId,
|
||||||
|
user_id: params.userId,
|
||||||
|
user_name: params.userName,
|
||||||
|
username: params.userName,
|
||||||
|
department: params.departmentName,
|
||||||
|
department_name: params.departmentName,
|
||||||
|
user_department: params.departmentName,
|
||||||
|
skill_tag_id: params.skillTagId || undefined,
|
||||||
|
skill_tag_name: params.skillTagName || undefined,
|
||||||
|
media_attachments:
|
||||||
|
params.mediaAttachments.length > 0
|
||||||
|
? params.mediaAttachments
|
||||||
|
: undefined,
|
||||||
|
spreadsheet_files:
|
||||||
|
params.spreadsheetFiles.length > 0
|
||||||
|
? params.spreadsheetFiles
|
||||||
|
: undefined,
|
||||||
|
spreadsheet_markdown_truncated:
|
||||||
|
params.spreadsheetMarkdownTruncated || undefined,
|
||||||
|
...params.metadata,
|
||||||
|
},
|
||||||
|
stream_progress: false,
|
||||||
|
sensitive_input: params.sensitiveInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.sensitiveInput) {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() =>
|
||||||
|
controller.abort(
|
||||||
|
new DOMException("OpenClaw request timed out", "TimeoutError")
|
||||||
|
),
|
||||||
|
params.config.timeoutMs
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${params.config.baseUrl}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${params.config.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(upstreamBody),
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const payload = (await response.json().catch(() => null)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenClaw request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!payload) throw new Error("OpenClaw returned an invalid response");
|
||||||
|
const answer = getAnswer(payload);
|
||||||
|
if (!answer) throw new Error("OpenClaw returned an empty answer");
|
||||||
|
await markClawdTaskCompleted({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
answer,
|
||||||
|
requestId: getRequestId(payload),
|
||||||
|
});
|
||||||
|
clearClawdRequestCancellation(params.cancelKey);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
};
|
||||||
|
const upstreamTask = await submitOpenClawDurableTask({
|
||||||
|
config: params.config,
|
||||||
|
body: upstreamBody,
|
||||||
|
idempotencyKey: `${params.userId}:${params.chatId}:${params.userMessageId}`,
|
||||||
|
target,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const outcome = await waitForOpenClawDurableTask({
|
||||||
|
config: params.config,
|
||||||
|
upstreamTask,
|
||||||
|
target,
|
||||||
|
deadlineAt: Date.now() + params.config.timeoutMs,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||||
|
if (outcome.status === "completed") {
|
||||||
|
await markClawdTaskCompleted({
|
||||||
|
...target,
|
||||||
|
answer: outcome.answer,
|
||||||
|
requestId: outcome.requestId,
|
||||||
|
});
|
||||||
|
} else if (outcome.status === "canceled") {
|
||||||
|
await markClawdTaskCanceled({
|
||||||
|
...target,
|
||||||
|
message: outcome.error,
|
||||||
|
});
|
||||||
|
} else if (outcome.status === "failed") {
|
||||||
|
await markClawdTaskFailed({
|
||||||
|
...target,
|
||||||
|
error: outcome.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearClawdRequestCancellation(params.cancelKey);
|
||||||
|
} catch (error) {
|
||||||
|
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||||
|
if (error instanceof OpenClawTaskDetachedError) {
|
||||||
|
await appendPersistedClawdTaskProgress({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
progress: {
|
||||||
|
step_code: "task.detached",
|
||||||
|
step_state: "waiting",
|
||||||
|
message: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
|
||||||
|
display: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
|
||||||
|
},
|
||||||
|
}).catch(() => null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[ClawdChat API] Durable task failed:", error);
|
||||||
|
await markClawdTaskFailed({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
error: getOpenClawTaskFailureMessage(error),
|
||||||
|
}).catch(() => null);
|
||||||
|
} finally {
|
||||||
|
if (params.sensitiveInput?.type === "taobao_credentials") {
|
||||||
|
params.sensitiveInput.username = "";
|
||||||
|
params.sensitiveInput.password = "";
|
||||||
|
} else if (params.sensitiveInput?.type === "taobao_verification") {
|
||||||
|
params.sensitiveInput.verification_code = "";
|
||||||
|
}
|
||||||
|
params.sensitiveInput = undefined;
|
||||||
|
unregisterActiveClawdRequest(params.cancelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskResponse(params: {
|
||||||
|
chatId: string;
|
||||||
|
userMessageId: string;
|
||||||
|
assistantMessageId: string;
|
||||||
|
status: string;
|
||||||
|
processingHint: string;
|
||||||
|
}) {
|
||||||
|
const statusUrl = `/api/canvas/clawd-chat/status?id=${encodeURIComponent(
|
||||||
|
params.chatId
|
||||||
|
)}&userMessageId=${encodeURIComponent(params.userMessageId)}`;
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
task: {
|
||||||
|
chatId: params.chatId,
|
||||||
|
userMessageId: params.userMessageId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
status: params.status,
|
||||||
|
processingHint: params.processingHint,
|
||||||
|
statusUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth().catch(() => null);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = await parseClawdChatRequest(request);
|
||||||
|
if (!parsed) {
|
||||||
|
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!parsed.message &&
|
||||||
|
parsed.tableFiles.length === 0 &&
|
||||||
|
parsed.attachments.length === 0
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请输入问题或上传附件" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readOpenClawConfig();
|
||||||
|
if (!config.enabled || !config.baseUrl || !config.token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingChat = await getChatById({ id: parsed.id }).catch(() => null);
|
||||||
|
if (existingChat && existingChat.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessageId = parsed.userMessageId || generateUUID();
|
||||||
|
const existingTask = await getPersistedClawdTask({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
}).catch(() => null);
|
||||||
|
if (existingTask) {
|
||||||
|
return NextResponse.json(
|
||||||
|
buildTaskResponse({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId: existingTask.metadata.assistantMessageId,
|
||||||
|
status: existingTask.metadata.status,
|
||||||
|
processingHint:
|
||||||
|
existingTask.metadata.processingHint || DEFAULT_PROCESSING_HINT,
|
||||||
|
}),
|
||||||
|
{ status: 202, headers: { "Retry-After": "2" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let spreadsheetMarkdownResult: Awaited<
|
||||||
|
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
|
||||||
|
> | null = null;
|
||||||
|
if (parsed.tableFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
spreadsheetMarkdownResult = await buildOpenClawSpreadsheetMarkdown(
|
||||||
|
parsed.tableFiles
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "表格解析失败,请检查文件格式",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMessage = buildOpenClawDisplayMessage({
|
||||||
|
message: parsed.displayMessage || parsed.message,
|
||||||
|
files: spreadsheetMarkdownResult?.files || [],
|
||||||
|
});
|
||||||
|
const upstreamMessage = buildOpenClawMessageWithSpreadsheetMarkdown({
|
||||||
|
message: parsed.message,
|
||||||
|
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||||
|
});
|
||||||
|
const spreadsheetAttachments: ClawdSpreadsheetAttachment[] = (
|
||||||
|
spreadsheetMarkdownResult?.files || []
|
||||||
|
).map((summary, index) => {
|
||||||
|
const sourceFile = parsed.tableFiles[index];
|
||||||
|
return {
|
||||||
|
name: summary.fileName,
|
||||||
|
size: sourceFile?.size || 0,
|
||||||
|
contentType:
|
||||||
|
sourceFile?.type ||
|
||||||
|
(summary.fileName.toLowerCase().endsWith(".xlsx")
|
||||||
|
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
: "text/csv"),
|
||||||
|
sheetCount: summary.sheetCount,
|
||||||
|
rowCount: summary.rowCount,
|
||||||
|
truncated: summary.truncated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingChat) {
|
||||||
|
await saveChat({
|
||||||
|
id: parsed.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
title: buildChatTitle(displayMessage),
|
||||||
|
visibility: "private",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessageId = generateUUID();
|
||||||
|
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
||||||
|
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
||||||
|
const processingHint =
|
||||||
|
getMetadataString(parsed.metadata, "processingHint") ||
|
||||||
|
DEFAULT_PROCESSING_HINT;
|
||||||
|
const messageAttachments = parsed.attachments.map((attachment) => ({
|
||||||
|
name:
|
||||||
|
attachment.name ||
|
||||||
|
(attachment.type === "video"
|
||||||
|
? "视频附件"
|
||||||
|
: attachment.type === "file"
|
||||||
|
? "文件附件"
|
||||||
|
: "图片附件"),
|
||||||
|
url: attachment.url,
|
||||||
|
contentType:
|
||||||
|
attachment.content_type ||
|
||||||
|
(attachment.type === "video"
|
||||||
|
? "video/mp4"
|
||||||
|
: attachment.type === "file"
|
||||||
|
? "application/octet-stream"
|
||||||
|
: "image/jpeg"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await persistPendingClawdTask({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId,
|
||||||
|
displayMessage,
|
||||||
|
attachments: messageAttachments,
|
||||||
|
spreadsheetAttachments,
|
||||||
|
skillTagId,
|
||||||
|
skillTagName,
|
||||||
|
processingHint,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userProfile = await getUserById({ id: session.user.id }).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
const userName = getOpenClawUserName({
|
||||||
|
id: session.user.id,
|
||||||
|
name: userProfile?.name ?? session.user.name,
|
||||||
|
email: userProfile?.email ?? session.user.email,
|
||||||
|
});
|
||||||
|
const cancelKey = buildClawdCancelKey({
|
||||||
|
userId: session.user.id,
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await executeClawdTask({
|
||||||
|
assistantMessageId,
|
||||||
|
cancelKey,
|
||||||
|
chatId: parsed.id,
|
||||||
|
config,
|
||||||
|
conversationId: createConversationId(session.user.id, parsed.id),
|
||||||
|
departmentName: String(userProfile?.department || "").trim(),
|
||||||
|
metadata: parsed.metadata,
|
||||||
|
sensitiveInput: parsed.sensitiveInput,
|
||||||
|
mediaAttachments: parsed.attachments,
|
||||||
|
skillTagId,
|
||||||
|
skillTagName,
|
||||||
|
spreadsheetFiles: spreadsheetMarkdownResult?.files || [],
|
||||||
|
spreadsheetMarkdownTruncated:
|
||||||
|
spreadsheetMarkdownResult?.truncated || false,
|
||||||
|
upstreamMessage,
|
||||||
|
userId: session.user.id,
|
||||||
|
userMessageId,
|
||||||
|
userName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = buildTaskResponse({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId,
|
||||||
|
status: "queued",
|
||||||
|
processingHint,
|
||||||
|
});
|
||||||
|
return NextResponse.json(responseBody, {
|
||||||
|
status: 202,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store, max-age=0",
|
||||||
|
Location: responseBody.task.statusUrl,
|
||||||
|
"Retry-After": "2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
190
app/api/canvas/clawd-chat/status/route.ts
Normal file
190
app/api/canvas/clawd-chat/status/route.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getChatById, getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
buildClawdCancelKey,
|
||||||
|
hasActiveClawdRequest,
|
||||||
|
registerActiveClawdRequest,
|
||||||
|
unregisterActiveClawdRequest,
|
||||||
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatConfig,
|
||||||
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
import {
|
||||||
|
getPersistedClawdTask,
|
||||||
|
markClawdTaskCanceled,
|
||||||
|
markClawdTaskCompleted,
|
||||||
|
markClawdTaskFailed,
|
||||||
|
} from "@/lib/openclaw-chat/task-store";
|
||||||
|
import { isPendingClawdTaskStatus } from "@/lib/openclaw-chat/task-state";
|
||||||
|
import {
|
||||||
|
getPersistedUpstreamTaskState,
|
||||||
|
refreshOpenClawDurableTask,
|
||||||
|
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||||
|
|
||||||
|
const statusSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
userMessageId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
async function reconcileDetachedOpenClawTask(params: {
|
||||||
|
chatId: string;
|
||||||
|
userId: string;
|
||||||
|
task: NonNullable<Awaited<ReturnType<typeof getPersistedClawdTask>>>;
|
||||||
|
}) {
|
||||||
|
if (!isPendingClawdTaskStatus(params.task.metadata.status)) return;
|
||||||
|
const upstreamTask = getPersistedUpstreamTaskState(params.task.metadata);
|
||||||
|
if (!upstreamTask) return;
|
||||||
|
const cancelKey = buildClawdCancelKey({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
userMessageId: params.task.metadata.userMessageId,
|
||||||
|
});
|
||||||
|
if (hasActiveClawdRequest(cancelKey)) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
registerActiveClawdRequest(cancelKey, controller, 30_000);
|
||||||
|
const target = {
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.task.metadata.assistantMessageId,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = normalizeOpenClawChatConfig(setting?.value);
|
||||||
|
if (!config.enabled || !config.baseUrl || !config.token) return;
|
||||||
|
const outcome = await refreshOpenClawDurableTask({
|
||||||
|
config,
|
||||||
|
upstreamTask,
|
||||||
|
target,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (outcome.status === "completed") {
|
||||||
|
await markClawdTaskCompleted({
|
||||||
|
...target,
|
||||||
|
answer: outcome.answer,
|
||||||
|
requestId: outcome.requestId,
|
||||||
|
});
|
||||||
|
} else if (outcome.status === "failed") {
|
||||||
|
await markClawdTaskFailed({ ...target, error: outcome.error });
|
||||||
|
} else if (outcome.status === "canceled") {
|
||||||
|
await markClawdTaskCanceled({ ...target, message: outcome.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
console.warn("[ClawdChat Status] Upstream reconciliation deferred:", {
|
||||||
|
chatId: params.chatId,
|
||||||
|
taskId: upstreamTask.taskId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
unregisterActiveClawdRequest(cancelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth().catch(() => null);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const parsed = statusSchema.safeParse({
|
||||||
|
id: url.searchParams.get("id"),
|
||||||
|
userMessageId: url.searchParams.get("userMessageId") || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await getChatById({ id: parsed.data.id }).catch(() => null);
|
||||||
|
if (!chat) {
|
||||||
|
return NextResponse.json({ task: null }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (chat.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = await getPersistedClawdTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
});
|
||||||
|
if (!task) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ task: null },
|
||||||
|
{ headers: { "Cache-Control": "no-store, max-age=0" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconcileDetachedOpenClawTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
task =
|
||||||
|
(await getPersistedClawdTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
})) || task;
|
||||||
|
|
||||||
|
const userTextPart = Array.isArray(task.userMessage?.parts)
|
||||||
|
? task.userMessage.parts.find(
|
||||||
|
(part) =>
|
||||||
|
part &&
|
||||||
|
typeof part === "object" &&
|
||||||
|
!Array.isArray(part) &&
|
||||||
|
(part as { type?: unknown }).type === "text"
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const userText =
|
||||||
|
userTextPart && typeof userTextPart === "object"
|
||||||
|
? String((userTextPart as { text?: unknown }).text || "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
task: {
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: task.metadata.userMessageId,
|
||||||
|
assistantMessageId: task.metadata.assistantMessageId,
|
||||||
|
status: task.metadata.status,
|
||||||
|
processingHint: task.metadata.processingHint,
|
||||||
|
progressEvents: task.metadata.progressEvents,
|
||||||
|
skillTagId: task.metadata.skillTagId || null,
|
||||||
|
skillTagName: task.metadata.skillTagName || null,
|
||||||
|
requestId: task.metadata.requestId || null,
|
||||||
|
error: task.metadata.error || null,
|
||||||
|
createdAt: task.metadata.createdAt,
|
||||||
|
updatedAt: task.metadata.updatedAt,
|
||||||
|
userMessage: {
|
||||||
|
text: userText,
|
||||||
|
attachments: task.userMessage?.attachments || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store, max-age=0",
|
||||||
|
"Retry-After": "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -49,6 +49,10 @@ const DETAIL_PLAN_INLINE_RETRIES = 1;
|
|||||||
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
|
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
|
||||||
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
|
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
|
||||||
const logger = createLogger("canvas-detail-plan");
|
const logger = createLogger("canvas-detail-plan");
|
||||||
|
const DETAIL_MODEL_USAGE_HINT_PATTERN =
|
||||||
|
/模特|人物|人像|真人|人体|露脸|人脸|面部|发型|体型|肤色|穿搭|服装|试坐|试用|手持|拿着|自拍|合照|真人演示|人物场景|场景演示|使用者|顾客.{0,8}使用|model|person|people|human|face|hair|body|outfit|wearing|holding|selfie|lifestyle\s+demo/i;
|
||||||
|
const DETAIL_NO_MODEL_HINT_PATTERN =
|
||||||
|
/不要出现人物|不出现人物|不要有人物|无人|无人物|只展示产品|产品信息展示|不使用人物|不使用模特|禁止出现人物|no\s+(human|person|people)|without\s+(human|person|people)|product[-\s]?only/i;
|
||||||
|
|
||||||
function isAbortError(error: unknown): boolean {
|
function isAbortError(error: unknown): boolean {
|
||||||
const target = error as {
|
const target = error as {
|
||||||
@ -271,6 +275,77 @@ type DetailPlanResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function buildFixedModelIdentityInstruction(language: "zh" | "en") {
|
||||||
|
return language === "en"
|
||||||
|
? "Fixed model identity lock: if this frame includes a person, use only the same person from the uploaded model image. Keep face, hairstyle, body type, skin tone, and outfit style consistent; do not replace with another person. Pose and camera angle may adapt to the composition."
|
||||||
|
: "固定模特身份硬约束:如果本屏出现人物,只允许使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换成其他人。姿势、视线和机位可按构图调整。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenSuggestsModelUsage(screen: DetailPlanScreen) {
|
||||||
|
const text = [
|
||||||
|
screen.objective,
|
||||||
|
screen.title,
|
||||||
|
screen.subtitle,
|
||||||
|
screen.copy,
|
||||||
|
screen.layoutNote,
|
||||||
|
screen.modelReason,
|
||||||
|
screen.visualPrompt,
|
||||||
|
screen.fabe?.b,
|
||||||
|
screen.fabe?.f,
|
||||||
|
screen.fabe?.a,
|
||||||
|
screen.fabe?.e,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
DETAIL_MODEL_USAGE_HINT_PATTERN.test(text) &&
|
||||||
|
!DETAIL_NO_MODEL_HINT_PATTERN.test(text)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetailModelUsage(params: {
|
||||||
|
screens: DetailPlanScreen[];
|
||||||
|
hasModelImage: boolean;
|
||||||
|
language: "zh" | "en";
|
||||||
|
}) {
|
||||||
|
const { screens, hasModelImage, language } = params;
|
||||||
|
const identityInstruction = buildFixedModelIdentityInstruction(language);
|
||||||
|
return screens.map((screen) => {
|
||||||
|
if (!hasModelImage) {
|
||||||
|
return {
|
||||||
|
...screen,
|
||||||
|
useModel: false,
|
||||||
|
modelReason:
|
||||||
|
language === "en"
|
||||||
|
? "No model image was provided, so this frame does not use a fixed model."
|
||||||
|
: "未提供模特图,当前分镜不使用人物。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUseModel = screen.useModel || screenSuggestsModelUsage(screen);
|
||||||
|
if (!shouldUseModel) {
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelReason =
|
||||||
|
screen.modelReason && !DETAIL_NO_MODEL_HINT_PATTERN.test(screen.modelReason)
|
||||||
|
? screen.modelReason
|
||||||
|
: language === "en"
|
||||||
|
? "This frame contains a human/model scenario, so the uploaded fixed model must be used."
|
||||||
|
: "本屏包含人物/模特展示语义,必须使用上传的固定模特图。";
|
||||||
|
const visualPrompt = screen.visualPrompt.includes(identityInstruction)
|
||||||
|
? screen.visualPrompt
|
||||||
|
: `${screen.visualPrompt} ${identityInstruction}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...screen,
|
||||||
|
useModel: true,
|
||||||
|
modelReason,
|
||||||
|
visualPrompt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type DetailPlanPreflight = {
|
type DetailPlanPreflight = {
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
designDirectorScore: number;
|
designDirectorScore: number;
|
||||||
@ -1230,9 +1305,13 @@ function buildFallbackPlan(params: {
|
|||||||
...params,
|
...params,
|
||||||
globalStyle,
|
globalStyle,
|
||||||
});
|
});
|
||||||
const screens = enforceCoreFocusOnScreens({
|
const screens = normalizeDetailModelUsage({
|
||||||
screens: fallbackScreens,
|
screens: enforceCoreFocusOnScreens({
|
||||||
coreFocus: params.coreFocus,
|
screens: fallbackScreens,
|
||||||
|
coreFocus: params.coreFocus,
|
||||||
|
language,
|
||||||
|
}),
|
||||||
|
hasModelImage: params.hasModelImage,
|
||||||
language,
|
language,
|
||||||
});
|
});
|
||||||
const qualityChecks = evaluatePlanQuality({
|
const qualityChecks = evaluatePlanQuality({
|
||||||
@ -1877,7 +1956,7 @@ export async function POST(request: Request) {
|
|||||||
(item) =>
|
(item) =>
|
||||||
!item.model.isSubModel &&
|
!item.model.isSubModel &&
|
||||||
item.model.isEnabled &&
|
item.model.isEnabled &&
|
||||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||||
);
|
);
|
||||||
const configuredDetailAgentConfig =
|
const configuredDetailAgentConfig =
|
||||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||||
@ -2181,27 +2260,28 @@ export async function POST(request: Request) {
|
|||||||
dslV2Screens?: unknown;
|
dslV2Screens?: unknown;
|
||||||
}>(text || "");
|
}>(text || "");
|
||||||
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
|
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
|
||||||
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
|
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language);
|
||||||
hasModelImage
|
const normalizedScreensWithoutBundles = rawScreens.map((item) =>
|
||||||
? item
|
|
||||||
: {
|
|
||||||
...item,
|
|
||||||
useModel: false,
|
|
||||||
modelReason: "未提供模特图,当前分镜不使用人物。",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const normalizedScreens = rawScreens.map((item) =>
|
|
||||||
hasBundleImages
|
hasBundleImages
|
||||||
? item
|
? item
|
||||||
: {
|
: {
|
||||||
...item,
|
...item,
|
||||||
bundleShowcase: false,
|
bundleShowcase: false,
|
||||||
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const coreFocusedScreens = enforceCoreFocusOnScreens({
|
const normalizedScreens = normalizeDetailModelUsage({
|
||||||
screens: normalizedScreens,
|
screens: normalizedScreensWithoutBundles,
|
||||||
coreFocus,
|
hasModelImage,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
const coreFocusedScreens = normalizeDetailModelUsage({
|
||||||
|
screens: enforceCoreFocusOnScreens({
|
||||||
|
screens: normalizedScreens,
|
||||||
|
coreFocus,
|
||||||
|
language,
|
||||||
|
}),
|
||||||
|
hasModelImage,
|
||||||
language,
|
language,
|
||||||
});
|
});
|
||||||
const qualityChecksInitial = evaluatePlanQuality({
|
const qualityChecksInitial = evaluatePlanQuality({
|
||||||
@ -2225,7 +2305,7 @@ export async function POST(request: Request) {
|
|||||||
hasBundleImages,
|
hasBundleImages,
|
||||||
});
|
});
|
||||||
|
|
||||||
const preflightScreensRaw = preflight.candidateScreens.map((item) =>
|
const preflightScreensWithoutBundles = preflight.candidateScreens.map((item) =>
|
||||||
hasBundleImages
|
hasBundleImages
|
||||||
? item
|
? item
|
||||||
: {
|
: {
|
||||||
@ -2234,9 +2314,18 @@ export async function POST(request: Request) {
|
|||||||
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const preflightScreens = enforceCoreFocusOnScreens({
|
const preflightScreensRaw = normalizeDetailModelUsage({
|
||||||
screens: preflightScreensRaw,
|
screens: preflightScreensWithoutBundles,
|
||||||
coreFocus,
|
hasModelImage,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
const preflightScreens = normalizeDetailModelUsage({
|
||||||
|
screens: enforceCoreFocusOnScreens({
|
||||||
|
screens: preflightScreensRaw,
|
||||||
|
coreFocus,
|
||||||
|
language,
|
||||||
|
}),
|
||||||
|
hasModelImage,
|
||||||
language,
|
language,
|
||||||
});
|
});
|
||||||
const qualityChecksAfterPreflight = evaluatePlanQuality({
|
const qualityChecksAfterPreflight = evaluatePlanQuality({
|
||||||
|
|||||||
@ -204,7 +204,7 @@ export async function POST(request: Request) {
|
|||||||
(item) =>
|
(item) =>
|
||||||
!item.model.isSubModel &&
|
!item.model.isSubModel &&
|
||||||
item.model.isEnabled &&
|
item.model.isEnabled &&
|
||||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||||
);
|
);
|
||||||
const configuredDetailAgentConfig =
|
const configuredDetailAgentConfig =
|
||||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||||
|
|||||||
@ -30,6 +30,21 @@ function resolveDownloadUrl(rawUrl: unknown, origin: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDownloadUrls(body: Record<string, unknown>, origin: string): string[] {
|
||||||
|
const rawCandidates = Array.isArray(body.urls) ? body.urls : [body.url];
|
||||||
|
const urls: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawUrl of rawCandidates) {
|
||||||
|
const url = resolveDownloadUrl(rawUrl, origin);
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
urls.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -39,23 +54,34 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const url = resolveDownloadUrl(body.url, requestOrigin);
|
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||||
if (!url) {
|
if (urls.length === 0) {
|
||||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||||
const result = await fetchCanvasDownloadAsset({
|
const cookie = request.headers.get("cookie") || "";
|
||||||
file: { url, name },
|
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
|
||||||
index: 0,
|
const failures: string[] = [];
|
||||||
requestOrigin,
|
|
||||||
cookie: request.headers.get("cookie") || "",
|
|
||||||
includeSequencePrefix: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
for (const url of urls) {
|
||||||
|
result = await fetchCanvasDownloadAsset({
|
||||||
|
file: { url, name },
|
||||||
|
index: 0,
|
||||||
|
requestOrigin,
|
||||||
|
cookie,
|
||||||
|
includeSequencePrefix: false,
|
||||||
|
});
|
||||||
|
if (result.ok) break;
|
||||||
|
failures.push(`${url}: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result?.ok) {
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: result.error || "图片下载失败" },
|
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
type ImageToolCreditFeature,
|
type ImageToolCreditFeature,
|
||||||
} from "@/lib/points/config";
|
} from "@/lib/points/config";
|
||||||
|
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||||
async?: boolean;
|
async?: boolean;
|
||||||
|
|||||||
@ -9,7 +9,25 @@ import {
|
|||||||
getCanvasImageTaskForUser,
|
getCanvasImageTaskForUser,
|
||||||
} from "@/lib/canvas/image-task-queue";
|
} from "@/lib/canvas/image-task-queue";
|
||||||
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
import { hasScaleAdjustPositionMoved } from "@/lib/canvas/scale-adjust-geometry";
|
||||||
|
import {
|
||||||
|
buildScaleAdjustProductLockAssets,
|
||||||
|
type CanvasScaleAdjustIntentRegion,
|
||||||
|
} from "@/lib/canvas/scale-adjust-product-lock";
|
||||||
|
import {
|
||||||
|
buildSemanticScaleAdjustCleanupPrompt,
|
||||||
|
buildSemanticScaleAdjustCompositeRegions,
|
||||||
|
buildSemanticScaleAdjustExtractionPrompt,
|
||||||
|
} from "@/lib/canvas/scale-adjust-semantic";
|
||||||
|
import {
|
||||||
|
getPointsSystemConfig,
|
||||||
|
getSiteSettings,
|
||||||
|
getSystemSetting,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||||
|
normalizeCanvasLocalEditConfig,
|
||||||
|
} from "@/lib/canvas/local-edit-config";
|
||||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
@ -30,6 +48,7 @@ type LocalEditRegionInput = {
|
|||||||
color?: string;
|
color?: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
rect?: LocalEditRect;
|
rect?: LocalEditRect;
|
||||||
|
targetRect?: LocalEditRect;
|
||||||
referenceImages?: Array<{
|
referenceImages?: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@ -45,6 +64,8 @@ type LocalEditRequest = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
imageSize?: string;
|
imageSize?: string;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
|
operation?: string;
|
||||||
|
scalePercent?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NormalizedLocalEditRegion = {
|
type NormalizedLocalEditRegion = {
|
||||||
@ -65,17 +86,37 @@ type NormalizedLocalEditRegion = {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
targetRect?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
pixelRect: {
|
pixelRect: {
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
targetPixelRect?: {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_LOCAL_EDIT_REGIONS = 8;
|
const MAX_LOCAL_EDIT_REGIONS = 8;
|
||||||
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
|
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
|
||||||
const MIN_REGION_EDGE_PX = 24;
|
const MIN_REGION_EDGE_PX = 24;
|
||||||
|
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
||||||
|
const SCALE_ADJUST_REPAIR_MODEL_REF = "e03d3341-cecd-425e-b88b-9350cbbce18f";
|
||||||
|
const SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF =
|
||||||
|
"5198c20f-b4cc-402c-bc73-eeabacd086b4";
|
||||||
|
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
|
||||||
|
const MIN_SCALE_ADJUST_PERCENT = -80;
|
||||||
|
const MAX_SCALE_ADJUST_PERCENT = 200;
|
||||||
|
type ScaleAdjustPercent = number;
|
||||||
const DEFAULT_REGION_COLORS = [
|
const DEFAULT_REGION_COLORS = [
|
||||||
"#ff3b30",
|
"#ff3b30",
|
||||||
"#1677ff",
|
"#1677ff",
|
||||||
@ -201,6 +242,35 @@ function normalizeRegionReferenceImages(
|
|||||||
.slice(0, MAX_REFERENCE_IMAGES_PER_REGION);
|
.slice(0, MAX_REFERENCE_IMAGES_PER_REGION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLocalEditOperation(input: unknown) {
|
||||||
|
return String(input || "").trim() === SCALE_ADJUST_OPERATION
|
||||||
|
? SCALE_ADJUST_OPERATION
|
||||||
|
: "local-edit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScaleAdjustPercent(input: unknown): ScaleAdjustPercent {
|
||||||
|
const value = Math.round(Number(input));
|
||||||
|
if (
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
value !== 0 &&
|
||||||
|
value >= MIN_SCALE_ADJUST_PERCENT &&
|
||||||
|
value <= MAX_SCALE_ADJUST_PERCENT
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new Error("请选择有效的比例调整幅度");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalEditDisplayPrompt(params: {
|
||||||
|
operation: LocalEditOperation;
|
||||||
|
scalePercent: ScaleAdjustPercent | null;
|
||||||
|
}) {
|
||||||
|
if (params.operation === SCALE_ADJUST_OPERATION) {
|
||||||
|
return "比例调整:按目标框调整";
|
||||||
|
}
|
||||||
|
return "局部编辑处理";
|
||||||
|
}
|
||||||
|
|
||||||
function getColorName(hex: string) {
|
function getColorName(hex: string) {
|
||||||
const normalized = hex.toLowerCase();
|
const normalized = hex.toLowerCase();
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
@ -250,6 +320,45 @@ function rectToPixels(
|
|||||||
return { left, top, width, height };
|
return { left, top, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scaleRectAroundCenter(
|
||||||
|
rect: NormalizedLocalEditRegion["rect"],
|
||||||
|
scalePercent: ScaleAdjustPercent
|
||||||
|
) {
|
||||||
|
const requestedScale = Math.max(0.1, 1 + scalePercent / 100);
|
||||||
|
const maxUniformScale = Math.max(
|
||||||
|
0.1,
|
||||||
|
Math.min(
|
||||||
|
rect.width > 0 ? 1 / rect.width : requestedScale,
|
||||||
|
rect.height > 0 ? 1 / rect.height : requestedScale
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const scale = Math.min(requestedScale, maxUniformScale);
|
||||||
|
const targetWidth = Math.min(1, rect.width * scale);
|
||||||
|
const targetHeight = Math.min(1, rect.height * scale);
|
||||||
|
const centerX = rect.x + rect.width / 2;
|
||||||
|
const centerY = rect.y + rect.height / 2;
|
||||||
|
const x = clamp(centerX - targetWidth / 2, 0, Math.max(0, 1 - targetWidth));
|
||||||
|
const y = clamp(centerY - targetHeight / 2, 0, Math.max(0, 1 - targetHeight));
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScaleAdjustTargetRect(
|
||||||
|
region: NormalizedLocalEditRegion,
|
||||||
|
scalePercent?: ScaleAdjustPercent | null
|
||||||
|
) {
|
||||||
|
if (region.targetRect) return region.targetRect;
|
||||||
|
if (typeof scalePercent === "number" && Number.isFinite(scalePercent)) {
|
||||||
|
return scaleRectAroundCenter(region.rect, scalePercent);
|
||||||
|
}
|
||||||
|
return region.rect;
|
||||||
|
}
|
||||||
|
|
||||||
function wrapText(text: string, maxChars: number, maxLines = 3) {
|
function wrapText(text: string, maxChars: number, maxLines = 3) {
|
||||||
const compact = text.replace(/\s+/g, " ").trim();
|
const compact = text.replace(/\s+/g, " ").trim();
|
||||||
if (!compact) return [];
|
if (!compact) return [];
|
||||||
@ -272,7 +381,8 @@ function wrapText(text: string, maxChars: number, maxLines = 3) {
|
|||||||
function normalizeRegions(
|
function normalizeRegions(
|
||||||
rawRegions: LocalEditRegionInput[] | undefined,
|
rawRegions: LocalEditRegionInput[] | undefined,
|
||||||
sourceWidth: number,
|
sourceWidth: number,
|
||||||
sourceHeight: number
|
sourceHeight: number,
|
||||||
|
options: { requirePrompt?: boolean; fallbackPrompt?: string } = {}
|
||||||
): NormalizedLocalEditRegion[] {
|
): NormalizedLocalEditRegion[] {
|
||||||
if (!Array.isArray(rawRegions) || rawRegions.length === 0) {
|
if (!Array.isArray(rawRegions) || rawRegions.length === 0) {
|
||||||
throw new Error("请先框选至少一个局部编辑区域");
|
throw new Error("请先框选至少一个局部编辑区域");
|
||||||
@ -286,11 +396,16 @@ function normalizeRegions(
|
|||||||
if (!rect) {
|
if (!rect) {
|
||||||
throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`);
|
throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`);
|
||||||
}
|
}
|
||||||
|
const targetRect = normalizeRect(region.targetRect);
|
||||||
const prompt = String(region.prompt || "").trim();
|
const prompt = String(region.prompt || "").trim();
|
||||||
if (!prompt) {
|
const resolvedPrompt = prompt || String(options.fallbackPrompt || "").trim();
|
||||||
|
if (options.requirePrompt !== false && !resolvedPrompt) {
|
||||||
throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`);
|
throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`);
|
||||||
}
|
}
|
||||||
const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight);
|
const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight);
|
||||||
|
const targetPixelRect = targetRect
|
||||||
|
? rectToPixels(targetRect, sourceWidth, sourceHeight)
|
||||||
|
: null;
|
||||||
if (pixelRect.width < MIN_REGION_EDGE_PX || pixelRect.height < MIN_REGION_EDGE_PX) {
|
if (pixelRect.width < MIN_REGION_EDGE_PX || pixelRect.height < MIN_REGION_EDGE_PX) {
|
||||||
throw new Error(`区域 ${itemIndex + 1} 过小,请重新框选更大的范围`);
|
throw new Error(`区域 ${itemIndex + 1} 过小,请重新框选更大的范围`);
|
||||||
}
|
}
|
||||||
@ -303,10 +418,12 @@ function normalizeRegions(
|
|||||||
index: itemIndex + 1,
|
index: itemIndex + 1,
|
||||||
color,
|
color,
|
||||||
colorName: getColorName(color),
|
colorName: getColorName(color),
|
||||||
prompt: prompt.slice(0, 300),
|
prompt: resolvedPrompt.slice(0, 300),
|
||||||
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
|
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
|
||||||
rect,
|
rect,
|
||||||
|
targetRect,
|
||||||
pixelRect,
|
pixelRect,
|
||||||
|
targetPixelRect,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -315,6 +432,7 @@ function buildAnnotationSvg(params: {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
regions: NormalizedLocalEditRegion[];
|
regions: NormalizedLocalEditRegion[];
|
||||||
|
hidePromptText?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { width, height, regions } = params;
|
const { width, height, regions } = params;
|
||||||
const minSide = Math.max(1, Math.min(width, height));
|
const minSide = Math.max(1, Math.min(width, height));
|
||||||
@ -329,7 +447,9 @@ function buildAnnotationSvg(params: {
|
|||||||
const regionSvg = regions
|
const regionSvg = regions
|
||||||
.map((region) => {
|
.map((region) => {
|
||||||
const { left, top, width: rectWidth, height: rectHeight } = region.pixelRect;
|
const { left, top, width: rectWidth, height: rectHeight } = region.pixelRect;
|
||||||
const labelText = `区域${region.index} ${region.prompt}`;
|
const labelText = params.hidePromptText
|
||||||
|
? `区域${region.index} 目标主体`
|
||||||
|
: `区域${region.index} ${region.prompt}`;
|
||||||
const maxChars = Math.max(8, Math.min(24, Math.floor((width * 0.42) / Math.max(1, fontSize * 0.58))));
|
const maxChars = Math.max(8, Math.min(24, Math.floor((width * 0.42) / Math.max(1, fontSize * 0.58))));
|
||||||
const lines = wrapText(labelText, maxChars, 3);
|
const lines = wrapText(labelText, maxChars, 3);
|
||||||
const labelWidth = Math.min(
|
const labelWidth = Math.min(
|
||||||
@ -407,6 +527,8 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
|||||||
regionLines,
|
regionLines,
|
||||||
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
|
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
|
||||||
"",
|
"",
|
||||||
|
"新增主体完整性要求:如果区域提示词要求新增动物、人物、商品或其它完整主体,必须把该主体完整放在框选区域的安全内边距内,四周至少预留 8%-12% 缓冲空间;不要让头、身体、四肢、尾巴、产品边缘或阴影贴到选区边界,更不能被边界截断。空间不足时请主动缩小新增主体,而不是裁切主体。",
|
||||||
|
"边缘融合要求:编辑区域与未编辑区域的地面、墙面、桌面、椅脚、阴影、纹理和透视必须连续自然,不得出现错位、断层、硬边、矩形补丁、局部贴片或不同清晰度的边界。",
|
||||||
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
|
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
|
||||||
"未被列为编辑区域的画面必须逐像素保持原图不变,不允许重绘、改色、补光、换材质、移动物体、改变清晰度或改变背景细节。",
|
"未被列为编辑区域的画面必须逐像素保持原图不变,不允许重绘、改色、补光、换材质、移动物体、改变清晰度或改变背景细节。",
|
||||||
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
|
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
|
||||||
@ -440,9 +562,38 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const operation = resolveLocalEditOperation(body.operation);
|
||||||
|
let scalePercent: ScaleAdjustPercent | null = null;
|
||||||
|
if (operation === SCALE_ADJUST_OPERATION) {
|
||||||
|
const hasRequestedScalePercent =
|
||||||
|
body.scalePercent !== undefined && body.scalePercent !== null;
|
||||||
|
if (hasRequestedScalePercent) {
|
||||||
|
try {
|
||||||
|
scalePercent = normalizeScaleAdjustPercent(body.scalePercent);
|
||||||
|
} catch (validationError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
validationError instanceof Error
|
||||||
|
? validationError.message
|
||||||
|
: "比例调整参数无效",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let regions: NormalizedLocalEditRegion[];
|
let regions: NormalizedLocalEditRegion[];
|
||||||
try {
|
try {
|
||||||
regions = normalizeRegions(body.regions, sourceWidth, sourceHeight);
|
regions = normalizeRegions(body.regions, sourceWidth, sourceHeight, {
|
||||||
|
fallbackPrompt:
|
||||||
|
operation === SCALE_ADJUST_OPERATION
|
||||||
|
? "比例调整:按目标框调整"
|
||||||
|
: undefined,
|
||||||
|
requirePrompt: operation !== SCALE_ADJUST_OPERATION,
|
||||||
|
});
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@ -455,27 +606,371 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const hasScaleAdjustTargetRects =
|
||||||
|
operation === SCALE_ADJUST_OPERATION &&
|
||||||
|
regions.length > 0 &&
|
||||||
|
regions.every((region) => Boolean(region.targetRect));
|
||||||
|
if (
|
||||||
|
operation === SCALE_ADJUST_OPERATION &&
|
||||||
|
!hasScaleAdjustTargetRects &&
|
||||||
|
scalePercent === null
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "请为每个区域提供有效的目标框 targetRect,或提供比例调整幅度 scalePercent",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
const [pointsConfig, siteSettingsForModel, localEditConfigSetting] = await Promise.all([
|
||||||
|
getPointsSystemConfig(),
|
||||||
|
getSiteSettings().catch(() => null),
|
||||||
|
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null),
|
||||||
|
]);
|
||||||
|
const localEditConfig = normalizeCanvasLocalEditConfig(
|
||||||
|
localEditConfigSetting?.value
|
||||||
|
);
|
||||||
|
const siteDetailDefaultModelId =
|
||||||
|
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
|
||||||
|
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
|
||||||
|
: "";
|
||||||
|
const configuredLocalEditModelId = localEditConfig.modelId;
|
||||||
|
const configuredScaleAdjustModelId =
|
||||||
|
localEditConfig.scaleAdjustModelId ||
|
||||||
|
siteDetailDefaultModelId ||
|
||||||
|
SCALE_ADJUST_REPAIR_MODEL_REF;
|
||||||
const pointsCharge = await prepareFeaturePointsCharge({
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||||
reason: "image_repaint",
|
reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint",
|
||||||
modelId: "canvas-local-edit",
|
modelId:
|
||||||
note: "局部编辑消耗积分",
|
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
|
||||||
|
note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分",
|
||||||
pointsConfig,
|
pointsConfig,
|
||||||
});
|
});
|
||||||
if (!pointsCharge.ok) {
|
if (!pointsCharge.ok) {
|
||||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
const annotationSvg = buildAnnotationSvg({
|
if (
|
||||||
height: sourceHeight,
|
operation === SCALE_ADJUST_OPERATION &&
|
||||||
regions,
|
(hasScaleAdjustTargetRects || scalePercent !== null)
|
||||||
width: sourceWidth,
|
) {
|
||||||
});
|
const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
|
||||||
|
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
||||||
|
body.aspectRatio,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight
|
||||||
|
);
|
||||||
|
const requestedModel =
|
||||||
|
typeof body.model === "string" && body.model.trim()
|
||||||
|
? body.model.trim()
|
||||||
|
: undefined;
|
||||||
|
const requestedImageSize =
|
||||||
|
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||||
|
? body.imageSize.trim()
|
||||||
|
: "2K";
|
||||||
|
const scaleAdjustIntentRegions: CanvasScaleAdjustIntentRegion[] = regions.map(
|
||||||
|
(region) => ({
|
||||||
|
index: region.index,
|
||||||
|
rect: region.rect,
|
||||||
|
targetRect: getScaleAdjustTargetRect(region, scalePercent),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const hasPositionMove = scaleAdjustIntentRegions.some((region) =>
|
||||||
|
hasScaleAdjustPositionMoved(region.rect, region.targetRect)
|
||||||
|
);
|
||||||
|
const productLockAssets = await buildScaleAdjustProductLockAssets({
|
||||||
|
protectPeople: hasPositionMove,
|
||||||
|
regions: scaleAdjustIntentRegions,
|
||||||
|
sourceBuffer,
|
||||||
|
sourceHeight,
|
||||||
|
sourceImageUrl,
|
||||||
|
sourceWidth,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const useSemanticScaleAdjust =
|
||||||
|
!hasPositionMove && productLockAssets.semanticFallbackSuggested;
|
||||||
|
const semanticCompositeRegions = useSemanticScaleAdjust
|
||||||
|
? buildSemanticScaleAdjustCompositeRegions(scaleAdjustIntentRegions)
|
||||||
|
: [];
|
||||||
|
const assetSuffix = `${Date.now()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
const [
|
||||||
|
modelMaskUpload,
|
||||||
|
overlayUpload,
|
||||||
|
repairMaskUpload,
|
||||||
|
semanticSourceCropUploads,
|
||||||
|
sourceSubjectMaskUpload,
|
||||||
|
] = await Promise.all([
|
||||||
|
uploadFile(
|
||||||
|
`canvas-scale-adjust-mask-${assetSuffix}.png`,
|
||||||
|
toArrayBuffer(productLockAssets.modelMaskBuffer)
|
||||||
|
),
|
||||||
|
uploadFile(
|
||||||
|
`canvas-scale-adjust-overlay-${assetSuffix}.png`,
|
||||||
|
toArrayBuffer(productLockAssets.overlayBuffer)
|
||||||
|
),
|
||||||
|
uploadFile(
|
||||||
|
`canvas-scale-adjust-repair-mask-${assetSuffix}.png`,
|
||||||
|
toArrayBuffer(productLockAssets.repairMaskBuffer)
|
||||||
|
),
|
||||||
|
useSemanticScaleAdjust
|
||||||
|
? Promise.all(
|
||||||
|
productLockAssets.semanticSourceCropBuffers.map(
|
||||||
|
(buffer, index) =>
|
||||||
|
uploadFile(
|
||||||
|
`canvas-scale-adjust-semantic-source-${assetSuffix}-${index + 1}.png`,
|
||||||
|
toArrayBuffer(buffer)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
hasPositionMove || useSemanticScaleAdjust
|
||||||
|
? uploadFile(
|
||||||
|
`canvas-scale-adjust-subject-mask-${assetSuffix}.png`,
|
||||||
|
toArrayBuffer(productLockAssets.sourceSubjectMaskBuffer)
|
||||||
|
)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
const backgroundPlateUpload = productLockAssets.backgroundPlateBuffer
|
||||||
|
? await uploadFile(
|
||||||
|
`canvas-scale-adjust-background-${assetSuffix}.png`,
|
||||||
|
toArrayBuffer(productLockAssets.backgroundPlateBuffer)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const prompt = useSemanticScaleAdjust
|
||||||
|
? buildSemanticScaleAdjustExtractionPrompt()
|
||||||
|
: hasPositionMove
|
||||||
|
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
|
||||||
|
: "移除掩膜中的完整产品并自然还原背景。";
|
||||||
|
const providerImageParams = useSemanticScaleAdjust
|
||||||
|
? {
|
||||||
|
quality: "high",
|
||||||
|
}
|
||||||
|
: hasPositionMove
|
||||||
|
? {
|
||||||
|
maskImageUrl: modelMaskUpload.url,
|
||||||
|
quality: "medium",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
objectRemovalMaskImageUrl: modelMaskUpload.url,
|
||||||
|
objectRemovalMode: "premium",
|
||||||
|
};
|
||||||
|
const productLockComposite = {
|
||||||
|
...(backgroundPlateUpload
|
||||||
|
? { backgroundPlateImageUrl: backgroundPlateUpload.url }
|
||||||
|
: {}),
|
||||||
|
overlayImageUrl: overlayUpload.url,
|
||||||
|
...(hasPositionMove
|
||||||
|
? {
|
||||||
|
repairMaskImageUrl: repairMaskUpload.url,
|
||||||
|
sourceImageUrl,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(hasPositionMove && sourceSubjectMaskUpload
|
||||||
|
? {
|
||||||
|
regions: scaleAdjustIntentRegions.map((region) => ({
|
||||||
|
source: region.rect,
|
||||||
|
target: region.targetRect,
|
||||||
|
})),
|
||||||
|
shadowSourceImageUrl: sourceImageUrl,
|
||||||
|
sourceSubjectMaskImageUrl: sourceSubjectMaskUpload.url,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
sourceHeight,
|
||||||
|
sourceWidth,
|
||||||
|
};
|
||||||
|
const scaleAdjustVerification = {
|
||||||
|
allowOutsideChangedWhenChangedInside: false,
|
||||||
|
deterministicProductOverlayApplied: true,
|
||||||
|
oldFootprintMaskImageUrl: useSemanticScaleAdjust
|
||||||
|
? sourceSubjectMaskUpload?.url
|
||||||
|
: hasPositionMove
|
||||||
|
? sourceSubjectMaskUpload?.url
|
||||||
|
: repairMaskUpload.url,
|
||||||
|
regions: scaleAdjustIntentRegions.map((region) => region.rect),
|
||||||
|
rejectFailedCandidates: true,
|
||||||
|
rejectNoEffectiveChangeOnly: false,
|
||||||
|
rejectOldFootprintOnly: true,
|
||||||
|
sourceImageUrl,
|
||||||
|
targetRegions: scaleAdjustIntentRegions.map((region) => region.targetRect),
|
||||||
|
};
|
||||||
|
const imageRequest: CanvasGenerateImageRequest = {
|
||||||
|
allowFailover: false,
|
||||||
|
aspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
||||||
|
billingFeature: "repaint",
|
||||||
|
debugLabel: "canvas-scale-adjust-product-lock",
|
||||||
|
debugMeta: {
|
||||||
|
displayPrompt,
|
||||||
|
operation,
|
||||||
|
requestedModel: requestedModel || null,
|
||||||
|
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
||||||
|
resolvedModel: configuredScaleAdjustModelId,
|
||||||
|
scaleAdjustProductLockMaskEditApplied: true,
|
||||||
|
scaleAdjustPersonExclusionApplied:
|
||||||
|
productLockAssets.personExclusionApplied,
|
||||||
|
scaleAdjustPromptStrategy: useSemanticScaleAdjust
|
||||||
|
? "gpt-extract-product-lock-v2"
|
||||||
|
: hasPositionMove
|
||||||
|
? "gpt-mask-background-rebuild-product-lock-v1"
|
||||||
|
: "finegrain-erase-product-lock-v1",
|
||||||
|
scaleAdjustProviderMaskImageUrl: useSemanticScaleAdjust
|
||||||
|
? null
|
||||||
|
: modelMaskUpload.url,
|
||||||
|
scaleAdjustSemanticFallbackRegionIndexes:
|
||||||
|
productLockAssets.semanticFallbackRegionIndexes,
|
||||||
|
scaleAdjustSemanticFallbackSuggested:
|
||||||
|
productLockAssets.semanticFallbackSuggested,
|
||||||
|
scaleAdjustSequentialCleanupRetry: !useSemanticScaleAdjust,
|
||||||
|
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
||||||
|
.length,
|
||||||
|
scalePercent,
|
||||||
|
sourceHeight,
|
||||||
|
sourceNodeId: body.sourceNodeId || null,
|
||||||
|
sourceWidth,
|
||||||
|
},
|
||||||
|
displayPrompt,
|
||||||
|
imageCount: 1,
|
||||||
|
imageSize: requestedImageSize,
|
||||||
|
model: configuredScaleAdjustModelId,
|
||||||
|
negativePrompt:
|
||||||
|
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
|
||||||
|
prompt,
|
||||||
|
promptExtend: false,
|
||||||
|
providerImageParams,
|
||||||
|
referenceImages: [sourceImageUrl],
|
||||||
|
...(useSemanticScaleAdjust
|
||||||
|
? {
|
||||||
|
localEditComposite: {
|
||||||
|
expandRegions: false,
|
||||||
|
featherMaxPx: 28,
|
||||||
|
featherMinPx: 12,
|
||||||
|
featherRatio: 0.012,
|
||||||
|
maskMode: "region" as const,
|
||||||
|
regions: semanticCompositeRegions,
|
||||||
|
sourceImageUrl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(useSemanticScaleAdjust &&
|
||||||
|
semanticSourceCropUploads.length === scaleAdjustIntentRegions.length
|
||||||
|
? {
|
||||||
|
semanticScaleAdjustPreparation: {
|
||||||
|
cleanupImageSize: "2K",
|
||||||
|
cleanupModel: SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF,
|
||||||
|
cleanupPrompt: buildSemanticScaleAdjustCleanupPrompt(),
|
||||||
|
extractionPrompt: buildSemanticScaleAdjustExtractionPrompt(),
|
||||||
|
regions: scaleAdjustIntentRegions.map((region, index) => ({
|
||||||
|
source: region.rect,
|
||||||
|
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
|
||||||
|
target: region.targetRect,
|
||||||
|
})),
|
||||||
|
relightModel: configuredScaleAdjustModelId,
|
||||||
|
sourceImageUrl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
retryVariants: useSemanticScaleAdjust ? [] : [
|
||||||
|
{
|
||||||
|
debugMeta: {
|
||||||
|
scaleAdjustRetryReason: "old-footprint-remnant-detected",
|
||||||
|
scaleAdjustRetryStrategy: "sequential-finegrain-cleanup",
|
||||||
|
},
|
||||||
|
label: "finegrain-cleanup-pass",
|
||||||
|
prompt,
|
||||||
|
providerImageParams,
|
||||||
|
referenceImages: [sourceImageUrl],
|
||||||
|
scaleAdjustProductLockComposite: productLockComposite,
|
||||||
|
scaleAdjustVerification: {
|
||||||
|
...scaleAdjustVerification,
|
||||||
|
rejectNoEffectiveChangeOnly: true,
|
||||||
|
rejectOldFootprintOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...(useSemanticScaleAdjust
|
||||||
|
? {}
|
||||||
|
: { scaleAdjustProductLockComposite: productLockComposite }),
|
||||||
|
...(hasPositionMove
|
||||||
|
? {
|
||||||
|
scaleAdjustModelRelight: {
|
||||||
|
model: configuredScaleAdjustModelId,
|
||||||
|
overlayImageUrl: overlayUpload.url,
|
||||||
|
quality: "medium" as const,
|
||||||
|
regions: scaleAdjustIntentRegions.map((region) => ({
|
||||||
|
target: region.targetRect,
|
||||||
|
})),
|
||||||
|
sourceHeight,
|
||||||
|
sourceWidth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
scaleAdjustVerification,
|
||||||
|
ownerKey: session.user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const task = await createCanvasImageTask({
|
||||||
|
request: imageRequest as unknown as Record<string, unknown>,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
await enqueueCanvasImageTask(task.id);
|
||||||
|
const currentTask = await getCanvasImageTaskForUser({
|
||||||
|
taskId: task.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
try {
|
||||||
|
await pointsCharge.consume({
|
||||||
|
note: `比例调整消耗积分(${task.id})`,
|
||||||
|
});
|
||||||
|
} catch (pointsError) {
|
||||||
|
console.error("[Points] Failed to consume scale adjust points:", pointsError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
annotatedReferenceUrl: overlayUpload.url,
|
||||||
|
aspectRatio: resolvedAspectRatio,
|
||||||
|
displayPrompt,
|
||||||
|
prompt: displayPrompt,
|
||||||
|
regions,
|
||||||
|
sourceHeight,
|
||||||
|
sourceWidth,
|
||||||
|
taskId: task.id,
|
||||||
|
status: currentTask?.status || task.status,
|
||||||
|
queueStatus: currentTask?.queueStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
|
||||||
|
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
||||||
|
body.aspectRatio,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight
|
||||||
|
);
|
||||||
|
const requestedModel =
|
||||||
|
typeof body.model === "string" && body.model.trim()
|
||||||
|
? body.model.trim()
|
||||||
|
: undefined;
|
||||||
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
||||||
.composite([{ input: annotationSvg, left: 0, top: 0 }])
|
.composite([
|
||||||
|
{
|
||||||
|
input: buildAnnotationSvg({
|
||||||
|
height: sourceHeight,
|
||||||
|
hidePromptText: false,
|
||||||
|
regions,
|
||||||
|
width: sourceWidth,
|
||||||
|
}),
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
.png({ compressionLevel: 6 })
|
.png({ compressionLevel: 6 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
|
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
|
||||||
@ -484,46 +979,42 @@ export async function POST(request: NextRequest) {
|
|||||||
toArrayBuffer(annotatedBuffer)
|
toArrayBuffer(annotatedBuffer)
|
||||||
);
|
);
|
||||||
const prompt = buildLocalEditPrompt(regions);
|
const prompt = buildLocalEditPrompt(regions);
|
||||||
const regionReferenceImageUrls = regions.flatMap((region) =>
|
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
|
||||||
region.referenceImages.map((reference) => reference.url)
|
region.referenceImages.map((reference) => reference.url)
|
||||||
);
|
);
|
||||||
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
const referenceImages = [sourceImageUrl, ...manualRegionReferenceImageUrls];
|
||||||
body.aspectRatio,
|
const requestedImageSize =
|
||||||
sourceWidth,
|
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||||
sourceHeight
|
? body.imageSize.trim()
|
||||||
);
|
: "2K";
|
||||||
|
|
||||||
const imageRequest: CanvasGenerateImageRequest = {
|
const imageRequest: CanvasGenerateImageRequest = {
|
||||||
aspectRatio: resolvedAspectRatio,
|
aspectRatio: resolvedAspectRatio,
|
||||||
billingFeature: "repaint",
|
billingFeature: "repaint",
|
||||||
debugLabel: "canvas-local-edit",
|
debugLabel: "canvas-local-edit",
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
annotatedReferenceUrl: annotatedUpload.url,
|
annotatedReferenceUrl: annotatedUpload.url,
|
||||||
annotatedReferenceSentToModel: false,
|
annotatedReferenceType: "source-overlay",
|
||||||
|
displayPrompt,
|
||||||
|
operation,
|
||||||
|
requestedModel: requestedModel || null,
|
||||||
|
resolvedModel: requestedModel || configuredLocalEditModelId || null,
|
||||||
resolvedAspectRatio,
|
resolvedAspectRatio,
|
||||||
regionReferenceImageCount: regionReferenceImageUrls.length,
|
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
|
||||||
|
regionReferenceImageCount: referenceImages.length,
|
||||||
regionCount: regions.length,
|
regionCount: regions.length,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
sourceNodeId: body.sourceNodeId || null,
|
sourceNodeId: body.sourceNodeId || null,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
},
|
},
|
||||||
|
displayPrompt,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
imageSize:
|
imageSize: requestedImageSize,
|
||||||
typeof body.imageSize === "string" && body.imageSize.trim()
|
model: requestedModel || configuredLocalEditModelId || undefined,
|
||||||
? body.imageSize.trim()
|
|
||||||
: "2K",
|
|
||||||
model:
|
|
||||||
typeof body.model === "string" && body.model.trim()
|
|
||||||
? body.model.trim()
|
|
||||||
: undefined,
|
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
|
||||||
prompt,
|
prompt,
|
||||||
promptExtend: false,
|
promptExtend: false,
|
||||||
referenceImages: [
|
referenceImages,
|
||||||
sourceImageUrl,
|
|
||||||
...regionReferenceImageUrls,
|
|
||||||
],
|
|
||||||
localEditComposite: {
|
localEditComposite: {
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
regions: regions.map((region) => region.rect),
|
regions: regions.map((region) => region.rect),
|
||||||
@ -544,7 +1035,10 @@ export async function POST(request: NextRequest) {
|
|||||||
if (pointsCharge.chargeable) {
|
if (pointsCharge.chargeable) {
|
||||||
try {
|
try {
|
||||||
await pointsCharge.consume({
|
await pointsCharge.consume({
|
||||||
note: `局部编辑消耗积分(${task.id})`,
|
note:
|
||||||
|
operation === SCALE_ADJUST_OPERATION
|
||||||
|
? `比例调整消耗积分(${task.id})`
|
||||||
|
: `局部编辑消耗积分(${task.id})`,
|
||||||
});
|
});
|
||||||
} catch (pointsError) {
|
} catch (pointsError) {
|
||||||
console.error("[Points] Failed to consume local edit points:", pointsError);
|
console.error("[Points] Failed to consume local edit points:", pointsError);
|
||||||
@ -555,7 +1049,8 @@ export async function POST(request: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
annotatedReferenceUrl: annotatedUpload.url,
|
annotatedReferenceUrl: annotatedUpload.url,
|
||||||
aspectRatio: resolvedAspectRatio,
|
aspectRatio: resolvedAspectRatio,
|
||||||
prompt,
|
displayPrompt,
|
||||||
|
prompt: displayPrompt,
|
||||||
regions,
|
regions,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
|
|||||||
@ -7,19 +7,26 @@ import { createHuankeGemini } from "@/lib/ai/providers/huanke-gemini";
|
|||||||
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||||||
import { createBflImage } from "@/lib/ai/providers/bfl-image";
|
import { createBflImage } from "@/lib/ai/providers/bfl-image";
|
||||||
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
||||||
|
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
||||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||||
|
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||||
|
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||||
|
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||||
|
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||||
|
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||||
import { createRunway } from "@/lib/ai/providers/runway";
|
import { createRunway } from "@/lib/ai/providers/runway";
|
||||||
import { createKrea } from "@/lib/ai/providers/krea";
|
import { createKrea } from "@/lib/ai/providers/krea";
|
||||||
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
|
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
|
||||||
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
||||||
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
const logger = createLogger("canvas-text-edit");
|
const logger = createLogger("canvas-text-edit");
|
||||||
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
|
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
|
||||||
@ -29,6 +36,7 @@ const textItemSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
originalText: z.string().min(1),
|
originalText: z.string().min(1),
|
||||||
editedText: z.string().optional(),
|
editedText: z.string().optional(),
|
||||||
|
textColor: z.string().optional(),
|
||||||
action: z.enum(["replace", "delete"]).optional(),
|
action: z.enum(["replace", "delete"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -45,7 +53,12 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
|||||||
"bfl-image",
|
"bfl-image",
|
||||||
"ruxa-image",
|
"ruxa-image",
|
||||||
"fal-image",
|
"fal-image",
|
||||||
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
|
"visionary",
|
||||||
|
"atlascloud",
|
||||||
|
"vip98k",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"runway",
|
"runway",
|
||||||
@ -106,8 +119,18 @@ function createTextEditProvider(
|
|||||||
return createRuxaImage({ baseURL, apiKey });
|
return createRuxaImage({ baseURL, apiKey });
|
||||||
case "fal-image":
|
case "fal-image":
|
||||||
return createFalImage({ baseURL, apiKey });
|
return createFalImage({ baseURL, apiKey });
|
||||||
|
case "snapgen-image":
|
||||||
|
return createSnapgenImage({ baseURL, apiKey });
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
return createOpenAIImage({ baseURL, apiKey });
|
return createOpenAIImage({ baseURL, apiKey });
|
||||||
|
case "bananaimage":
|
||||||
|
return createBananaImage({ baseURL, apiKey });
|
||||||
|
case "visionary":
|
||||||
|
return createVisionary({ baseURL, apiKey });
|
||||||
|
case "atlascloud":
|
||||||
|
return createAtlasCloud({ baseURL, apiKey });
|
||||||
|
case "vip98k":
|
||||||
|
return createVip98k({ baseURL, apiKey });
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
return createStepfunImage({ baseURL, apiKey });
|
return createStepfunImage({ baseURL, apiKey });
|
||||||
case "midjourney":
|
case "midjourney":
|
||||||
@ -370,22 +393,53 @@ function parseImageFromGenerationText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTextEditPrompt(
|
function buildTextEditPrompt(
|
||||||
replacements: Array<{ originalText: string; editedText: string }>,
|
replacements: Array<{
|
||||||
|
originalText: string;
|
||||||
|
editedText: string;
|
||||||
|
textColor?: string;
|
||||||
|
}>,
|
||||||
deletions: Array<{ originalText: string }>,
|
deletions: Array<{ originalText: string }>,
|
||||||
) {
|
) {
|
||||||
const sections: string[] = [
|
const sections: string[] = [
|
||||||
"请基于输入图片执行精确文字替换。",
|
"请基于输入图片执行精确文字替换。",
|
||||||
"硬性要求:",
|
"硬性要求:",
|
||||||
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
|
"1. 仅编辑下方列出的目标文字区域,不要修改其他主体、产品、背景、材质、配色、光影、图标、装饰元素与构图。",
|
||||||
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
|
"2. 必须保持原图排版、字重、笔画风格、字体形状、字号关系与文字所在位置尽可能一致,仅替换指定文字内容或指定文字颜色。",
|
||||||
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
|
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
|
||||||
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
|
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
|
||||||
"5. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
|
"5. 如果指定了文字颜色,只允许修改该段文字本身的颜色,绝对禁止把产品、包装、背景、阴影、高光、边框或其他非文字区域一起染色。",
|
||||||
|
"6. 如未指定文字颜色,则尽量保持原图该文字本来的颜色与质感。",
|
||||||
|
"7. 删除文字时请仅移除对应文字并自然修复背景,不要改动周围产品外观。",
|
||||||
|
"8. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (replacements.length > 0) {
|
if (replacements.length > 0) {
|
||||||
const replacementLines = replacements
|
const replacementLines = replacements
|
||||||
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`)
|
.map((item, index) => {
|
||||||
|
if (item.textColor && item.originalText === item.editedText) {
|
||||||
|
return [
|
||||||
|
`${index + 1}. 原文: ${item.originalText}`,
|
||||||
|
" 文字内容保持不变",
|
||||||
|
` 仅将这段文字颜色改为: ${item.textColor.toUpperCase()}`,
|
||||||
|
" 注意: 只能改文字颜色,不要让产品、背景或其他元素变色。",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.textColor) {
|
||||||
|
return [
|
||||||
|
`${index + 1}. 原文: ${item.originalText}`,
|
||||||
|
` 替换为: ${item.editedText}`,
|
||||||
|
` 新文字颜色: ${item.textColor.toUpperCase()}`,
|
||||||
|
" 注意: 只对这段文字应用该颜色,其它区域颜色保持原样。",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${index + 1}. 原文: ${item.originalText}`,
|
||||||
|
` 替换为: ${item.editedText}`,
|
||||||
|
" 文字颜色要求: 保持原图该文字原有颜色不变",
|
||||||
|
].join("\n");
|
||||||
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
sections.push("替换清单:", replacementLines);
|
sections.push("替换清单:", replacementLines);
|
||||||
}
|
}
|
||||||
@ -397,7 +451,7 @@ function buildTextEditPrompt(
|
|||||||
sections.push(
|
sections.push(
|
||||||
"删除清单:",
|
"删除清单:",
|
||||||
deletionLines,
|
deletionLines,
|
||||||
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹。",
|
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹,也不要影响产品主体颜色。",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -426,7 +480,10 @@ async function generateTextEditImageWithFailover(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const provider = createTextEditProvider(selected.provider);
|
const provider = createTextEditProvider(selected.provider);
|
||||||
const result = await provider.chat(selected.model.modelId).doGenerate({
|
const runtimeModelId = resolveModelRuntimeId(selected.model, {
|
||||||
|
providerType: selected.provider.type,
|
||||||
|
});
|
||||||
|
const generationOptions: any = {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
@ -439,7 +496,18 @@ async function generateTextEditImageWithFailover(params: {
|
|||||||
imageParams: {
|
imageParams: {
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
|
||||||
|
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
|
||||||
|
}
|
||||||
|
const modelInstance =
|
||||||
|
selected.provider.type === "vip98k"
|
||||||
|
? (provider as any).chat(runtimeModelId, {
|
||||||
|
capabilities: selected.model.capabilities || null,
|
||||||
|
imageGenConfig: selected.model.imageGenConfig || null,
|
||||||
|
})
|
||||||
|
: provider.chat(runtimeModelId);
|
||||||
|
const result = await modelInstance.doGenerate(generationOptions);
|
||||||
|
|
||||||
const resultText = String(result?.text || "").trim();
|
const resultText = String(result?.text || "").trim();
|
||||||
const finishReason = String(result?.finishReason || "").toLowerCase();
|
const finishReason = String(result?.finishReason || "").toLowerCase();
|
||||||
@ -493,6 +561,7 @@ export async function POST(request: Request) {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText.trim(),
|
originalText: item.originalText.trim(),
|
||||||
editedText: (item.editedText || "").trim(),
|
editedText: (item.editedText || "").trim(),
|
||||||
|
textColor: normalizeOptionalHexColor(item.textColor),
|
||||||
action: item.action,
|
action: item.action,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -500,14 +569,15 @@ export async function POST(request: Request) {
|
|||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.originalText &&
|
item.originalText &&
|
||||||
item.editedText &&
|
|
||||||
item.action !== "delete" &&
|
item.action !== "delete" &&
|
||||||
item.originalText !== item.editedText,
|
item.editedText &&
|
||||||
|
(item.originalText !== item.editedText || Boolean(item.textColor)),
|
||||||
)
|
)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText,
|
originalText: item.originalText,
|
||||||
editedText: item.editedText,
|
editedText: item.editedText,
|
||||||
|
textColor: item.textColor,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const deletions = normalizedItems
|
const deletions = normalizedItems
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const VIDEO_ANALYSIS_MAX_SIDE = 960;
|
|||||||
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
|
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
mode: z.enum(["video-analysis", "character-scene-extraction", "agent-plan"]),
|
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
|
||||||
prompt: z.string().trim().optional(),
|
prompt: z.string().trim().optional(),
|
||||||
imageUrls: z.array(z.string().trim()).optional(),
|
imageUrls: z.array(z.string().trim()).optional(),
|
||||||
videoUrls: z.array(z.string().trim()).optional(),
|
videoUrls: z.array(z.string().trim()).optional(),
|
||||||
@ -70,6 +70,14 @@ function normalizeText(value: unknown) {
|
|||||||
return String(value || "").trim();
|
return String(value || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseLocalWorkflowE2E(request: Request) {
|
||||||
|
const hostname = new URL(request.url).hostname;
|
||||||
|
return (
|
||||||
|
process.env.CANVAS_E2E_LOCAL === "true" &&
|
||||||
|
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStringList(value: unknown, limit = 8) {
|
function normalizeStringList(value: unknown, limit = 8) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return Array.from(
|
return Array.from(
|
||||||
@ -118,6 +126,22 @@ function normalizeVideoAnalysisRows(
|
|||||||
const sceneDescription = normalizeText(
|
const sceneDescription = normalizeText(
|
||||||
raw.sceneDescription || raw.scene || raw.title || raw.description,
|
raw.sceneDescription || raw.scene || raw.title || raw.description,
|
||||||
);
|
);
|
||||||
|
const narrative = normalizeText(
|
||||||
|
raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary,
|
||||||
|
);
|
||||||
|
const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1);
|
||||||
|
const startTime = normalizeText(raw.startTime || raw.start || raw.start_time);
|
||||||
|
const endTime = normalizeText(raw.endTime || raw.end || raw.end_time);
|
||||||
|
const duration = normalizeText(raw.duration || raw.durationSeconds || raw.duration_seconds);
|
||||||
|
const shotSize = normalizeText(raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale);
|
||||||
|
const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle);
|
||||||
|
const cameraMovement = normalizeText(
|
||||||
|
raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType,
|
||||||
|
);
|
||||||
|
const focusDepth = normalizeText(
|
||||||
|
raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth,
|
||||||
|
);
|
||||||
|
const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign);
|
||||||
const backgroundMusic = normalizeText(
|
const backgroundMusic = normalizeText(
|
||||||
raw.backgroundMusic || raw.music || raw.bgm,
|
raw.backgroundMusic || raw.music || raw.bgm,
|
||||||
);
|
);
|
||||||
@ -135,10 +159,16 @@ function normalizeVideoAnalysisRows(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!sceneDescription &&
|
!sceneDescription &&
|
||||||
|
!narrative &&
|
||||||
!backgroundMusic &&
|
!backgroundMusic &&
|
||||||
!voiceAndSfx &&
|
!voiceAndSfx &&
|
||||||
!imagePrompt &&
|
!imagePrompt &&
|
||||||
!motionPrompt
|
!motionPrompt &&
|
||||||
|
!shotSize &&
|
||||||
|
!cameraAngle &&
|
||||||
|
!cameraMovement &&
|
||||||
|
!focusDepth &&
|
||||||
|
!lighting
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -147,7 +177,17 @@ function normalizeVideoAnalysisRows(
|
|||||||
? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1))
|
? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1))
|
||||||
: index % Math.max(framePreviews.length, 1);
|
: index % Math.max(framePreviews.length, 1);
|
||||||
return {
|
return {
|
||||||
|
shot: shot || String(index + 1),
|
||||||
|
startTime: startTime || (index === 0 ? "0" : ""),
|
||||||
|
endTime,
|
||||||
|
duration,
|
||||||
sceneDescription: sceneDescription || `镜头 ${index + 1}`,
|
sceneDescription: sceneDescription || `镜头 ${index + 1}`,
|
||||||
|
narrative: narrative || sceneDescription || `镜头 ${index + 1}`,
|
||||||
|
shotSize: shotSize || "中景至近景",
|
||||||
|
cameraAngle: cameraAngle || "平视",
|
||||||
|
cameraMovement: cameraMovement || "缓慢推镜头 (Slow Zoom In)",
|
||||||
|
focusDepth: focusDepth || "浅景深",
|
||||||
|
lighting: lighting || "冷调低沉照明,主体光线柔和。",
|
||||||
backgroundMusic: backgroundMusic || "无",
|
backgroundMusic: backgroundMusic || "无",
|
||||||
voiceAndSfx: voiceAndSfx || "无",
|
voiceAndSfx: voiceAndSfx || "无",
|
||||||
imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧",
|
imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧",
|
||||||
@ -159,7 +199,17 @@ function normalizeVideoAnalysisRows(
|
|||||||
(
|
(
|
||||||
item,
|
item,
|
||||||
): item is {
|
): item is {
|
||||||
|
shot: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
duration: string;
|
||||||
sceneDescription: string;
|
sceneDescription: string;
|
||||||
|
narrative: string;
|
||||||
|
shotSize: string;
|
||||||
|
cameraAngle: string;
|
||||||
|
cameraMovement: string;
|
||||||
|
focusDepth: string;
|
||||||
|
lighting: string;
|
||||||
backgroundMusic: string;
|
backgroundMusic: string;
|
||||||
voiceAndSfx: string;
|
voiceAndSfx: string;
|
||||||
imagePrompt: string;
|
imagePrompt: string;
|
||||||
@ -170,6 +220,226 @@ function normalizeVideoAnalysisRows(
|
|||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStoryShot(value: unknown, index: number) {
|
||||||
|
const text = normalizeText(value);
|
||||||
|
if (/^\d+$/.test(text)) return text.padStart(2, "0");
|
||||||
|
return text || String(index + 1).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
type NormalizedStoryScriptRow = {
|
||||||
|
shot: string;
|
||||||
|
duration: string;
|
||||||
|
scene: string;
|
||||||
|
sceneDescription: string;
|
||||||
|
shotSize: string;
|
||||||
|
lighting: string;
|
||||||
|
dialogue: string;
|
||||||
|
backgroundMusic: string;
|
||||||
|
voiceAndSfx: string;
|
||||||
|
cameraMovement: string;
|
||||||
|
imagePrompt: string;
|
||||||
|
motionPrompt: string;
|
||||||
|
keyframeUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeStoryScriptRows(value: unknown): NormalizedStoryScriptRow[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item, index) => {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
const cells = item.map((cell) => normalizeText(cell));
|
||||||
|
const shot = normalizeStoryShot(cells[0], index);
|
||||||
|
const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || "");
|
||||||
|
const duration = secondCellLooksLikeDuration ? cells[1] : "5s";
|
||||||
|
const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`;
|
||||||
|
const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene;
|
||||||
|
return {
|
||||||
|
shot,
|
||||||
|
duration,
|
||||||
|
scene,
|
||||||
|
sceneDescription,
|
||||||
|
shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景",
|
||||||
|
lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影",
|
||||||
|
dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+",
|
||||||
|
backgroundMusic: "无",
|
||||||
|
voiceAndSfx: (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无",
|
||||||
|
cameraMovement: (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头",
|
||||||
|
imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription,
|
||||||
|
motionPrompt:
|
||||||
|
(secondCellLooksLikeDuration ? cells[9] || cells[7] : cells[6]) ||
|
||||||
|
"保持镜头运动自然、连贯,围绕主体动作推进。",
|
||||||
|
keyframeUrl: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
item && typeof item === "object" && !Array.isArray(item)
|
||||||
|
? (item as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const shot = normalizeStoryShot(raw.shot || raw.shotNumber || raw.index || raw.id, index);
|
||||||
|
const duration = normalizeText(raw.duration || raw.length || raw.time || raw.seconds);
|
||||||
|
const scene = normalizeText(raw.scene || raw.location || raw.title || `镜头 ${shot}`);
|
||||||
|
const sceneDescription = normalizeText(
|
||||||
|
raw.sceneDescription ||
|
||||||
|
raw.description ||
|
||||||
|
raw.visual ||
|
||||||
|
raw.picture ||
|
||||||
|
raw.screen ||
|
||||||
|
raw.summary,
|
||||||
|
);
|
||||||
|
const shotSize = normalizeText(
|
||||||
|
raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale || raw.cameraShot,
|
||||||
|
);
|
||||||
|
const lighting = normalizeText(
|
||||||
|
raw.lighting || raw.light || raw.atmosphere || raw.mood || raw.lightMood,
|
||||||
|
);
|
||||||
|
const dialogue = normalizeText(
|
||||||
|
raw.dialogue || raw.dialog || raw.narration || raw.voiceover || raw.monologue || raw.line,
|
||||||
|
);
|
||||||
|
const cameraMovement = normalizeText(
|
||||||
|
raw.cameraMovement || raw.cameraMove || raw.movement || raw.lensMovement || raw.motion,
|
||||||
|
);
|
||||||
|
const imagePrompt = normalizeText(
|
||||||
|
raw.imagePrompt ||
|
||||||
|
raw.image_generation_prompt ||
|
||||||
|
raw.image ||
|
||||||
|
raw.keyframePrompt ||
|
||||||
|
raw.keyFramePrompt,
|
||||||
|
);
|
||||||
|
const motionPrompt = normalizeText(
|
||||||
|
raw.motionPrompt ||
|
||||||
|
raw.videoPrompt ||
|
||||||
|
raw.video_motion_prompt ||
|
||||||
|
raw.motion ||
|
||||||
|
raw.cameraMovement,
|
||||||
|
);
|
||||||
|
const backgroundMusic = normalizeText(
|
||||||
|
raw.backgroundMusic || raw.music || raw.bgm || raw.soundtrack,
|
||||||
|
);
|
||||||
|
const voiceAndSfx = normalizeText(
|
||||||
|
raw.voiceAndSfx || raw.voice || raw.sound || raw.audio || raw.sfx,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!scene &&
|
||||||
|
!sceneDescription &&
|
||||||
|
!imagePrompt &&
|
||||||
|
!motionPrompt &&
|
||||||
|
!backgroundMusic &&
|
||||||
|
!voiceAndSfx
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shot,
|
||||||
|
duration: duration || "5s",
|
||||||
|
scene,
|
||||||
|
sceneDescription: sceneDescription || imagePrompt || scene || `镜头 ${shot}`,
|
||||||
|
shotSize: shotSize || "中景",
|
||||||
|
lighting: lighting || "电影感光影",
|
||||||
|
dialogue: dialogue || "+",
|
||||||
|
backgroundMusic: backgroundMusic || "无",
|
||||||
|
voiceAndSfx: voiceAndSfx || "无",
|
||||||
|
cameraMovement: cameraMovement || "缓慢推镜头",
|
||||||
|
imagePrompt: imagePrompt || sceneDescription || scene || `镜头 ${shot}关键帧`,
|
||||||
|
motionPrompt: motionPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。",
|
||||||
|
keyframeUrl: "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is NormalizedStoryScriptRow => Boolean(item),
|
||||||
|
)
|
||||||
|
.slice(0, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyExpansionPhases = [
|
||||||
|
{
|
||||||
|
description: "建立镜头,交代环境、人物位置和当前压力",
|
||||||
|
cameraMovement: "缓慢推镜头",
|
||||||
|
imagePrompt: "广角或中景建立画面,环境信息清晰,主体位置明确",
|
||||||
|
lighting: "环境主光明确,氛围基调清晰",
|
||||||
|
motionPrompt: "镜头缓慢推进或轻微横移,建立空间关系",
|
||||||
|
shotSize: "中景",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "动作推进,突出人物的关键行为或剧情转折",
|
||||||
|
cameraMovement: "跟随主体动作推进",
|
||||||
|
imagePrompt: "中景表现人物动作,服装、道具和光影保持连续",
|
||||||
|
lighting: "主体光影连续,动作区域清晰",
|
||||||
|
motionPrompt: "跟随主体动作推进,节奏自然连贯",
|
||||||
|
shotSize: "中景",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "情绪反应,用近景或特写强化人物心理和冲突",
|
||||||
|
cameraMovement: "轻微推近或稳定特写",
|
||||||
|
imagePrompt: "近景或特写,突出表情、眼神、手部或关键道具细节",
|
||||||
|
lighting: "面部光线突出,情绪反差增强",
|
||||||
|
motionPrompt: "轻微推近或稳定特写,强调情绪变化",
|
||||||
|
shotSize: "特写",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "关系与氛围,表现周围人物、环境反馈或声画张力",
|
||||||
|
cameraMovement: "横移扫过环境或切换视角",
|
||||||
|
imagePrompt: "加入环境反馈、群像反应或空间层次,电影感光影",
|
||||||
|
lighting: "场景氛围增强,空间层次明显",
|
||||||
|
motionPrompt: "镜头扫过环境或切换视角,保持节奏递进",
|
||||||
|
shotSize: "全景",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "转场收束,为下一镜头保留动作、视线或情绪钩子",
|
||||||
|
cameraMovement: "顺势后移、上摇或转场",
|
||||||
|
imagePrompt: "构图留出转场方向,动作或视线指向下一段剧情",
|
||||||
|
lighting: "光影引导视线并保留转场方向",
|
||||||
|
motionPrompt: "镜头后移、上摇或顺势转场,衔接下一镜头",
|
||||||
|
shotSize: "大远景",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function expandStoryScriptRowsToTarget(
|
||||||
|
rows: NormalizedStoryScriptRow[],
|
||||||
|
targetCount = 18,
|
||||||
|
): NormalizedStoryScriptRow[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
if (rows.length >= targetCount) {
|
||||||
|
return rows.slice(0, targetCount).map((row, index) => ({
|
||||||
|
...row,
|
||||||
|
shot: String(index + 1).padStart(2, "0"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCount = Math.floor(targetCount / rows.length);
|
||||||
|
const extraCount = targetCount % rows.length;
|
||||||
|
const expanded: NormalizedStoryScriptRow[] = [];
|
||||||
|
|
||||||
|
rows.forEach((row, rowIndex) => {
|
||||||
|
const segmentCount = baseCount + (rowIndex < extraCount ? 1 : 0);
|
||||||
|
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
|
||||||
|
const phase =
|
||||||
|
storyExpansionPhases[
|
||||||
|
Math.min(
|
||||||
|
storyExpansionPhases.length - 1,
|
||||||
|
Math.floor((segmentIndex / Math.max(segmentCount, 1)) * storyExpansionPhases.length),
|
||||||
|
)
|
||||||
|
];
|
||||||
|
expanded.push({
|
||||||
|
...row,
|
||||||
|
shot: String(expanded.length + 1).padStart(2, "0"),
|
||||||
|
sceneDescription: `${phase.description}:${row.sceneDescription}`,
|
||||||
|
shotSize: row.shotSize || phase.shotSize,
|
||||||
|
lighting: row.lighting || phase.lighting,
|
||||||
|
cameraMovement: row.cameraMovement || phase.cameraMovement,
|
||||||
|
imagePrompt: `${row.imagePrompt};${phase.imagePrompt}`,
|
||||||
|
motionPrompt: `${phase.motionPrompt};${row.motionPrompt}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return expanded.slice(0, targetCount);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCharacters(value: unknown) {
|
function normalizeCharacters(value: unknown) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value
|
return value
|
||||||
@ -243,11 +513,21 @@ function buildVideoAnalysisPrompt(params: {
|
|||||||
"highlights": ["3-5条镜头分析要点"],
|
"highlights": ["3-5条镜头分析要点"],
|
||||||
"analysisRows": [
|
"analysisRows": [
|
||||||
{
|
{
|
||||||
"sceneDescription": "镜头内容与景别描述",
|
"shot": "1",
|
||||||
|
"startTime": "0",
|
||||||
|
"endTime": "5",
|
||||||
|
"duration": "5",
|
||||||
|
"sceneDescription": "画面描述,说明主体、环境、动作、构图和色调",
|
||||||
|
"narrative": "叙事内容,说明这一镜头承担的剧情/情绪/信息作用",
|
||||||
|
"shotSize": "景别,例如中景至近景",
|
||||||
|
"cameraAngle": "摄影机角度,例如平视",
|
||||||
|
"cameraMovement": "摄影机运动,例如缓慢推镜头 (Slow Zoom In)",
|
||||||
|
"focusDepth": "焦距与景深,例如浅景深",
|
||||||
|
"lighting": "光线,例如冷调低沉照明,屏幕光作为侧逆光",
|
||||||
"backgroundMusic": "这一段适合的背景音乐氛围",
|
"backgroundMusic": "这一段适合的背景音乐氛围",
|
||||||
"voiceAndSfx": "人声/环境声/音效建议,没有就写无",
|
"voiceAndSfx": "人声/环境声/音效建议,没有就写无",
|
||||||
"imagePrompt": "后续生成关键帧时可直接使用的图像提示词",
|
"imagePrompt": "后续生成关键帧时可直接使用的英文图像提示词",
|
||||||
"motionPrompt": "后续生成视频时可直接使用的运动提示词",
|
"motionPrompt": "后续生成视频时可直接使用的英文运动提示词",
|
||||||
"keyframeIndex": 1
|
"keyframeIndex": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -257,7 +537,9 @@ function buildVideoAnalysisPrompt(params: {
|
|||||||
"visualMotifs": ["2-4个可复用的视觉母题"],
|
"visualMotifs": ["2-4个可复用的视觉母题"],
|
||||||
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
||||||
}`,
|
}`,
|
||||||
"analysisRows 请尽量输出 2-6 行;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
|
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
|
||||||
|
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
|
||||||
|
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
|
||||||
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
|
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -437,13 +719,63 @@ function buildAgentPlanPrompt(params: {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStoryScriptPrompt(params: {
|
||||||
|
nodeTitle?: string;
|
||||||
|
prompt: string;
|
||||||
|
imageCount: number;
|
||||||
|
videoCount: number;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
"你是视频画布里的故事脚本生成节点,需要把用户输入、参考图和参考视频整理成可直接用于后续分镜/生图/生视频的结构化脚本表。",
|
||||||
|
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
|
||||||
|
`参考图数量:${params.imageCount}`,
|
||||||
|
`视频数量:${params.videoCount}`,
|
||||||
|
params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请生成一个节奏清晰的短视频脚本。",
|
||||||
|
"",
|
||||||
|
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
||||||
|
"JSON 结构如下:",
|
||||||
|
`{
|
||||||
|
"summary": "一句话概括故事主题、情绪和视频方向,不超过120字",
|
||||||
|
"analysisRows": [
|
||||||
|
{
|
||||||
|
"shot": "01",
|
||||||
|
"duration": "5s",
|
||||||
|
"scene": "场景名或地点,如 盛唐·金銮殿·日",
|
||||||
|
"sceneDescription": "画面内容、人物动作和剧情推进,不要重复 scene 字段",
|
||||||
|
"shotSize": "景别,如中景、特写、全景、大远景",
|
||||||
|
"lighting": "光影氛围,如暖金主光带阴影,气氛紧张压抑",
|
||||||
|
"dialogue": "对白或旁白,没有就写 +",
|
||||||
|
"backgroundMusic": "背景音乐或情绪氛围,没有就写无",
|
||||||
|
"voiceAndSfx": "环境声、动作声或音效,没有就写无",
|
||||||
|
"cameraMovement": "运镜,如缓慢推镜头、固定、横移、上摇、环绕轨道跟拍",
|
||||||
|
"imagePrompt": "后续生成该镜头关键帧时可直接使用的中文图像提示词",
|
||||||
|
"motionPrompt": "后续生成视频时可直接使用的中文视频运动提示词"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assetCandidates": {
|
||||||
|
"style": "全局视觉风格,如 盛唐·国风二次元",
|
||||||
|
"characters": ["需要生成的角色资产名"],
|
||||||
|
"scenes": ["需要生成的场景资产名"],
|
||||||
|
"props": ["需要生成的道具资产名"]
|
||||||
|
},
|
||||||
|
"shotIdeas": [
|
||||||
|
{ "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" }
|
||||||
|
],
|
||||||
|
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
||||||
|
}`,
|
||||||
|
"analysisRows 必须直接输出 18 行,按镜头时间顺序编号 01-18;每行默认 5s 或 6s,剧情高潮可用 6s。",
|
||||||
|
"每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。",
|
||||||
|
"画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。",
|
||||||
|
"imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。",
|
||||||
|
"assetCandidates 根据 18 镜头抽取 3-6 个角色、2-5 个场景和 1-4 个道具,方便后续准备资产。",
|
||||||
|
"每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const parsed = requestSchema.safeParse(body);
|
const parsed = requestSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@ -468,6 +800,11 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const [allModels, agentModel] = await Promise.all([
|
const [allModels, agentModel] = await Promise.all([
|
||||||
getEnabledAiModelsWithProvider(false),
|
getEnabledAiModelsWithProvider(false),
|
||||||
getAgentAiModelWithProvider(),
|
getAgentAiModelWithProvider(),
|
||||||
@ -534,7 +871,14 @@ export async function POST(request: Request) {
|
|||||||
prompt,
|
prompt,
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
})
|
})
|
||||||
: buildAgentPlanPrompt({
|
: parsed.data.mode === "story-script"
|
||||||
|
? buildStoryScriptPrompt({
|
||||||
|
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||||
|
prompt,
|
||||||
|
imageCount: imageUrls.length,
|
||||||
|
videoCount: videoUrls.length,
|
||||||
|
})
|
||||||
|
: buildAgentPlanPrompt({
|
||||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||||
prompt,
|
prompt,
|
||||||
imageCount: imageUrls.length,
|
imageCount: imageUrls.length,
|
||||||
@ -568,7 +912,7 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
maxTokens: 1600,
|
maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
|
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
|
||||||
@ -619,6 +963,29 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.data.mode === "story-script") {
|
||||||
|
const analysisRows = expandStoryScriptRowsToTarget(normalizeStoryScriptRows(
|
||||||
|
parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots,
|
||||||
|
));
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
summary,
|
||||||
|
analysisRows,
|
||||||
|
shotIdeas:
|
||||||
|
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
|
||||||
|
? normalizeShotIdeas(parsedJson.shotIdeas)
|
||||||
|
: analysisRows.map((row, index) => ({
|
||||||
|
title: `镜头 ${index + 1}`,
|
||||||
|
description: row.sceneDescription,
|
||||||
|
})),
|
||||||
|
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
|
||||||
|
model: selectedModel.model.displayName,
|
||||||
|
raw: text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -7,18 +7,34 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
|||||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import {
|
||||||
|
isWorldLabsExternalTaskApi,
|
||||||
|
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
||||||
import type {
|
import type {
|
||||||
|
WorldLabsExternalTaskResponse,
|
||||||
|
WorldLabsExternalTaskResultData,
|
||||||
WorldLabsOperation,
|
WorldLabsOperation,
|
||||||
WorldLabsWorld,
|
WorldLabsWorld,
|
||||||
WorldLabsWorldAssets,
|
WorldLabsWorldAssets,
|
||||||
} from "@/lib/worldlabs/types";
|
} from "@/lib/worldlabs/types";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 180;
|
||||||
|
|
||||||
const logger = createLogger("world-labs-task-status");
|
const logger = createLogger("world-labs-task-status");
|
||||||
|
const WORLDLABS_TASK_STATUS_TIMEOUT_MS =
|
||||||
|
Number.parseInt(String(process.env.WORLDLABS_TASK_STATUS_TIMEOUT_MS || "").trim(), 10) ||
|
||||||
|
180_000;
|
||||||
|
|
||||||
|
function resolveWorldLabsStatusTimeoutMs() {
|
||||||
|
if (!Number.isFinite(WORLDLABS_TASK_STATUS_TIMEOUT_MS)) return 180_000;
|
||||||
|
if (WORLDLABS_TASK_STATUS_TIMEOUT_MS <= 0) return 180_000;
|
||||||
|
return WORLDLABS_TASK_STATUS_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
type ArchiveCacheItem = {
|
type ArchiveCacheItem = {
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
@ -410,6 +426,214 @@ function buildAssetPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||||
|
const root = getObjectValue(payload);
|
||||||
|
const data = getObjectValue(root?.data);
|
||||||
|
return (data || root || {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringRecord(value: unknown) {
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
const entries = Object.entries(record).filter(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
|
||||||
|
);
|
||||||
|
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedString(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): string | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = getStringValue(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedString(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedObject(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = getObjectValue(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedObject(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedStringRecord(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): Record<string, string> | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = readStringRecord(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedStringRecord(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExternalTaskErrorMessage(
|
||||||
|
data: WorldLabsExternalTaskResultData,
|
||||||
|
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||||
|
) {
|
||||||
|
const dataError = getObjectValue(data.error);
|
||||||
|
const payloadError = getObjectValue(payload.error);
|
||||||
|
return (
|
||||||
|
getStringValue(
|
||||||
|
data.errorMessage,
|
||||||
|
data.message,
|
||||||
|
dataError?.message,
|
||||||
|
payload.message,
|
||||||
|
payloadError?.message,
|
||||||
|
payload.error
|
||||||
|
) || "World Labs 外部任务失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) {
|
||||||
|
const root = getObjectValue(data) || {};
|
||||||
|
const resultRoot =
|
||||||
|
getObjectValue(root.result) ||
|
||||||
|
getObjectValue(root.output) ||
|
||||||
|
getObjectValue(root.world) ||
|
||||||
|
root;
|
||||||
|
const assets = findNestedObject(resultRoot, ["assets"]);
|
||||||
|
const worldAssets = assets as Partial<WorldLabsWorldAssets> | null;
|
||||||
|
const splats = getObjectValue(worldAssets?.splats);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worldId: getStringValue(
|
||||||
|
findNestedString(resultRoot, ["worldId", "world_id"]),
|
||||||
|
findNestedString(root, ["worldId", "world_id"])
|
||||||
|
),
|
||||||
|
worldUrl: getStringValue(
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"worldUrl",
|
||||||
|
"world_marble_url",
|
||||||
|
"sourceWorldUrl",
|
||||||
|
"viewerUrl",
|
||||||
|
"marbleUrl",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"worldUrl",
|
||||||
|
"world_marble_url",
|
||||||
|
"sourceWorldUrl",
|
||||||
|
"viewerUrl",
|
||||||
|
"marbleUrl",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
displayName: getStringValue(
|
||||||
|
findNestedString(resultRoot, ["displayName", "display_name"]),
|
||||||
|
findNestedString(root, ["displayName", "display_name"])
|
||||||
|
),
|
||||||
|
model:
|
||||||
|
getStringValue(
|
||||||
|
findNestedString(resultRoot, ["model", "modelId", "model_id"]),
|
||||||
|
findNestedString(root, ["model", "modelId", "model_id"])
|
||||||
|
) || WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
caption: getStringValue(
|
||||||
|
worldAssets?.caption,
|
||||||
|
findNestedString(resultRoot, ["caption"]),
|
||||||
|
findNestedString(root, ["caption"])
|
||||||
|
),
|
||||||
|
thumbnailUrl: getStringValue(
|
||||||
|
worldAssets?.thumbnail_url,
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"thumbnailUrl",
|
||||||
|
"thumbnail_url",
|
||||||
|
"previewImageUrl",
|
||||||
|
"preview_image_url",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"thumbnailUrl",
|
||||||
|
"thumbnail_url",
|
||||||
|
"previewImageUrl",
|
||||||
|
"preview_image_url",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
panoUrl: getStringValue(
|
||||||
|
worldAssets?.imagery?.pano_url,
|
||||||
|
findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]),
|
||||||
|
findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"])
|
||||||
|
),
|
||||||
|
meshUrl: getStringValue(
|
||||||
|
worldAssets?.mesh?.collider_mesh_url,
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"meshUrl",
|
||||||
|
"mesh_url",
|
||||||
|
"modelUrl",
|
||||||
|
"model_url",
|
||||||
|
"glbUrl",
|
||||||
|
"glb_url",
|
||||||
|
"colliderMeshUrl",
|
||||||
|
"collider_mesh_url",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"meshUrl",
|
||||||
|
"mesh_url",
|
||||||
|
"modelUrl",
|
||||||
|
"model_url",
|
||||||
|
"glbUrl",
|
||||||
|
"glb_url",
|
||||||
|
"colliderMeshUrl",
|
||||||
|
"collider_mesh_url",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
spzUrls:
|
||||||
|
readStringRecord(splats?.spz_urls) ||
|
||||||
|
findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) ||
|
||||||
|
findNestedStringRecord(root, ["spzUrls", "spz_urls"]),
|
||||||
|
semanticsMetadata:
|
||||||
|
getObjectValue(splats?.semantics_metadata) ||
|
||||||
|
findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) ||
|
||||||
|
findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ taskId: string }>;
|
params: Promise<{ taskId: string }>;
|
||||||
}
|
}
|
||||||
@ -443,9 +667,191 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
providerForLog = config.providerId || null;
|
providerForLog = config.providerId || null;
|
||||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
|
const persistedAssetPayload =
|
||||||
|
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
||||||
|
const shouldUseExternalTaskResultApi =
|
||||||
|
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
||||||
|
persistedAssetPayload.externalTaskApi !== false &&
|
||||||
|
persistedAssetPayload.marbleApi !== true;
|
||||||
|
if (shouldUseExternalTaskResultApi) {
|
||||||
|
const externalPayload = await worldLabsRequest<
|
||||||
|
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||||
|
>(config, {
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||||
|
});
|
||||||
|
const externalData =
|
||||||
|
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
|
||||||
|
const externalStatusText = getStringValue(externalData.status) || "running";
|
||||||
|
const externalStatus = normalizeExternalTaskStatus(
|
||||||
|
externalStatusText,
|
||||||
|
externalData.done === true
|
||||||
|
);
|
||||||
|
const externalAssetPayloadBase = {
|
||||||
|
...persistedAssetPayload,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
providerName: config.providerName || null,
|
||||||
|
externalTaskApi: true,
|
||||||
|
externalTaskStatus: externalStatusText,
|
||||||
|
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (externalStatus === "running") {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "running",
|
||||||
|
progress: null,
|
||||||
|
output: externalData,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalStatus === "failed") {
|
||||||
|
const errorMessage = extractExternalTaskErrorMessage(
|
||||||
|
externalData,
|
||||||
|
externalPayload
|
||||||
|
);
|
||||||
|
await updatePersistedWorldAsset({
|
||||||
|
id: persistedWorldAsset?.id,
|
||||||
|
status: "failed",
|
||||||
|
progress: null,
|
||||||
|
error: errorMessage,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
});
|
||||||
|
logger.warn("external task failed status", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
taskIdLength: normalizedTaskId.length,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "failed",
|
||||||
|
progress: null,
|
||||||
|
errorMessage,
|
||||||
|
output: externalData,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
|
||||||
|
const rawMeshUrl = extractedAssetPayload.meshUrl;
|
||||||
|
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
|
||||||
|
const rawPanoUrl = extractedAssetPayload.panoUrl;
|
||||||
|
const stableWorldUrl: string | null = null;
|
||||||
|
const stableModelUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
|
||||||
|
sourceUrl: rawMeshUrl,
|
||||||
|
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "glb",
|
||||||
|
validateAs: "glb",
|
||||||
|
});
|
||||||
|
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
||||||
|
sourceUrl: rawThumbnailUrl,
|
||||||
|
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
});
|
||||||
|
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
|
||||||
|
sourceUrl: rawPanoUrl,
|
||||||
|
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
});
|
||||||
|
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
|
||||||
|
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
|
||||||
|
sourceMap: extractedAssetPayload.spzUrls,
|
||||||
|
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "spz",
|
||||||
|
validateAs: "spz",
|
||||||
|
});
|
||||||
|
const assetPayload = {
|
||||||
|
...externalAssetPayloadBase,
|
||||||
|
worldId: extractedAssetPayload.worldId,
|
||||||
|
worldUrl: stableWorldUrl,
|
||||||
|
displayName: extractedAssetPayload.displayName,
|
||||||
|
model: resolveThreeDModelAliasId(
|
||||||
|
extractedAssetPayload.model,
|
||||||
|
modelAliasConfig.worldModels
|
||||||
|
),
|
||||||
|
caption: extractedAssetPayload.caption,
|
||||||
|
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
|
||||||
|
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
|
||||||
|
meshUrl: stableModelUrl,
|
||||||
|
spzUrls: stableSpzUrls,
|
||||||
|
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
|
||||||
|
storageArchivedAt: new Date().toISOString(),
|
||||||
|
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
|
||||||
|
sourceMeshUrl:
|
||||||
|
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||||
|
? rawMeshUrl
|
||||||
|
: null,
|
||||||
|
sourceThumbnailUrl:
|
||||||
|
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
|
||||||
|
? rawThumbnailUrl
|
||||||
|
: null,
|
||||||
|
sourcePanoUrl:
|
||||||
|
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
|
||||||
|
? rawPanoUrl
|
||||||
|
: null,
|
||||||
|
sourceSpzUrls:
|
||||||
|
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
|
||||||
|
? extractedAssetPayload.spzUrls
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updatePersistedWorldAsset({
|
||||||
|
id: persistedWorldAsset?.id,
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
modelUrl: stableModelUrl,
|
||||||
|
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||||
|
error: null,
|
||||||
|
assetPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("external task success status", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
taskIdLength: normalizedTaskId.length,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
hasMesh: Boolean(stableModelUrl),
|
||||||
|
hasPano: Boolean(stablePanoUrl),
|
||||||
|
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||||
|
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
output: externalData,
|
||||||
|
modelUrl: stableModelUrl,
|
||||||
|
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||||
|
worldId: assetPayload.worldId,
|
||||||
|
worldUrl: stableWorldUrl,
|
||||||
|
panoUrl: stablePanoUrl,
|
||||||
|
caption: assetPayload.caption,
|
||||||
|
assetPayload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!operation.done) {
|
if (!operation.done) {
|
||||||
@ -505,6 +911,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
|
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
|
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
|
||||||
|
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||||
});
|
});
|
||||||
world = world
|
world = world
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -12,18 +12,37 @@ import {
|
|||||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { get3DWorldCreditCost } from "@/lib/points/config";
|
import { get3DWorldCreditCost } from "@/lib/points/config";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
|
||||||
import {
|
import {
|
||||||
disableWorldLabsProvider,
|
isWorldLabsExternalTaskApi,
|
||||||
|
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
|
import {
|
||||||
getWorldLabsProviderConfig,
|
getWorldLabsProviderConfig,
|
||||||
getWorldLabsProviderConfigs,
|
getWorldLabsProviderConfigs,
|
||||||
|
reportWorldLabsProviderRuntimeFailure,
|
||||||
} from "@/lib/worldlabs/server";
|
} from "@/lib/worldlabs/server";
|
||||||
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
|
import type {
|
||||||
|
WorldLabsExternalTaskCreateData,
|
||||||
|
WorldLabsExternalTaskResponse,
|
||||||
|
WorldLabsModel,
|
||||||
|
WorldLabsOperation,
|
||||||
|
} from "@/lib/worldlabs/types";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 180;
|
||||||
|
|
||||||
const logger = createLogger("world-labs-task-create");
|
const logger = createLogger("world-labs-task-create");
|
||||||
|
const WORLDLABS_TASK_CREATE_TIMEOUT_MS =
|
||||||
|
Number.parseInt(String(process.env.WORLDLABS_TASK_CREATE_TIMEOUT_MS || "").trim(), 10) ||
|
||||||
|
180_000;
|
||||||
|
|
||||||
|
function resolveWorldLabsCreateTimeoutMs() {
|
||||||
|
if (!Number.isFinite(WORLDLABS_TASK_CREATE_TIMEOUT_MS)) return 180_000;
|
||||||
|
if (WORLDLABS_TASK_CREATE_TIMEOUT_MS <= 0) return 180_000;
|
||||||
|
return WORLDLABS_TASK_CREATE_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
type WorldTaskMode = "text" | "image" | "multi-image" | "video";
|
type WorldTaskMode = "text" | "image" | "multi-image" | "video";
|
||||||
|
|
||||||
@ -38,8 +57,10 @@ type CreateWorldTaskRequest = {
|
|||||||
makePublic?: boolean;
|
makePublic?: boolean;
|
||||||
reconstructImages?: boolean;
|
reconstructImages?: boolean;
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
mediaAssets?: Array<{
|
mediaAssets?: Array<{
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
azimuth?: number | null;
|
azimuth?: number | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@ -49,10 +70,89 @@ function readOptionalTrimmedText(input: unknown) {
|
|||||||
return value || null;
|
return value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalHttpUrl(input: unknown) {
|
||||||
|
const value = readOptionalTrimmedText(input);
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||||
|
? parsed.toString()
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectValue(value: unknown) {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||||
|
const root = getObjectValue(payload);
|
||||||
|
const data = getObjectValue(root?.data);
|
||||||
|
return (data || root || {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskCreateOperation(
|
||||||
|
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||||
|
): WorldLabsOperation {
|
||||||
|
const data = unwrapExternalTaskData<WorldLabsExternalTaskCreateData>(payload);
|
||||||
|
const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
|
||||||
|
const status = readOptionalTrimmedText(data.status) || "accepted";
|
||||||
|
const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
|
||||||
|
return {
|
||||||
|
operation_id: taskId || undefined,
|
||||||
|
done: normalizedStatus === "success",
|
||||||
|
error:
|
||||||
|
normalizedStatus === "failed"
|
||||||
|
? {
|
||||||
|
message:
|
||||||
|
readOptionalTrimmedText(
|
||||||
|
(payload as Record<string, unknown>)?.message ||
|
||||||
|
(payload as Record<string, unknown>)?.error
|
||||||
|
) || "World Labs 外部任务创建失败",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
metadata: {
|
||||||
|
externalTaskApi: true,
|
||||||
|
status,
|
||||||
|
resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
|
||||||
|
requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
|
||||||
|
},
|
||||||
|
response: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
||||||
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseExternalTaskCreateApi(
|
||||||
|
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||||
|
mode: WorldTaskMode
|
||||||
|
) {
|
||||||
|
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOfficialMarbleModel(model?: string | null) {
|
||||||
|
const normalized = readOptionalTrimmedText(model);
|
||||||
|
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
|
||||||
|
return "marble-1.1";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||||
const textPrompt = readOptionalTrimmedText(body.prompt);
|
const textPrompt = readOptionalTrimmedText(body.prompt);
|
||||||
|
|
||||||
@ -71,15 +171,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
|
|
||||||
if (mode === "image") {
|
if (mode === "image") {
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
if (!mediaAssetId) {
|
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||||
throw new Error("图生世界缺少 mediaAssetId");
|
if (!mediaAssetId && !sourceUrl) {
|
||||||
|
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: "image",
|
type: "image",
|
||||||
image_prompt: {
|
image_prompt: mediaAssetId
|
||||||
source: "media_asset",
|
? {
|
||||||
media_asset_id: mediaAssetId,
|
source: "media_asset",
|
||||||
},
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
|
},
|
||||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||||
...(body.disableRecaption !== undefined
|
...(body.disableRecaption !== undefined
|
||||||
? { disable_recaption: body.disableRecaption }
|
? { disable_recaption: body.disableRecaption }
|
||||||
@ -93,7 +199,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
const multiImagePrompt = mediaAssets
|
const multiImagePrompt = mediaAssets
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
||||||
if (!mediaAssetId) return null;
|
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
||||||
|
if (!mediaAssetId && !sourceUrl) return null;
|
||||||
const azimuth =
|
const azimuth =
|
||||||
item?.azimuth === null || item?.azimuth === undefined
|
item?.azimuth === null || item?.azimuth === undefined
|
||||||
? null
|
? null
|
||||||
@ -102,10 +209,15 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
...(azimuth === null ? {} : { azimuth }),
|
...(azimuth === null ? {} : { azimuth }),
|
||||||
content: {
|
content: mediaAssetId
|
||||||
source: "media_asset",
|
? {
|
||||||
media_asset_id: mediaAssetId,
|
source: "media_asset",
|
||||||
},
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -128,21 +240,27 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
if (!mediaAssetId) {
|
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||||
throw new Error("视频生成世界缺少 mediaAssetId");
|
if (!mediaAssetId && !sourceUrl) {
|
||||||
|
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "video",
|
type: "video",
|
||||||
video_prompt: {
|
video_prompt: mediaAssetId
|
||||||
source: "media_asset",
|
? {
|
||||||
media_asset_id: mediaAssetId,
|
source: "media_asset",
|
||||||
},
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
|
},
|
||||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPermanentProviderError(error: unknown) {
|
function isCredentialOrBillingProviderError(error: unknown) {
|
||||||
return (
|
return (
|
||||||
error instanceof WorldLabsApiError &&
|
error instanceof WorldLabsApiError &&
|
||||||
[401, 402, 403].includes(error.httpStatus)
|
[401, 402, 403].includes(error.httpStatus)
|
||||||
@ -178,12 +296,29 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
modelForLog = resolvedWorldModel || "marble-1.1";
|
modelForLog = resolvedWorldModel || "marble-1.1";
|
||||||
|
|
||||||
|
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
||||||
|
const displayName =
|
||||||
|
readOptionalTrimmedText(body.displayName) ||
|
||||||
|
readOptionalTrimmedText(body.prompt) ||
|
||||||
|
`World ${new Date().toLocaleString("zh-CN")}`;
|
||||||
|
const worldPrompt = buildWorldPrompt(body, mode);
|
||||||
|
|
||||||
|
const candidates = preferredProviderId
|
||||||
|
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
||||||
|
: await getWorldLabsProviderConfigs();
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error("未找到可用的 World Labs Key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
|
||||||
|
const predictedBillingModel = candidates.some((candidate) =>
|
||||||
|
shouldUseExternalTaskCreateApi(candidate, mode)
|
||||||
|
)
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel;
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||||||
const unitCost = get3DWorldCreditCost(
|
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
|
||||||
pointsConfig,
|
|
||||||
resolvedWorldModel || "marble-1.1",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldChargePoints &&
|
shouldChargePoints &&
|
||||||
@ -211,47 +346,57 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
|
||||||
const displayName =
|
|
||||||
readOptionalTrimmedText(body.displayName) ||
|
|
||||||
readOptionalTrimmedText(body.prompt) ||
|
|
||||||
`World ${new Date().toLocaleString("zh-CN")}`;
|
|
||||||
const worldPrompt = buildWorldPrompt(body, mode);
|
|
||||||
|
|
||||||
const candidates = preferredProviderId
|
|
||||||
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
|
||||||
: await getWorldLabsProviderConfigs();
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
throw new Error("未找到可用的 World Labs Key");
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: WorldLabsOperation | null = null;
|
let result: WorldLabsOperation | null = null;
|
||||||
let usedProvider = null as Awaited<
|
let usedProvider = null as Awaited<
|
||||||
ReturnType<typeof getWorldLabsProviderConfig>
|
ReturnType<typeof getWorldLabsProviderConfig>
|
||||||
> | null;
|
> | null;
|
||||||
|
let usedExternalTaskApi = false;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
||||||
method: "POST",
|
if (useExternalTaskApi) {
|
||||||
path: "/marble/v1/worlds:generate",
|
const prompt = readOptionalTrimmedText(body.prompt);
|
||||||
json: {
|
if (!prompt) {
|
||||||
display_name: displayName,
|
throw new Error("请输入世界描述");
|
||||||
model: resolvedWorldModel || "marble-1.1",
|
}
|
||||||
permission: {
|
const externalResult = await worldLabsRequest<
|
||||||
public: body.makePublic === true,
|
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||||
|
>(candidate, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/external/task/generate",
|
||||||
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
|
json: {
|
||||||
|
model: WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
displayName,
|
||||||
|
prompt,
|
||||||
},
|
},
|
||||||
world_prompt: worldPrompt,
|
});
|
||||||
},
|
result = normalizeExternalTaskCreateOperation(externalResult);
|
||||||
});
|
} else {
|
||||||
|
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/marble/v1/worlds:generate",
|
||||||
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
|
json: {
|
||||||
|
display_name: displayName,
|
||||||
|
model: officialMarbleModel,
|
||||||
|
permission: {
|
||||||
|
public: body.makePublic === true,
|
||||||
|
},
|
||||||
|
world_prompt: worldPrompt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
usedProvider = candidate;
|
usedProvider = candidate;
|
||||||
|
usedExternalTaskApi = useExternalTaskApi;
|
||||||
providerForLog = candidate.providerId || null;
|
providerForLog = candidate.providerId || null;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (isPermanentProviderError(error)) {
|
if (isCredentialOrBillingProviderError(error)) {
|
||||||
await disableWorldLabsProvider(
|
await reportWorldLabsProviderRuntimeFailure(
|
||||||
candidate.providerId,
|
candidate.providerId,
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
);
|
);
|
||||||
@ -282,12 +427,18 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
||||||
try {
|
try {
|
||||||
|
const usedBillingModel = usedExternalTaskApi
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel;
|
||||||
|
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: worldModelDisplayName;
|
||||||
await consumeUserPoints({
|
await consumeUserPoints({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
amount: unitCost,
|
amount: unitCost,
|
||||||
reason: "world3d_generation",
|
reason: "world3d_generation",
|
||||||
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
modelId: `worldlabs-3d:${usedBillingModel}`,
|
||||||
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("points consume failed", error, {
|
logger.error("points consume failed", error, {
|
||||||
@ -303,10 +454,13 @@ export async function POST(request: Request) {
|
|||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
taskIdLength: taskId.length,
|
taskIdLength: taskId.length,
|
||||||
mode,
|
mode,
|
||||||
model: resolvedWorldModel || "marble-1.1",
|
model: usedExternalTaskApi
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel,
|
||||||
providerId: usedProvider.providerId || null,
|
providerId: usedProvider.providerId || null,
|
||||||
status: result.done ? "success" : "running",
|
status: result.done ? "success" : "running",
|
||||||
unitCost,
|
unitCost,
|
||||||
|
externalTaskApi: usedExternalTaskApi,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -317,6 +471,8 @@ export async function POST(request: Request) {
|
|||||||
assetPayload: {
|
assetPayload: {
|
||||||
providerId: usedProvider.providerId || null,
|
providerId: usedProvider.providerId || null,
|
||||||
providerName: usedProvider.providerName || null,
|
providerName: usedProvider.providerName || null,
|
||||||
|
externalTaskApi: usedExternalTaskApi,
|
||||||
|
marbleApi: !usedExternalTaskApi,
|
||||||
},
|
},
|
||||||
raw: result,
|
raw: result,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
import {
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import {
|
import {
|
||||||
disableWorldLabsProvider,
|
|
||||||
getWorldLabsProviderConfig,
|
getWorldLabsProviderConfig,
|
||||||
getWorldLabsProviderConfigs,
|
getWorldLabsProviderConfigs,
|
||||||
|
reportWorldLabsProviderRuntimeFailure,
|
||||||
} from "@/lib/worldlabs/server";
|
} from "@/lib/worldlabs/server";
|
||||||
import type { WorldLabsPrepareUploadResponse } from "@/lib/worldlabs/types";
|
import type { WorldLabsPrepareUploadResponse } from "@/lib/worldlabs/types";
|
||||||
|
|
||||||
@ -112,7 +116,82 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
|
|||||||
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPermanentProviderError(error: unknown) {
|
function readOptionalString(value: unknown) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUploadInfoFromCurlExample(curlExample: unknown) {
|
||||||
|
const curl = String(curlExample || "");
|
||||||
|
const uploadUrl =
|
||||||
|
[...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
.find(Boolean) || null;
|
||||||
|
const requiredHeaders: Record<string, string> = {};
|
||||||
|
const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
|
||||||
|
let headerMatch: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((headerMatch = headerPattern.exec(curl))) {
|
||||||
|
const header = String(
|
||||||
|
headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
|
||||||
|
).trim();
|
||||||
|
const colonIndex = header.indexOf(":");
|
||||||
|
if (colonIndex <= 0) continue;
|
||||||
|
const name = header.slice(0, colonIndex).trim();
|
||||||
|
const value = header.slice(colonIndex + 1).trim();
|
||||||
|
if (name && value) {
|
||||||
|
requiredHeaders[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadUrl, requiredHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
|
||||||
|
const uploadInfo = data.upload_info || {};
|
||||||
|
const uploadInfoRecord = uploadInfo as Record<string, unknown>;
|
||||||
|
const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
|
||||||
|
const explicitHeaders =
|
||||||
|
uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
|
||||||
|
? uploadInfo.required_headers
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadUrl:
|
||||||
|
readOptionalString(uploadInfo.upload_url) ||
|
||||||
|
readOptionalString(uploadInfoRecord.uploadUrl) ||
|
||||||
|
readOptionalString(uploadInfoRecord.url) ||
|
||||||
|
curlDetails.uploadUrl,
|
||||||
|
uploadMethod:
|
||||||
|
readOptionalString(uploadInfo.upload_method) ||
|
||||||
|
readOptionalString(uploadInfoRecord.method) ||
|
||||||
|
"PUT",
|
||||||
|
requiredHeaders: {
|
||||||
|
...curlDetails.requiredHeaders,
|
||||||
|
...explicitHeaders,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignedUploadFailure(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
|
return (
|
||||||
|
message.includes("SignatureDoesNotMatch") ||
|
||||||
|
message.includes("Access denied") ||
|
||||||
|
message.includes("Google secret key") ||
|
||||||
|
message.includes("签名")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWorldLabsInputToStorage(file: File, extension: string) {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const safeExtension = extension && extension !== "bin" ? extension : "bin";
|
||||||
|
const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
|
||||||
|
const uploaded = await uploadFile(filename, buffer);
|
||||||
|
return uploaded.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCredentialOrBillingProviderError(error: unknown) {
|
||||||
return (
|
return (
|
||||||
error instanceof WorldLabsApiError &&
|
error instanceof WorldLabsApiError &&
|
||||||
[401, 402, 403].includes(error.httpStatus)
|
[401, 402, 403].includes(error.httpStatus)
|
||||||
@ -202,24 +281,24 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
||||||
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
|
const { uploadUrl, uploadMethod, requiredHeaders } =
|
||||||
|
resolvePrepareUploadDetails(prepareUpload);
|
||||||
if (!mediaAssetId || !uploadUrl) {
|
if (!mediaAssetId || !uploadUrl) {
|
||||||
throw new Error("World Labs 返回的上传信息不完整");
|
throw new Error("World Labs 返回的上传信息不完整");
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadHeaders = new Headers();
|
const uploadHeaders = new Headers();
|
||||||
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
|
||||||
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
|
|
||||||
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
|
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
|
||||||
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
||||||
uploadHeaders.set(headerName, String(headerValue));
|
uploadHeaders.set(headerName, String(headerValue));
|
||||||
}
|
}
|
||||||
|
if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
|
||||||
|
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
const uploadMethod = String(
|
const normalizedUploadMethod = uploadMethod.toUpperCase();
|
||||||
prepareUpload.upload_info?.upload_method || "PUT"
|
|
||||||
).toUpperCase();
|
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
|
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
|
||||||
headers: uploadHeaders,
|
headers: uploadHeaders,
|
||||||
body: file,
|
body: file,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -252,8 +331,33 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (isPermanentProviderError(error)) {
|
if (isSignedUploadFailure(error)) {
|
||||||
await disableWorldLabsProvider(config.providerId, (error as Error).message);
|
try {
|
||||||
|
const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
|
||||||
|
logger.warn("worldlabs signed upload failed; using storage source url", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
kind: isImage ? "image" : "video",
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceUrl,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
providerName: config.providerName || null,
|
||||||
|
uploadFallback: "storage-source-url",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fallbackError) {
|
||||||
|
lastError = fallbackError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isCredentialOrBillingProviderError(error)) {
|
||||||
|
await reportWorldLabsProviderRuntimeFailure(
|
||||||
|
config.providerId,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (requestedProviderId) {
|
if (requestedProviderId) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { clientPerformanceEvent } from "@/lib/db/schema";
|
import { clientPerformanceEvent } from "@/lib/db/schema";
|
||||||
|
|
||||||
@ -167,9 +168,12 @@ export async function POST(request: Request) {
|
|||||||
console.warn("[ClientPerformance] prune failed", error);
|
console.warn("[ClientPerformance] prune failed", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persistedChatId =
|
||||||
|
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
|
||||||
|
|
||||||
await db.insert(clientPerformanceEvent).values({
|
await db.insert(clientPerformanceEvent).values({
|
||||||
userId: session?.user?.id || null,
|
userId: session?.user?.id || null,
|
||||||
chatId: event.chatId || null,
|
chatId: persistedChatId,
|
||||||
projectId: clampText(event.projectId, 64),
|
projectId: clampText(event.projectId, 64),
|
||||||
eventType: clampText(event.eventType, 64) || "unknown",
|
eventType: clampText(event.eventType, 64) || "unknown",
|
||||||
section: event.section || "unknown",
|
section: event.section || "unknown",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
createMediaAssetRelation,
|
createMediaAssetRelation,
|
||||||
|
getAiProvidersByType,
|
||||||
getMediaAssetsByUserAndUrls,
|
getMediaAssetsByUserAndUrls,
|
||||||
getSystemSetting,
|
getSystemSetting,
|
||||||
upsertMediaAsset,
|
upsertMediaAsset,
|
||||||
@ -9,9 +10,12 @@ import { auth } from "@/app/(auth)/auth";
|
|||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
|
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
|
||||||
|
import { createFalClient } from "@fal-ai/client";
|
||||||
|
|
||||||
// 变清晰API配置类型
|
// 变清晰API配置类型
|
||||||
type EnhanceApiConfig = {
|
type EnhanceApiConfig = {
|
||||||
|
provider?: "vip98k" | "aoscdn";
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
type?: string; // "clean" | "face"
|
type?: string; // "clean" | "face"
|
||||||
@ -317,6 +321,155 @@ async function processWithRealESRGANBase64(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processWithFalEsrgan(
|
||||||
|
imageBase64: string,
|
||||||
|
requestedScaleFactor: number,
|
||||||
|
): Promise<{ imageBase64: string; processingTime: number }> {
|
||||||
|
const providers = await getAiProvidersByType({
|
||||||
|
type: "fal-image",
|
||||||
|
enabledOnly: true,
|
||||||
|
});
|
||||||
|
const provider = providers.find((item) => item.apiKey?.trim());
|
||||||
|
if (!provider?.apiKey) {
|
||||||
|
throw new Error("未配置可用的 FAL 专用高清服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.max(2, Math.min(8, requestedScaleFactor || 4));
|
||||||
|
const model = scale <= 2 ? "RealESRGAN_x2plus" : "RealESRGAN_x4plus";
|
||||||
|
const credentials = provider.apiKey
|
||||||
|
.trim()
|
||||||
|
.replace(/^(?:Key|Bearer)\s+/i, "");
|
||||||
|
const fal = createFalClient({ credentials });
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result: any = await fal.subscribe("fal-ai/esrgan", {
|
||||||
|
input: {
|
||||||
|
image_url: `data:image/jpeg;base64,${imageBase64}`,
|
||||||
|
scale,
|
||||||
|
model,
|
||||||
|
output_format: "png",
|
||||||
|
},
|
||||||
|
logs: true,
|
||||||
|
timeout: 180_000,
|
||||||
|
});
|
||||||
|
const outputUrl = result?.data?.image?.url || result?.image?.url;
|
||||||
|
if (!outputUrl) {
|
||||||
|
throw new Error("FAL ESRGAN 专用高清服务未返回图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputResponse = await fetch(outputUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(120_000),
|
||||||
|
});
|
||||||
|
if (!outputResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`FAL ESRGAN 高清结果下载失败(HTTP ${outputResponse.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageBase64: Buffer.from(await outputResponse.arrayBuffer()).toString(
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
processingTime: (Date.now() - startedAt) / 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithVip98kEnhance(params: {
|
||||||
|
userId: string;
|
||||||
|
imageBase64: string;
|
||||||
|
prompt: string;
|
||||||
|
chatId: string | null;
|
||||||
|
messageId: string | null;
|
||||||
|
sourceImageUrl: string | null;
|
||||||
|
scaleFactor: number;
|
||||||
|
}) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result = await executeConfiguredVip98kTask({
|
||||||
|
modelId: "enhance",
|
||||||
|
kind: "image-tool",
|
||||||
|
prompt: `${params.prompt || "Enhance image clarity"}. Upscale ${params.scaleFactor}x while preserving composition, color and texture.`,
|
||||||
|
imageUrls: [`data:image/jpeg;base64,${params.imageBase64}`],
|
||||||
|
imageParams: { scaleFactor: params.scaleFactor },
|
||||||
|
});
|
||||||
|
const outputUrl = result.outputUrls[0];
|
||||||
|
if (!outputUrl) throw new Error("VIP98K 变清晰未返回图片");
|
||||||
|
|
||||||
|
const response = await fetch(outputUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(120_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`VIP98K 变清晰结果下载失败(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
const outputBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
const outputBase64 = outputBuffer.toString("base64");
|
||||||
|
const uploaded = await uploadBase64ToS3(outputBase64, "image/png");
|
||||||
|
const [savedImage] = await db
|
||||||
|
.insert(generatedImage)
|
||||||
|
.values({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
thumbnailUrl: uploaded.thumbnail,
|
||||||
|
originalUrl: uploaded.original,
|
||||||
|
imageSize: `${params.scaleFactor}x放大`,
|
||||||
|
prompt: params.prompt,
|
||||||
|
modelId: "enhance",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceAssets = params.sourceImageUrl
|
||||||
|
? await getMediaAssetsByUserAndUrls({
|
||||||
|
userId: params.userId,
|
||||||
|
urls: [params.sourceImageUrl],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const media = await upsertMediaAsset({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
mediaType: "image",
|
||||||
|
sourceType: "edited",
|
||||||
|
status: "ready",
|
||||||
|
originalUrl: uploaded.original,
|
||||||
|
previewUrl: uploaded.original,
|
||||||
|
thumbnailUrl: uploaded.thumbnail,
|
||||||
|
displayName: params.prompt.slice(0, 80),
|
||||||
|
prompt: params.prompt,
|
||||||
|
modelId: "enhance",
|
||||||
|
sourceMessageId: params.messageId,
|
||||||
|
sourceTable: "GeneratedImage",
|
||||||
|
sourceRowId: savedImage?.id || null,
|
||||||
|
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||||||
|
metadata: { provider: "vip98k", scaleFactor: params.scaleFactor },
|
||||||
|
});
|
||||||
|
if (media) {
|
||||||
|
for (const sourceAsset of sourceAssets) {
|
||||||
|
await createMediaAssetRelation({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
fromAssetId: sourceAsset.id,
|
||||||
|
toAssetId: media.id,
|
||||||
|
relationType: "edited_from",
|
||||||
|
sourceMessageId: params.messageId,
|
||||||
|
metadata: { prompt: params.prompt, provider: "vip98k" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Enhance-VIP98K] MediaAsset registration failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
image_base64: outputBase64,
|
||||||
|
thumbnail_url: uploaded.thumbnail,
|
||||||
|
original_url: uploaded.original,
|
||||||
|
processing_time: (Date.now() - startedAt) / 1000,
|
||||||
|
api_type: "vip98k",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮询获取任务结果(根据官方示例)
|
* 轮询获取任务结果(根据官方示例)
|
||||||
*/
|
*/
|
||||||
@ -536,6 +689,42 @@ async function finalizeEnhanceTaskResult(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeFalEsrganFallback(params: {
|
||||||
|
userId: string;
|
||||||
|
imageBase64: string;
|
||||||
|
prompt: string;
|
||||||
|
chatId: string | null;
|
||||||
|
messageId: string | null;
|
||||||
|
sourceImageUrl: string | null;
|
||||||
|
requestedScaleFactor?: number;
|
||||||
|
reason: string;
|
||||||
|
}) {
|
||||||
|
const scaleFactor = params.requestedScaleFactor || 4;
|
||||||
|
console.warn("[Enhance-FAL-ESRGAN] 启用专用高清备用链路", {
|
||||||
|
reason: params.reason.slice(0, 500),
|
||||||
|
scaleFactor,
|
||||||
|
});
|
||||||
|
const result = await processWithFalEsrgan(params.imageBase64, scaleFactor);
|
||||||
|
const payload = await finalizeEnhanceTaskResult({
|
||||||
|
userId: params.userId,
|
||||||
|
taskResult: {
|
||||||
|
status: 200,
|
||||||
|
data: { image_base64: result.imageBase64 },
|
||||||
|
},
|
||||||
|
prompt: params.prompt || "图片变清晰处理",
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
sourceImageUrls: params.sourceImageUrl
|
||||||
|
? [params.sourceImageUrl]
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
processing_time: result.processingTime,
|
||||||
|
api_type: "fal-esrgan",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -563,6 +752,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = enhanceConfig.value as EnhanceApiConfig;
|
const config = enhanceConfig.value as EnhanceApiConfig;
|
||||||
|
if (config.provider === "vip98k") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "VIP98K 变清晰任务在 POST 请求内完成,无需 taskId 轮询" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (config.useBackupApi) {
|
if (config.useBackupApi) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
|
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
|
||||||
@ -651,12 +846,15 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = enhanceConfig.value as EnhanceApiConfig;
|
const config = enhanceConfig.value as EnhanceApiConfig;
|
||||||
|
const useVip98k = config.provider === "vip98k";
|
||||||
|
|
||||||
// 检查是否使用备用API
|
// 检查是否使用备用API
|
||||||
const useBackupApi = config.useBackupApi === true;
|
const useBackupApi = !useVip98k && config.useBackupApi === true;
|
||||||
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
|
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
|
||||||
|
|
||||||
if (useBackupApi) {
|
if (useVip98k) {
|
||||||
|
// VIP98K 密钥只从 AiProvider 读取。
|
||||||
|
} else if (useBackupApi) {
|
||||||
// 验证备用API配置
|
// 验证备用API配置
|
||||||
if (!config.backupApiUrl || !config.backupApiKey) {
|
if (!config.backupApiUrl || !config.backupApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -757,6 +955,31 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useVip98k) {
|
||||||
|
try {
|
||||||
|
const scaleFactor = requestedScaleFactor || 4;
|
||||||
|
const payload = await processWithVip98kEnhance({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt: prompt || "图片变清晰处理",
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
scaleFactor,
|
||||||
|
});
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Enhance-VIP98K] Processing failed", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "VIP98K 变清晰处理失败",
|
||||||
|
detail: error instanceof Error ? error.message : "未知错误",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 备用API处理路径 (Real-ESRGAN) ==========
|
// ========== 备用API处理路径 (Real-ESRGAN) ==========
|
||||||
if (useBackupApi) {
|
if (useBackupApi) {
|
||||||
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
|
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
|
||||||
@ -894,6 +1117,21 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Enhance-RealESRGAN] ❌ 处理失败:", error);
|
console.error("[Enhance-RealESRGAN] ❌ 处理失败:", error);
|
||||||
|
try {
|
||||||
|
const fallbackPayload = await executeFalEsrganFallback({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
requestedScaleFactor,
|
||||||
|
reason: error instanceof Error ? error.message : "RealESRGAN 备用服务失败",
|
||||||
|
});
|
||||||
|
return NextResponse.json(fallbackPayload);
|
||||||
|
} catch (falError) {
|
||||||
|
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "Real-ESRGAN变清晰处理失败",
|
error: "Real-ESRGAN变清晰处理失败",
|
||||||
@ -1048,6 +1286,22 @@ export async function POST(request: NextRequest) {
|
|||||||
enhanceUrl,
|
enhanceUrl,
|
||||||
processingTime,
|
processingTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fallbackPayload = await executeFalEsrganFallback({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
requestedScaleFactor,
|
||||||
|
reason: fetchError instanceof Error ? fetchError.message : "AOS 高清任务创建失败",
|
||||||
|
});
|
||||||
|
return NextResponse.json(fallbackPayload);
|
||||||
|
} catch (falError) {
|
||||||
|
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否是网络连接错误
|
// 检查是否是网络连接错误
|
||||||
if (fetchError instanceof Error) {
|
if (fetchError instanceof Error) {
|
||||||
@ -1114,6 +1368,21 @@ export async function POST(request: NextRequest) {
|
|||||||
return buildEnhanceTaskPendingResponse(taskId);
|
return buildEnhanceTaskPendingResponse(taskId);
|
||||||
}
|
}
|
||||||
const detail = pollError instanceof Error ? pollError.message : "轮询失败";
|
const detail = pollError instanceof Error ? pollError.message : "轮询失败";
|
||||||
|
try {
|
||||||
|
const fallbackPayload = await executeFalEsrganFallback({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
requestedScaleFactor,
|
||||||
|
reason: detail,
|
||||||
|
});
|
||||||
|
return NextResponse.json(fallbackPayload);
|
||||||
|
} catch (falError) {
|
||||||
|
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||||
|
}
|
||||||
const friendlyMessage = getAosFriendlyErrorMessage(detail);
|
const friendlyMessage = getAosFriendlyErrorMessage(detail);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "变清晰处理失败", detail: friendlyMessage || detail },
|
{ error: "变清晰处理失败", detail: friendlyMessage || detail },
|
||||||
|
|||||||
221
app/api/external/image/generate/route.ts
vendored
221
app/api/external/image/generate/route.ts
vendored
@ -8,115 +8,18 @@ import {
|
|||||||
authenticateExternalImageApiRequest,
|
authenticateExternalImageApiRequest,
|
||||||
buildExternalImageApiModelCandidates,
|
buildExternalImageApiModelCandidates,
|
||||||
buildExternalImageApiKeyFingerprint,
|
buildExternalImageApiKeyFingerprint,
|
||||||
ensureHostedImageMarkdown,
|
ExternalImageApiGenerationError,
|
||||||
ensureHostedReferenceImageUrls,
|
ensureHostedReferenceImageUrls,
|
||||||
extractGeneratedImagesFromMarkdown,
|
|
||||||
generateImagesWithFailover,
|
generateImagesWithFailover,
|
||||||
getRequesterIp,
|
getRequesterIp,
|
||||||
|
normalizeExternalImageApiImageSize,
|
||||||
resolveExternalImageApiOwnerUserId,
|
resolveExternalImageApiOwnerUserId,
|
||||||
serializeExternalImageApiModel,
|
serializeExternalImageApiModel,
|
||||||
uploadExternalImageApiFile,
|
uploadExternalImageApiFile,
|
||||||
} from "@/lib/external-image-api/service";
|
} from "@/lib/external-image-api/service";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
|
|
||||||
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
|
|
||||||
const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
|
|
||||||
const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
|
|
||||||
|
|
||||||
class ExternalImageApiBusyError extends Error {
|
|
||||||
readonly retryAfterSeconds: number;
|
|
||||||
|
|
||||||
constructor(message: string, retryAfterSeconds = 15) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ExternalImageApiBusyError";
|
|
||||||
this.retryAfterSeconds = retryAfterSeconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExternalImageApiWaiter = {
|
|
||||||
resolve: (release: () => void) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
timer: ReturnType<typeof setTimeout>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExternalImageApiLimiterState = {
|
|
||||||
running: number;
|
|
||||||
queue: ExternalImageApiWaiter[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function parsePositiveInteger(value: unknown, fallback: number) {
|
|
||||||
const parsed = Number.parseInt(String(value || ""), 10);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
|
|
||||||
const globalState = globalThis as typeof globalThis & {
|
|
||||||
[EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
|
|
||||||
globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
|
|
||||||
running: 0,
|
|
||||||
queue: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function acquireExternalImageApiSlot(): Promise<() => void> {
|
|
||||||
const state = getExternalImageApiLimiterState();
|
|
||||||
const maxConcurrent = parsePositiveInteger(
|
|
||||||
process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
|
||||||
);
|
|
||||||
const maxQueue = parsePositiveInteger(
|
|
||||||
process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
|
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
|
|
||||||
);
|
|
||||||
const queueTimeoutMs = parsePositiveInteger(
|
|
||||||
process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const release = () => {
|
|
||||||
state.running = Math.max(0, state.running - 1);
|
|
||||||
const next = state.queue.shift();
|
|
||||||
if (!next) return;
|
|
||||||
clearTimeout(next.timer);
|
|
||||||
state.running += 1;
|
|
||||||
next.resolve(release);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (state.running < maxConcurrent) {
|
|
||||||
state.running += 1;
|
|
||||||
return Promise.resolve(release);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.queue.length >= maxQueue) {
|
|
||||||
return Promise.reject(
|
|
||||||
new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const waiter: ExternalImageApiWaiter = {
|
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
timer: setTimeout(() => {
|
|
||||||
const index = state.queue.indexOf(waiter);
|
|
||||||
if (index >= 0) {
|
|
||||||
state.queue.splice(index, 1);
|
|
||||||
}
|
|
||||||
reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
|
|
||||||
}, queueTimeoutMs),
|
|
||||||
};
|
|
||||||
state.queue.push(waiter);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
||||||
@ -124,8 +27,19 @@ const requestSchema = z.object({
|
|||||||
imageCount: z.coerce.number().int().min(1).max(8).optional(),
|
imageCount: z.coerce.number().int().min(1).max(8).optional(),
|
||||||
aspectRatio: z.string().trim().max(32).optional(),
|
aspectRatio: z.string().trim().max(32).optional(),
|
||||||
imageSize: z.string().trim().max(32).optional(),
|
imageSize: z.string().trim().max(32).optional(),
|
||||||
|
model: z.string().trim().optional(),
|
||||||
|
modelId: z.string().trim().optional(),
|
||||||
|
modelRef: z.string().trim().optional(),
|
||||||
metadata: z.record(z.string(), z.any()).optional(),
|
metadata: z.record(z.string(), z.any()).optional(),
|
||||||
referenceImages: z.array(z.string().trim().min(1)).max(14).optional(),
|
referenceImages: z.array(z.string().trim().min(1)).max(14).optional(),
|
||||||
|
}).superRefine((payload, ctx) => {
|
||||||
|
if (payload.model || payload.modelId || payload.modelRef) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["model"],
|
||||||
|
message: "第三方生图 API 不允许传入模型参数,系统会按后台配置自动选模",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeString(value: FormDataEntryValue | string | null | undefined) {
|
function normalizeString(value: FormDataEntryValue | string | null | undefined) {
|
||||||
@ -153,6 +67,10 @@ function parseOptionalJsonRecord(value: FormDataEntryValue | null) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDisallowedModelOverride(formValue: string) {
|
||||||
|
return formValue.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReferenceImageInputs(value: unknown): string[] {
|
function normalizeReferenceImageInputs(value: unknown): string[] {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@ -173,6 +91,23 @@ async function parseRequestPayload(request: Request) {
|
|||||||
const contentType = request.headers.get("content-type") || "";
|
const contentType = request.headers.get("content-type") || "";
|
||||||
if (contentType.toLowerCase().includes("multipart/form-data")) {
|
if (contentType.toLowerCase().includes("multipart/form-data")) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
const forcedModel = normalizeString(formData.get("model"));
|
||||||
|
const forcedModelId = normalizeString(formData.get("modelId"));
|
||||||
|
const forcedModelRef = normalizeString(formData.get("modelRef"));
|
||||||
|
if (
|
||||||
|
hasDisallowedModelOverride(forcedModel) ||
|
||||||
|
hasDisallowedModelOverride(forcedModelId) ||
|
||||||
|
hasDisallowedModelOverride(forcedModelRef)
|
||||||
|
) {
|
||||||
|
throw new z.ZodError([
|
||||||
|
{
|
||||||
|
code: "custom",
|
||||||
|
path: ["model"],
|
||||||
|
message: "第三方生图 API 不允许传入模型参数,系统会按后台配置自动选模",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const fileEntries = [
|
const fileEntries = [
|
||||||
...formData.getAll("images"),
|
...formData.getAll("images"),
|
||||||
...formData.getAll("image"),
|
...formData.getAll("image"),
|
||||||
@ -205,6 +140,9 @@ async function parseRequestPayload(request: Request) {
|
|||||||
imageCount: parseOptionalInteger(formData.get("imageCount")),
|
imageCount: parseOptionalInteger(formData.get("imageCount")),
|
||||||
aspectRatio: normalizeString(formData.get("aspectRatio")) || undefined,
|
aspectRatio: normalizeString(formData.get("aspectRatio")) || undefined,
|
||||||
imageSize: normalizeString(formData.get("imageSize")) || undefined,
|
imageSize: normalizeString(formData.get("imageSize")) || undefined,
|
||||||
|
model: forcedModel,
|
||||||
|
modelId: forcedModelId,
|
||||||
|
modelRef: forcedModelRef,
|
||||||
metadata: parseOptionalJsonRecord(formData.get("metadata")),
|
metadata: parseOptionalJsonRecord(formData.get("metadata")),
|
||||||
referenceImages: [...uploadedReferenceImages, ...hostedInlineImages],
|
referenceImages: [...uploadedReferenceImages, ...hostedInlineImages],
|
||||||
});
|
});
|
||||||
@ -223,6 +161,9 @@ async function parseRequestPayload(request: Request) {
|
|||||||
const json = await request.json();
|
const json = await request.json();
|
||||||
const parsed = requestSchema.parse({
|
const parsed = requestSchema.parse({
|
||||||
...json,
|
...json,
|
||||||
|
model: normalizeString((json as any)?.model),
|
||||||
|
modelId: normalizeString((json as any)?.modelId),
|
||||||
|
modelRef: normalizeString((json as any)?.modelRef),
|
||||||
referenceImages: await ensureHostedReferenceImageUrls([
|
referenceImages: await ensureHostedReferenceImageUrls([
|
||||||
...normalizeReferenceImageInputs((json as any)?.referenceImages),
|
...normalizeReferenceImageInputs((json as any)?.referenceImages),
|
||||||
...normalizeReferenceImageInputs((json as any)?.imageUrls),
|
...normalizeReferenceImageInputs((json as any)?.imageUrls),
|
||||||
@ -256,13 +197,14 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let logId: string | null = null;
|
let logId: string | null = null;
|
||||||
let releaseExternalImageApiSlot: (() => void) | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
|
||||||
const payload = await parseRequestPayload(request);
|
const payload = await parseRequestPayload(request);
|
||||||
|
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
||||||
const candidateModels = await buildExternalImageApiModelCandidates({
|
const candidateModels = await buildExternalImageApiModelCandidates({
|
||||||
referenceImageCount: payload.referenceImages?.length || 0,
|
referenceImageCount: payload.referenceImages?.length || 0,
|
||||||
|
aspectRatio: payload.aspectRatio,
|
||||||
|
imageSize,
|
||||||
});
|
});
|
||||||
const selectedModel = candidateModels[0];
|
const selectedModel = candidateModels[0];
|
||||||
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
||||||
@ -284,12 +226,13 @@ export async function POST(request: Request) {
|
|||||||
negativePrompt: payload.negativePrompt || null,
|
negativePrompt: payload.negativePrompt || null,
|
||||||
imageCount: payload.imageCount || 1,
|
imageCount: payload.imageCount || 1,
|
||||||
aspectRatio: payload.aspectRatio || null,
|
aspectRatio: payload.aspectRatio || null,
|
||||||
imageSize: payload.imageSize || null,
|
imageSize,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
requesterIp,
|
requesterIp,
|
||||||
requesterUserAgent,
|
requesterUserAgent,
|
||||||
requestPayload: {
|
requestPayload: {
|
||||||
...payload,
|
...payload,
|
||||||
|
imageSize,
|
||||||
candidateModels: candidateModels.map(serializeExternalImageApiModel),
|
candidateModels: candidateModels.map(serializeExternalImageApiModel),
|
||||||
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
||||||
authResult.accessKey,
|
authResult.accessKey,
|
||||||
@ -302,24 +245,19 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const generationResult = await generateImagesWithFailover({
|
const generationResult = await generateImagesWithFailover({
|
||||||
candidates: candidateModels,
|
candidates: candidateModels,
|
||||||
|
ownerKey: `external-image-api:${authResult.accessKey.id}`,
|
||||||
prompt: payload.prompt,
|
prompt: payload.prompt,
|
||||||
negativePrompt: payload.negativePrompt || null,
|
negativePrompt: payload.negativePrompt || null,
|
||||||
imageCount: payload.imageCount || 1,
|
imageCount: payload.imageCount || 1,
|
||||||
aspectRatio: payload.aspectRatio || null,
|
aspectRatio: payload.aspectRatio || null,
|
||||||
imageSize: payload.imageSize || null,
|
imageSize,
|
||||||
referenceImageUrls: payload.referenceImages || [],
|
referenceImageUrls: payload.referenceImages || [],
|
||||||
});
|
});
|
||||||
const actualModel = generationResult.usedModel;
|
const actualModel = generationResult.usedModel;
|
||||||
const rawMarkdown = generationResult.markdown;
|
const hostedMarkdown = generationResult.markdown;
|
||||||
|
const images = generationResult.images;
|
||||||
const hostedMarkdown = await ensureHostedImageMarkdown(
|
const originalImageUrls = images.map((image) => image.originalUrl || image.url);
|
||||||
rawMarkdown,
|
const thumbnailImageUrls = images.map((image) => image.thumbnailUrl);
|
||||||
payload.imageSize || "2K",
|
|
||||||
);
|
|
||||||
const images = extractGeneratedImagesFromMarkdown(hostedMarkdown);
|
|
||||||
if (!images.length) {
|
|
||||||
throw new Error("模型返回结果里未解析到图片");
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistResult = await saveGeneratedImages(
|
const persistResult = await saveGeneratedImages(
|
||||||
hostedMarkdown,
|
hostedMarkdown,
|
||||||
@ -334,7 +272,7 @@ export async function POST(request: Request) {
|
|||||||
optimizedPrompt: payload.prompt,
|
optimizedPrompt: payload.prompt,
|
||||||
requestedImageCount: payload.imageCount || images.length,
|
requestedImageCount: payload.imageCount || images.length,
|
||||||
aspectRatio: payload.aspectRatio,
|
aspectRatio: payload.aspectRatio,
|
||||||
imageSize: payload.imageSize,
|
imageSize,
|
||||||
sourceImageUrls: payload.referenceImages || [],
|
sourceImageUrls: payload.referenceImages || [],
|
||||||
relationType: "edited_from",
|
relationType: "edited_from",
|
||||||
skipBilling: true,
|
skipBilling: true,
|
||||||
@ -353,6 +291,8 @@ export async function POST(request: Request) {
|
|||||||
model: serializeExternalImageApiModel(actualModel),
|
model: serializeExternalImageApiModel(actualModel),
|
||||||
markdown: hostedMarkdown,
|
markdown: hostedMarkdown,
|
||||||
images,
|
images,
|
||||||
|
imageUrls: originalImageUrls,
|
||||||
|
thumbnailUrls: thumbnailImageUrls,
|
||||||
attempts: generationResult.attempts,
|
attempts: generationResult.attempts,
|
||||||
},
|
},
|
||||||
generatedImageIds: persistResult.savedImageIds,
|
generatedImageIds: persistResult.savedImageIds,
|
||||||
@ -369,6 +309,14 @@ export async function POST(request: Request) {
|
|||||||
data: {
|
data: {
|
||||||
requestId: logId,
|
requestId: logId,
|
||||||
model: serializeExternalImageApiModel(actualModel),
|
model: serializeExternalImageApiModel(actualModel),
|
||||||
|
imageUrl: originalImageUrls[0] || null,
|
||||||
|
image_url: originalImageUrls[0] || null,
|
||||||
|
imageUrls: originalImageUrls,
|
||||||
|
image_urls: originalImageUrls,
|
||||||
|
originalUrls: originalImageUrls,
|
||||||
|
original_urls: originalImageUrls,
|
||||||
|
thumbnailUrls: thumbnailImageUrls,
|
||||||
|
thumbnail_urls: thumbnailImageUrls,
|
||||||
images,
|
images,
|
||||||
generatedImageIds: persistResult.savedImageIds,
|
generatedImageIds: persistResult.savedImageIds,
|
||||||
generatedAssetIds: persistResult.savedAssetIds,
|
generatedAssetIds: persistResult.savedAssetIds,
|
||||||
@ -377,6 +325,17 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[External Image API] Generate failed:", error);
|
console.error("[External Image API] Generate failed:", error);
|
||||||
|
const shouldHideModelInfo = error instanceof ExternalImageApiGenerationError;
|
||||||
|
const internalError = shouldHideModelInfo
|
||||||
|
? error.internalMessage
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "图片生成失败";
|
||||||
|
const publicError = shouldHideModelInfo
|
||||||
|
? error.userMessage
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "图片生成失败";
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -393,9 +352,16 @@ export async function POST(request: Request) {
|
|||||||
.update(externalImageApiRequestLog)
|
.update(externalImageApiRequestLog)
|
||||||
.set({
|
.set({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
errorMessage: error instanceof Error ? error.message : "图片生成失败",
|
errorMessage: internalError,
|
||||||
responsePayload: {
|
responsePayload: {
|
||||||
error: error instanceof Error ? error.message : "图片生成失败",
|
error: shouldHideModelInfo
|
||||||
|
? {
|
||||||
|
message: error.userMessage,
|
||||||
|
attempts: error.attempts,
|
||||||
|
internalError,
|
||||||
|
}
|
||||||
|
: internalError,
|
||||||
|
attempts: shouldHideModelInfo ? error.attempts : undefined,
|
||||||
},
|
},
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
@ -403,31 +369,12 @@ export async function POST(request: Request) {
|
|||||||
.where(eq(externalImageApiRequestLog.id, logId));
|
.where(eq(externalImageApiRequestLog.id, logId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ExternalImageApiBusyError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
"Retry-After": String(error.retryAfterSeconds),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "图片生成失败",
|
error: publicError,
|
||||||
},
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
if (releaseExternalImageApiSlot) {
|
|
||||||
releaseExternalImageApiSlot();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,8 +56,24 @@ function resolveFaviconUrl(rawUrl: string, requestUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmptyValues(values: string[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
result.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function readLocalFallbackFavicon() {
|
async function readLocalFallbackFavicon() {
|
||||||
const file = await readFile(join(process.cwd(), "public", "default-favicon.ico"));
|
const file = await readFile(
|
||||||
|
join(process.cwd(), "public", "default-favicon.ico")
|
||||||
|
);
|
||||||
return new NextResponse(file, {
|
return new NextResponse(file, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: noStoreHeaders(DEFAULT_CONTENT_TYPE),
|
headers: noStoreHeaders(DEFAULT_CONTENT_TYPE),
|
||||||
@ -66,55 +82,78 @@ async function readLocalFallbackFavicon() {
|
|||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
if (process.env.CANVAS_E2E_LOCAL === "true") {
|
||||||
|
return await readLocalFallbackFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferDark = requestUrl.searchParams.get("theme") === "dark";
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
const rawFaviconUrl = String(settings?.favicon || "").trim();
|
const configuredFavicon = String(settings?.favicon || "").trim();
|
||||||
|
const configuredLightLogo = String(settings?.siteLogo || "").trim();
|
||||||
|
const configuredDarkLogo = String(settings?.siteLogoDark || "").trim();
|
||||||
|
const rawFaviconCandidates = configuredFavicon
|
||||||
|
? [configuredFavicon]
|
||||||
|
: preferDark
|
||||||
|
? uniqueNonEmptyValues([configuredDarkLogo, configuredLightLogo])
|
||||||
|
: uniqueNonEmptyValues([configuredLightLogo, configuredDarkLogo]);
|
||||||
|
|
||||||
if (!rawFaviconUrl) {
|
if (rawFaviconCandidates.length === 0) {
|
||||||
return await readLocalFallbackFavicon();
|
return await readLocalFallbackFavicon();
|
||||||
}
|
}
|
||||||
|
|
||||||
const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url);
|
for (const rawFaviconUrl of rawFaviconCandidates) {
|
||||||
if (!faviconUrl) {
|
const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url);
|
||||||
console.error("[favicon] Invalid favicon URL:", rawFaviconUrl);
|
if (!faviconUrl) {
|
||||||
return await readLocalFallbackFavicon();
|
console.error("[favicon] Invalid favicon URL:", rawFaviconUrl);
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`;
|
const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
let upstream: Response;
|
let upstream: Response;
|
||||||
try {
|
try {
|
||||||
upstream = await fetch(targetUrl, {
|
upstream = await fetch(targetUrl, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "image/*,*/*;q=0.8",
|
Accept: "image/*,*/*;q=0.8",
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; BuyiAIFavicon/1.0)",
|
"User-Agent": "Mozilla/5.0 (compatible; BuyiAIFavicon/1.0)",
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[favicon] Candidate request failed:", {
|
||||||
|
targetUrl,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
console.error("[favicon] Upstream request failed:", {
|
||||||
|
targetUrl,
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await upstream.arrayBuffer();
|
||||||
|
const contentType =
|
||||||
|
upstream.headers.get("content-type") || inferContentType(faviconUrl);
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: noStoreHeaders(contentType),
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!upstream.ok) {
|
return await readLocalFallbackFavicon();
|
||||||
console.error("[favicon] Upstream request failed:", {
|
|
||||||
targetUrl,
|
|
||||||
status: upstream.status,
|
|
||||||
statusText: upstream.statusText,
|
|
||||||
});
|
|
||||||
return await readLocalFallbackFavicon();
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await upstream.arrayBuffer();
|
|
||||||
const contentType =
|
|
||||||
upstream.headers.get("content-type") || inferContentType(faviconUrl);
|
|
||||||
|
|
||||||
return new NextResponse(buffer, {
|
|
||||||
status: 200,
|
|
||||||
headers: noStoreHeaders(contentType),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[favicon] Failed to proxy favicon:", error);
|
console.error("[favicon] Failed to proxy favicon:", error);
|
||||||
return await readLocalFallbackFavicon();
|
return await readLocalFallbackFavicon();
|
||||||
|
|||||||
147
app/api/internal/access-log/route.ts
Normal file
147
app/api/internal/access-log/route.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
type AccessLogSeverity,
|
||||||
|
recordAccessIpEvent,
|
||||||
|
} from "@/lib/security/access-log";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
eventType: z.string().trim().max(32).optional(),
|
||||||
|
ip: z.string().trim().max(128).optional().nullable(),
|
||||||
|
userId: z.string().trim().uuid().optional().nullable(),
|
||||||
|
userEmail: z.string().trim().max(128).optional().nullable(),
|
||||||
|
userRole: z.string().trim().max(32).optional().nullable(),
|
||||||
|
method: z.string().trim().max(12).optional().nullable(),
|
||||||
|
path: z.string().trim().max(256).optional().nullable(),
|
||||||
|
routeGroup: z.string().trim().max(64).optional().nullable(),
|
||||||
|
statusCode: z.coerce.number().int().min(100).max(599).optional().nullable(),
|
||||||
|
outcome: z.string().trim().max(32).optional().nullable(),
|
||||||
|
severity: z.enum(["info", "watch", "high", "critical"]).optional(),
|
||||||
|
reasons: z.array(z.string().trim().max(80)).max(12).optional(),
|
||||||
|
userAgent: z.string().trim().max(512).optional().nullable(),
|
||||||
|
referer: z.string().trim().max(512).optional().nullable(),
|
||||||
|
metadata: z.record(z.unknown()).optional().nullable(),
|
||||||
|
occurredAt: z.union([z.string(), z.number(), z.date()]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const suspiciousPathPatterns = [
|
||||||
|
/\.env/i,
|
||||||
|
/\/\.git/i,
|
||||||
|
/\/wp-admin/i,
|
||||||
|
/\/phpmyadmin/i,
|
||||||
|
/\/actuator/i,
|
||||||
|
/\/vendor\/phpunit/i,
|
||||||
|
/\/eval-stdin\.php/i,
|
||||||
|
/\/containers\/json/i,
|
||||||
|
/\/index\.php/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const suspiciousUserAgentPatterns = [
|
||||||
|
/libredtail|masscan|zgrab|sqlmap|nikto|acunetix|nessus/i,
|
||||||
|
/wget|curl|python-requests|go-http-client/i,
|
||||||
|
/nuclei|crawlergo|httpx/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function pushReason(reasons: string[], reason: string) {
|
||||||
|
if (!reasons.includes(reason)) reasons.push(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankSeverity(value: AccessLogSeverity) {
|
||||||
|
return { info: 0, watch: 1, high: 2, critical: 3 }[value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxSeverity(
|
||||||
|
current: AccessLogSeverity,
|
||||||
|
next: AccessLogSeverity
|
||||||
|
): AccessLogSeverity {
|
||||||
|
return rankSeverity(next) > rankSeverity(current) ? next : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPayload(
|
||||||
|
payload: z.infer<typeof payloadSchema>
|
||||||
|
): { severity: AccessLogSeverity; reasons: string[] } {
|
||||||
|
let severity: AccessLogSeverity = payload.severity || "info";
|
||||||
|
const reasons = [...(payload.reasons || [])];
|
||||||
|
const path = payload.path || "";
|
||||||
|
const userAgent = payload.userAgent || "";
|
||||||
|
const statusCode = payload.statusCode || 0;
|
||||||
|
const outcome = payload.outcome || "allowed";
|
||||||
|
|
||||||
|
if (outcome === "rate_limited") {
|
||||||
|
severity = maxSeverity(severity, "high");
|
||||||
|
pushReason(reasons, "触发访问频率限制");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome === "unauthorized" || statusCode === 401 || statusCode === 403) {
|
||||||
|
severity = maxSeverity(severity, "watch");
|
||||||
|
pushReason(reasons, "出现未授权访问");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode === 429) {
|
||||||
|
severity = maxSeverity(severity, "high");
|
||||||
|
pushReason(reasons, "返回 429 限流");
|
||||||
|
} else if (statusCode >= 500) {
|
||||||
|
severity = maxSeverity(severity, "high");
|
||||||
|
pushReason(reasons, "触发服务端错误");
|
||||||
|
} else if (statusCode >= 400) {
|
||||||
|
severity = maxSeverity(severity, "watch");
|
||||||
|
pushReason(reasons, "返回 4xx 异常状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/api/auth") && statusCode >= 400) {
|
||||||
|
severity = maxSeverity(severity, "high");
|
||||||
|
pushReason(reasons, "认证接口失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suspiciousPathPatterns.some((pattern) => pattern.test(path))) {
|
||||||
|
severity = maxSeverity(severity, "high");
|
||||||
|
pushReason(reasons, "命中敏感探测路径");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suspiciousUserAgentPatterns.some((pattern) => pattern.test(userAgent))) {
|
||||||
|
severity = maxSeverity(severity, "watch");
|
||||||
|
pushReason(reasons, "User-Agent 异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasons.includes("静态黑名单命中但未封禁")) {
|
||||||
|
severity = maxSeverity(severity, "high");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
severity,
|
||||||
|
reasons: Array.from(new Set(reasons)).slice(0, 8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpectedSecret() {
|
||||||
|
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const expectedSecret = getExpectedSecret();
|
||||||
|
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
|
||||||
|
|
||||||
|
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json().catch(() => null);
|
||||||
|
const parsed = payloadSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const classification = classifyPayload(parsed.data);
|
||||||
|
await recordAccessIpEvent({
|
||||||
|
...parsed.data,
|
||||||
|
severity: classification.severity,
|
||||||
|
reasons: classification.reasons,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[AccessIpEvent] failed to record access event", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
140
app/api/internal/ip-blocklist/check/route.ts
Normal file
140
app/api/internal/ip-blocklist/check/route.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { accessIpBlocklist } from "@/lib/db/schema";
|
||||||
|
import { matchManualIpBlock } from "@/lib/security/manual-ip-blocklist";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 5;
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
ip: z.string().trim().max(128).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BLOCKLIST_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
let blocklistUnavailableUntil = 0;
|
||||||
|
let blocklistMissingTableWarned = false;
|
||||||
|
|
||||||
|
function getExpectedSecret() {
|
||||||
|
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorCode(error: unknown) {
|
||||||
|
const directCode =
|
||||||
|
error && typeof error === "object"
|
||||||
|
? (error as { code?: unknown }).code
|
||||||
|
: null;
|
||||||
|
if (typeof directCode === "string") return directCode;
|
||||||
|
|
||||||
|
const cause =
|
||||||
|
error && typeof error === "object"
|
||||||
|
? (error as { cause?: unknown }).cause
|
||||||
|
: null;
|
||||||
|
const causeCode =
|
||||||
|
cause && typeof cause === "object"
|
||||||
|
? (cause as { code?: unknown }).code
|
||||||
|
: null;
|
||||||
|
return typeof causeCode === "string" ? causeCode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
const messages: string[] = [];
|
||||||
|
if (error instanceof Error) messages.push(error.message);
|
||||||
|
|
||||||
|
const cause =
|
||||||
|
error && typeof error === "object"
|
||||||
|
? (error as { cause?: unknown }).cause
|
||||||
|
: null;
|
||||||
|
if (cause instanceof Error) messages.push(cause.message);
|
||||||
|
|
||||||
|
if (!messages.length) messages.push(String(error));
|
||||||
|
return messages.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingAccessIpBlocklistTableError(error: unknown) {
|
||||||
|
if (getErrorCode(error) === "42P01") return true;
|
||||||
|
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
return (
|
||||||
|
message.includes('relation "AccessIpBlocklist" does not exist') ||
|
||||||
|
(message.includes("AccessIpBlocklist") && message.includes("does not exist"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markBlocklistUnavailable(error: unknown) {
|
||||||
|
blocklistUnavailableUntil = Date.now() + BLOCKLIST_UNAVAILABLE_CACHE_MS;
|
||||||
|
if (blocklistMissingTableWarned) return;
|
||||||
|
|
||||||
|
blocklistMissingTableWarned = true;
|
||||||
|
console.warn(
|
||||||
|
"[AccessIpBlocklist] table is unavailable; manual IP block checks will be skipped temporarily",
|
||||||
|
getErrorMessage(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const expectedSecret = getExpectedSecret();
|
||||||
|
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
|
||||||
|
|
||||||
|
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = payloadSchema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success || !parsed.data.ip) {
|
||||||
|
return NextResponse.json({ blocked: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.MANUAL_IP_BLOCKLIST_ENABLED === "false" ||
|
||||||
|
Date.now() < blocklistUnavailableUntil
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ blocked: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const entries = await db
|
||||||
|
.select({
|
||||||
|
id: accessIpBlocklist.id,
|
||||||
|
ip: accessIpBlocklist.ip,
|
||||||
|
reason: accessIpBlocklist.reason,
|
||||||
|
expiresAt: accessIpBlocklist.expiresAt,
|
||||||
|
})
|
||||||
|
.from(accessIpBlocklist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(accessIpBlocklist.isActive, true),
|
||||||
|
or(
|
||||||
|
isNull(accessIpBlocklist.expiresAt),
|
||||||
|
gt(accessIpBlocklist.expiresAt, now)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1000);
|
||||||
|
|
||||||
|
const activeEntries = entries.filter((entry) => entry.ip && entry.id);
|
||||||
|
const matched = matchManualIpBlock(parsed.data.ip, activeEntries);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
blocked: Boolean(matched),
|
||||||
|
entry: matched
|
||||||
|
? {
|
||||||
|
id: matched.id,
|
||||||
|
ip: matched.ip,
|
||||||
|
reason: matched.reason || null,
|
||||||
|
expiresAt: matched.expiresAt || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingAccessIpBlocklistTableError(error)) {
|
||||||
|
markBlocklistUnavailable(error);
|
||||||
|
return NextResponse.json({ blocked: false });
|
||||||
|
}
|
||||||
|
console.warn("[AccessIpBlocklist] internal check failed", error);
|
||||||
|
return NextResponse.json({ blocked: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/api/internal/pascal-agent/v1/chat/completions/route.ts
Normal file
138
app/api/internal/pascal-agent/v1/chat/completions/route.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getProviderDefaultBaseUrl } from "@/lib/ai/provider-defaults";
|
||||||
|
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
||||||
|
import { getMultimodalAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 600;
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const logger = createLogger("pascal-model-gateway");
|
||||||
|
const OPENAI_COMPATIBLE_PROVIDER_TYPES = new Set([
|
||||||
|
"alibaba",
|
||||||
|
"custom",
|
||||||
|
"openai",
|
||||||
|
"zhipu",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function upstreamChatUrl(baseUrl: string) {
|
||||||
|
const normalized = baseUrl.replace(/\/+$/, "");
|
||||||
|
return normalized.endsWith("/chat/completions")
|
||||||
|
? normalized
|
||||||
|
: `${normalized}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAiError(message: string, status: number) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { message, type: "pascal_gateway_error" } },
|
||||||
|
{ status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConfiguredMultimodalModel() {
|
||||||
|
const candidates = await getMultimodalAiModelsWithProvider();
|
||||||
|
return candidates.find(({ provider }) =>
|
||||||
|
OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!isPascalInternalRequest(request)) {
|
||||||
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = await getConfiguredMultimodalModel();
|
||||||
|
if (!configured) {
|
||||||
|
return NextResponse.json({ configured: false, selection: "multimodal" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
configured: true,
|
||||||
|
compatible: OPENAI_COMPATIBLE_PROVIDER_TYPES.has(configured.provider.type),
|
||||||
|
model: configured.model.displayName || configured.model.modelId,
|
||||||
|
provider: configured.provider.name,
|
||||||
|
providerType: configured.provider.type,
|
||||||
|
selection: "multimodal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isPascalInternalRequest(request)) {
|
||||||
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const parsed = await request.json();
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return openAiError("Invalid request body.", 400);
|
||||||
|
}
|
||||||
|
body = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return openAiError("Invalid JSON body.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = await getConfiguredMultimodalModel();
|
||||||
|
if (!configured) {
|
||||||
|
return openAiError("No enabled multimodal model is configured.", 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { model, provider } = configured;
|
||||||
|
if (!OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)) {
|
||||||
|
return openAiError(
|
||||||
|
`The configured multimodal provider type '${provider.type}' is not OpenAI-compatible.`,
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
provider.baseUrl?.trim() || getProviderDefaultBaseUrl(provider.type) || "";
|
||||||
|
const runtimeModelId = resolveModelRuntimeId(model, {
|
||||||
|
providerType: provider.type,
|
||||||
|
});
|
||||||
|
if (!baseUrl || !runtimeModelId || !provider.apiKey) {
|
||||||
|
return openAiError("The configured multimodal model is incomplete.", 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(upstreamChatUrl(baseUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${provider.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...body, model: runtimeModelId }),
|
||||||
|
cache: "no-store",
|
||||||
|
signal: request.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("upstream completed", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
modelId: model.id,
|
||||||
|
providerId: provider.id,
|
||||||
|
status: upstream.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"Content-Type":
|
||||||
|
upstream.headers.get("content-type") || "application/json; charset=utf-8",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
});
|
||||||
|
return new Response(upstream.body, { status: upstream.status, headers });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("upstream failed", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
modelId: model.id,
|
||||||
|
providerId: provider.id,
|
||||||
|
});
|
||||||
|
return openAiError(
|
||||||
|
"The configured multimodal model is temporarily unavailable.",
|
||||||
|
502
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/api/internal/pascal/access/route.ts
Normal file
26
app/api/internal/pascal/access/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!isPascalInternalRequest(request)) {
|
||||||
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id?.trim();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Pascal-User-Id": userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
65
app/api/jobs/archive-images/route.ts
Normal file
65
app/api/jobs/archive-images/route.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { runImageArchiveJob } from "@/lib/jobs/image-archive-job";
|
||||||
|
import { getImageArchiveTaskStats } from "@/lib/storage/image-archive-db";
|
||||||
|
|
||||||
|
function verifyAuthToken(request: Request): boolean {
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
const token = authHeader?.replace("Bearer ", "");
|
||||||
|
const cronSecret = process.env.CRON_SECRET;
|
||||||
|
|
||||||
|
if (!cronSecret) {
|
||||||
|
console.error("[ImageArchiveAPI] CRON_SECRET is not configured");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token === cronSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
if (!verifyAuthToken(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runImageArchiveJob();
|
||||||
|
return NextResponse.json({
|
||||||
|
...result,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ImageArchiveAPI] Job execution failed:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
if (!verifyAuthToken(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await getImageArchiveTaskStats();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
stats,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ImageArchiveAPI] Stats query failed:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/api/mcp/canvas/[id]/route.ts
Normal file
38
app/api/mcp/canvas/[id]/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { createCanvasCapabilityMcpServer } from "@/lib/canvas/capabilities/mcp-server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 600;
|
||||||
|
|
||||||
|
async function handleMcpRequest(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "未登录,无法访问画布 MCP 服务" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: projectId } = await params;
|
||||||
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||||
|
enableJsonResponse: true,
|
||||||
|
});
|
||||||
|
const server = await createCanvasCapabilityMcpServer({
|
||||||
|
projectId,
|
||||||
|
requestHeaders: request.headers,
|
||||||
|
requestUrl: request.url,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
await server.connect(transport);
|
||||||
|
return transport.handleRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = handleMcpRequest;
|
||||||
|
export const GET = handleMcpRequest;
|
||||||
|
export const DELETE = handleMcpRequest;
|
||||||
@ -18,6 +18,12 @@ import {
|
|||||||
getFalImageTaskStatus,
|
getFalImageTaskStatus,
|
||||||
resolveFalImageTaskToMarkdown,
|
resolveFalImageTaskToMarkdown,
|
||||||
} from "@/lib/ai/providers/fal-image";
|
} from "@/lib/ai/providers/fal-image";
|
||||||
|
import {
|
||||||
|
isAgentModeScopedChatId,
|
||||||
|
isScopedEmptyHistoryRequest,
|
||||||
|
isValidMessagesChatId,
|
||||||
|
normalizeMessagesChatId,
|
||||||
|
} from "@/lib/chat/messages-route-access";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@ -25,18 +31,14 @@ export const revalidate = 0;
|
|||||||
const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
||||||
const falTaskLastPolledAt = new Map<string, number>();
|
const falTaskLastPolledAt = new Map<string, number>();
|
||||||
const falTaskSyncInFlight = new Set<string>();
|
const falTaskSyncInFlight = new Set<string>();
|
||||||
const AGENT_MODE_SCOPED_CHAT_SUFFIX = "a93e17a60001";
|
|
||||||
|
|
||||||
function isAgentModeScopedChatId(chatId: string) {
|
function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) {
|
||||||
const normalized = String(chatId || "")
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||||
.trim()
|
const metadata = value as Record<string, unknown>;
|
||||||
.toLowerCase()
|
if (metadata.taskKind !== "clawd_chat") return value;
|
||||||
.replace(/-/g, "");
|
const publicMetadata = { ...metadata };
|
||||||
|
delete publicMetadata.upstreamTaskKey;
|
||||||
return (
|
return publicMetadata;
|
||||||
/^[0-9a-f]{32}$/.test(normalized) &&
|
|
||||||
normalized.endsWith(AGENT_MODE_SCOPED_CHAT_SUFFIX)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyMessagesResponse() {
|
function createEmptyMessagesResponse() {
|
||||||
@ -226,18 +228,27 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatId = request.nextUrl.searchParams.get("chatId");
|
const requestedChatId = request.nextUrl.searchParams.get("chatId");
|
||||||
|
|
||||||
if (!chatId) {
|
if (!requestedChatId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing required parameter: chatId" },
|
{ error: "Missing required parameter: chatId" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatId = normalizeMessagesChatId(requestedChatId);
|
||||||
|
if (!isValidMessagesChatId(chatId)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = request.nextUrl.searchParams.get("scope");
|
||||||
const selectedChat = await getChatById({ id: chatId });
|
const selectedChat = await getChatById({ id: chatId });
|
||||||
if (!selectedChat) {
|
if (!selectedChat) {
|
||||||
if (isAgentModeScopedChatId(chatId)) {
|
if (
|
||||||
|
isAgentModeScopedChatId(chatId) ||
|
||||||
|
isScopedEmptyHistoryRequest({ chatId, scope })
|
||||||
|
) {
|
||||||
return createEmptyMessagesResponse();
|
return createEmptyMessagesResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,7 +286,7 @@ export async function GET(request: NextRequest) {
|
|||||||
createdAt: msg.createdAt,
|
createdAt: msg.createdAt,
|
||||||
parts: parts,
|
parts: parts,
|
||||||
attachments: msg.attachments || [],
|
attachments: msg.attachments || [],
|
||||||
videoMetadata: msg.videoMetadata || null,
|
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
148
app/api/openapi/v1/access-keys/provision/route.ts
Normal file
148
app/api/openapi/v1/access-keys/provision/route.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
createUserAccessKey,
|
||||||
|
listUserAccessKeys,
|
||||||
|
} from "@/lib/access-keys/service";
|
||||||
|
import {
|
||||||
|
MIN_PROVISIONING_SECRET_LENGTH,
|
||||||
|
parseProvisioningBearerSecret,
|
||||||
|
verifyProvisioningSecret,
|
||||||
|
} from "@/lib/access-keys/provisioning-secret";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { user } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
const provisionAccessKeySchema = z.object({
|
||||||
|
dingTalkUsername: z.string().trim().min(1).max(100),
|
||||||
|
dingTalkUserId: z.string().trim().min(1).max(128).optional(),
|
||||||
|
keyName: z.string().trim().min(1).max(100).optional(),
|
||||||
|
scopes: z
|
||||||
|
.array(z.enum(["canvas:read", "canvas:write"]))
|
||||||
|
.min(1)
|
||||||
|
.optional(),
|
||||||
|
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonError(error: string, code: string, status: number) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error, code },
|
||||||
|
{ status, headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const expectedSecret = String(
|
||||||
|
process.env.MCP_KEY_PROVISIONING_SECRET || ""
|
||||||
|
).trim();
|
||||||
|
if (expectedSecret.length < MIN_PROVISIONING_SECRET_LENGTH) {
|
||||||
|
return jsonError(
|
||||||
|
"Access Key provisioning is not configured",
|
||||||
|
"PROVISIONING_NOT_CONFIGURED",
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedSecret = parseProvisioningBearerSecret(
|
||||||
|
request.headers.get("authorization")
|
||||||
|
);
|
||||||
|
if (!verifyProvisioningSecret(providedSecret, expectedSecret)) {
|
||||||
|
return jsonError(
|
||||||
|
"Missing or invalid provisioning credential",
|
||||||
|
"PROVISIONING_UNAUTHORIZED",
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = provisionAccessKeySchema.safeParse(
|
||||||
|
await request.json().catch(() => null)
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return jsonError(
|
||||||
|
"Invalid provisioning request",
|
||||||
|
"PROVISIONING_REQUEST_INVALID",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data;
|
||||||
|
const conditions = [
|
||||||
|
eq(user.name, body.dingTalkUsername),
|
||||||
|
eq(user.isActive, true),
|
||||||
|
isNotNull(user.dingtalkUserId),
|
||||||
|
];
|
||||||
|
if (body.dingTalkUserId) {
|
||||||
|
conditions.push(eq(user.dingtalkUserId, body.dingTalkUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
dingtalkUserId: user.dingtalkUserId,
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(2);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return jsonError(
|
||||||
|
"No active DingTalk user matched the supplied identity",
|
||||||
|
"DINGTALK_USER_NOT_FOUND",
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return jsonError(
|
||||||
|
"DingTalk username is not unique; provide dingTalkUserId",
|
||||||
|
"DINGTALK_USERNAME_AMBIGUOUS",
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedUser = matches[0];
|
||||||
|
const existingKeys = await listUserAccessKeys(matchedUser.id);
|
||||||
|
const activeKeyCount = existingKeys.filter(
|
||||||
|
(item) =>
|
||||||
|
!item.revokedAt &&
|
||||||
|
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
|
||||||
|
).length;
|
||||||
|
if (activeKeyCount >= 10) {
|
||||||
|
return jsonError(
|
||||||
|
"The user already has 10 active Access Keys",
|
||||||
|
"ACCESS_KEY_LIMIT_REACHED",
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessKey = await createUserAccessKey({
|
||||||
|
userId: matchedUser.id,
|
||||||
|
name: body.keyName || "第三方 OpenClaw",
|
||||||
|
scopes: body.scopes,
|
||||||
|
expiresInDays: body.expiresInDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: matchedUser,
|
||||||
|
accessKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return jsonError(
|
||||||
|
"Failed to provision Access Key",
|
||||||
|
"ACCESS_KEY_PROVISION_FAILED",
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/openapi/v1/canvases/[id]/operations/route.ts
Normal file
48
app/api/openapi/v1/canvases/[id]/operations/route.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
|
||||||
|
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||||
|
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
const canvasIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:write");
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Invalid canvas ID",
|
||||||
|
code: "INVALID_CANVAS_ID",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsedId.data;
|
||||||
|
const response = await handleCanvasAgentOperationRouteRequest({
|
||||||
|
userId: principal.userId,
|
||||||
|
projectId: id,
|
||||||
|
readBody: () => request.json(),
|
||||||
|
features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
|
||||||
|
applyOperation: applyCanvasOperationPlan,
|
||||||
|
logger: console,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/api/openapi/v1/canvases/[id]/route.ts
Normal file
57
app/api/openapi/v1/canvases/[id]/route.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
const canvasIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Invalid canvas ID",
|
||||||
|
code: "INVALID_CANVAS_ID",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsedId.data;
|
||||||
|
const [project] = await db
|
||||||
|
.select()
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.id, id),
|
||||||
|
eq(canvasProject.userId, principal.userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "Canvas not found", code: "CANVAS_NOT_FOUND" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ success: true, data: project },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/api/openapi/v1/canvases/route.ts
Normal file
52
app/api/openapi/v1/canvases/route.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
function normalizeWorkspaceType(value: string | null) {
|
||||||
|
return value === "agent" || value === "video" ? value : "canvas";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workspaceType = normalizeWorkspaceType(
|
||||||
|
url.searchParams.get("workspaceType")
|
||||||
|
);
|
||||||
|
const requestedLimit = Number(url.searchParams.get("limit") || 24);
|
||||||
|
const limit = Number.isFinite(requestedLimit)
|
||||||
|
? Math.min(50, Math.max(1, Math.round(requestedLimit)))
|
||||||
|
: 24;
|
||||||
|
|
||||||
|
const projects = await db
|
||||||
|
.select({
|
||||||
|
id: canvasProject.id,
|
||||||
|
title: canvasProject.title,
|
||||||
|
description: canvasProject.description,
|
||||||
|
thumbnail: canvasProject.thumbnail,
|
||||||
|
workspaceType: canvasProject.workspaceType,
|
||||||
|
createdAt: canvasProject.createdAt,
|
||||||
|
updatedAt: canvasProject.updatedAt,
|
||||||
|
})
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.userId, principal.userId),
|
||||||
|
eq(canvasProject.workspaceType, workspaceType)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(canvasProject.updatedAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ success: true, data: projects },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/openapi/v1/me/route.ts
Normal file
41
app/api/openapi/v1/me/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { user } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||||
|
const [currentUser] = await db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
department: user.department,
|
||||||
|
role: user.role,
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, principal.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: currentUser,
|
||||||
|
accessKey: {
|
||||||
|
id: principal.accessKeyId,
|
||||||
|
name: principal.accessKeyName,
|
||||||
|
scopes: principal.scopes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -21,10 +27,12 @@ interface PickObjectRequest {
|
|||||||
imageData: string; // 带红色标记的图片 base64
|
imageData: string; // 带红色标记的图片 base64
|
||||||
clickX: number; // 点击位置 X (0-1)
|
clickX: number; // 点击位置 X (0-1)
|
||||||
clickY: number; // 点击位置 Y (0-1)
|
clickY: number; // 点击位置 Y (0-1)
|
||||||
|
mode?: "object" | "video-canvas-mark";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickObjectResult {
|
interface PickObjectResult {
|
||||||
name: string;
|
name: string;
|
||||||
|
labels?: string[];
|
||||||
position: string;
|
position: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
boundingBox: {
|
boundingBox: {
|
||||||
@ -119,7 +127,51 @@ async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string)
|
|||||||
return uploaded.url;
|
return uploaded.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function normalizeImageInput(imageData: string): Promise<string> {
|
async function prepareMarkedVisionAnalysisImage(
|
||||||
|
buffer: Buffer,
|
||||||
|
clickX: number,
|
||||||
|
clickY: number
|
||||||
|
) {
|
||||||
|
const prepared = await downscaleImageForVision(buffer);
|
||||||
|
const metadata = await sharp(prepared.buffer, { failOn: "none" }).metadata();
|
||||||
|
const width = Math.max(1, metadata.width || prepared.width || MAX_VISION_SIDE);
|
||||||
|
const height = Math.max(1, metadata.height || prepared.height || MAX_VISION_SIDE);
|
||||||
|
const markerX = Math.max(0, Math.min(width, clickX * width));
|
||||||
|
const markerY = Math.max(0, Math.min(height, clickY * height));
|
||||||
|
const radius = Math.max(10, Math.min(width, height) * 0.025);
|
||||||
|
const overlay = Buffer.from(`
|
||||||
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="${markerX}" cy="${markerY}" r="${radius + 3}" fill="#fff" />
|
||||||
|
<circle cx="${markerX}" cy="${markerY}" r="${radius}" fill="#f00" />
|
||||||
|
<g stroke="#f00" stroke-width="2">
|
||||||
|
<line x1="${markerX - radius * 2}" y1="${markerY}" x2="${markerX - radius - 2}" y2="${markerY}" />
|
||||||
|
<line x1="${markerX + radius + 2}" y1="${markerY}" x2="${markerX + radius * 2}" y2="${markerY}" />
|
||||||
|
<line x1="${markerX}" y1="${markerY - radius * 2}" x2="${markerX}" y2="${markerY - radius - 2}" />
|
||||||
|
<line x1="${markerX}" y1="${markerY + radius + 2}" x2="${markerX}" y2="${markerY + radius * 2}" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`);
|
||||||
|
const composited = sharp(prepared.buffer, { failOn: "none" }).composite([
|
||||||
|
{ input: overlay, left: 0, top: 0 },
|
||||||
|
]);
|
||||||
|
const markedBuffer =
|
||||||
|
prepared.ext === "png"
|
||||||
|
? await composited.png({ compressionLevel: 9 }).toBuffer()
|
||||||
|
: await composited.jpeg({ quality: 84, mozjpeg: true }).toBuffer();
|
||||||
|
logger.info("marked vision image prepared", {
|
||||||
|
clickX,
|
||||||
|
clickY,
|
||||||
|
outputBytes: markedBuffer.length,
|
||||||
|
outputExt: prepared.ext,
|
||||||
|
});
|
||||||
|
const mimeType = prepared.ext === "png" ? "image/png" : "image/jpeg";
|
||||||
|
return `data:${mimeType};base64,${markedBuffer.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeImageInput(
|
||||||
|
imageData: string,
|
||||||
|
options?: { clickX: number; clickY: number; markPoint: boolean }
|
||||||
|
): Promise<string> {
|
||||||
const raw = String(imageData || "").trim();
|
const raw = String(imageData || "").trim();
|
||||||
if (!raw) throw new Error("缺少图片数据");
|
if (!raw) throw new Error("缺少图片数据");
|
||||||
|
|
||||||
@ -145,7 +197,10 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
ensureImageSizeAllowed(arrayBuffer.byteLength);
|
ensureImageSizeAllowed(arrayBuffer.byteLength);
|
||||||
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
return options?.markPoint
|
||||||
|
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
|
||||||
|
: uploadVisionAnalysisImage(buffer, "pick-object-url");
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
@ -160,7 +215,9 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
|||||||
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
|
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
|
||||||
const buffer = Buffer.from(base64Body, "base64");
|
const buffer = Buffer.from(base64Body, "base64");
|
||||||
ensureImageSizeAllowed(buffer.byteLength);
|
ensureImageSizeAllowed(buffer.byteLength);
|
||||||
return uploadVisionAnalysisImage(buffer, "pick-object-base64");
|
return options?.markPoint
|
||||||
|
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
|
||||||
|
: uploadVisionAnalysisImage(buffer, "pick-object-base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -174,7 +231,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body: PickObjectRequest = await request.json();
|
const body: PickObjectRequest = await request.json();
|
||||||
const { imageData, clickX, clickY } = body;
|
const { imageData, clickX, clickY, mode = "object" } = body;
|
||||||
|
|
||||||
logger.info("request received", {
|
logger.info("request received", {
|
||||||
imageDataLength: imageData?.length,
|
imageDataLength: imageData?.length,
|
||||||
@ -204,12 +261,34 @@ export async function POST(request: Request) {
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedImage = await normalizeImageInput(imageData);
|
const normalizedImage = await normalizeImageInput(imageData, {
|
||||||
|
clickX,
|
||||||
|
clickY,
|
||||||
|
markPoint: mode === "video-canvas-mark",
|
||||||
|
});
|
||||||
|
|
||||||
logger.info("vision model selected", { model: "qwen3-vl" });
|
logger.info("vision model selected", { model: "qwen3-vl" });
|
||||||
|
|
||||||
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
|
const videoCanvasConfig =
|
||||||
const prompt = `
|
mode === "video-canvas-mark"
|
||||||
|
? normalizeVideoCanvasFunctionConfig(
|
||||||
|
(
|
||||||
|
await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||||
|
() => null
|
||||||
|
)
|
||||||
|
)?.value || defaultVideoCanvasFunctionConfig
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const elementMarkDefinition = videoCanvasConfig?.functions["element-mark"];
|
||||||
|
if (mode === "video-canvas-mark" && elementMarkDefinition?.enabled === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "视频元素标记功能当前未启用" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 通过红色标记识别用户点中的主体;视频画布规则来自系统设置。
|
||||||
|
const defaultObjectPrompt = `
|
||||||
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
|
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
|
||||||
|
|
||||||
【重要】
|
【重要】
|
||||||
@ -232,12 +311,24 @@ export async function POST(request: Request) {
|
|||||||
【position 格式】固定4字,从以下选择:
|
【position 格式】固定4字,从以下选择:
|
||||||
${POSITION_OPTIONS.join('、')}
|
${POSITION_OPTIONS.join('、')}
|
||||||
`.trim();
|
`.trim();
|
||||||
|
const prompt =
|
||||||
|
mode === "video-canvas-mark"
|
||||||
|
? String(
|
||||||
|
elementMarkDefinition?.prompt ||
|
||||||
|
defaultVideoCanvasFunctionConfig.functions["element-mark"]?.prompt ||
|
||||||
|
defaultObjectPrompt
|
||||||
|
).replaceAll("{{positionOptions}}", POSITION_OPTIONS.join("、"))
|
||||||
|
: defaultObjectPrompt;
|
||||||
|
|
||||||
logger.info("vision model call started");
|
logger.info("vision model call started");
|
||||||
|
|
||||||
// 调用视觉模型,发送带红色标记的图片
|
// 调用视觉模型,发送带红色标记的图片
|
||||||
const { text: responseText, model } = await callQwen3Vl({
|
const { text: responseText, model } = await callQwen3Vl({
|
||||||
imageUrl: normalizedImage,
|
imageUrl: normalizedImage,
|
||||||
|
model:
|
||||||
|
mode === "video-canvas-mark"
|
||||||
|
? elementMarkDefinition?.model || videoCanvasConfig?.defaultMultimodalModel
|
||||||
|
: undefined,
|
||||||
prompt,
|
prompt,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
maxTokens: 300,
|
maxTokens: 300,
|
||||||
@ -266,7 +357,17 @@ ${POSITION_OPTIONS.join('、')}
|
|||||||
const parsed = JSON.parse(jsonMatch[0]);
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
|
||||||
// 验证和修正名称
|
// 验证和修正名称
|
||||||
let name = parsed.name || '物品';
|
const labels =
|
||||||
|
mode === "video-canvas-mark" && Array.isArray(parsed.labels)
|
||||||
|
? Array.from(
|
||||||
|
new Set(
|
||||||
|
parsed.labels
|
||||||
|
.map((item: unknown) => String(item || "").trim().slice(0, 8))
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
).slice(0, 4)
|
||||||
|
: [];
|
||||||
|
let name = labels[0] || parsed.name || '物品';
|
||||||
if (name.length > 8) {
|
if (name.length > 8) {
|
||||||
name = name.substring(0, 6);
|
name = name.substring(0, 6);
|
||||||
}
|
}
|
||||||
@ -292,6 +393,7 @@ ${POSITION_OPTIONS.join('、')}
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
name,
|
name,
|
||||||
|
labels: mode === "video-canvas-mark" ? (labels.length ? labels : [name]) : undefined,
|
||||||
position,
|
position,
|
||||||
confidence,
|
confidence,
|
||||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
|
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
|
||||||
@ -312,6 +414,7 @@ ${POSITION_OPTIONS.join('、')}
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
name: '物品',
|
name: '物品',
|
||||||
|
labels: mode === "video-canvas-mark" ? ['物品'] : undefined,
|
||||||
position,
|
position,
|
||||||
confidence: 0.5,
|
confidence: 0.5,
|
||||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },
|
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },
|
||||||
|
|||||||
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