Compare commits
362 Commits
codex/atla
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae4605d479 | |||
|
|
36efdfae53 | ||
| 10efb4c1e7 | |||
|
|
9da598967a | ||
| 91edc4019e | |||
|
|
7439792d40 | ||
| be642948ec | |||
|
|
9155682238 | ||
|
|
03b8a0deb0 | ||
|
|
439fd56fe1 | ||
|
|
99db635e0a | ||
|
|
14a7668274 | ||
|
|
01485340b5 | ||
|
|
8784bb783e | ||
|
|
82c71b8cd8 | ||
|
|
90c17a61fb | ||
|
|
404d783fa5 | ||
|
|
1705f0ba43 | ||
|
|
b689f8f770 | ||
|
|
7541262f28 | ||
|
|
75e6a28309 | ||
|
|
7925545980 | ||
|
|
59994062c9 | ||
|
|
fb10747828 | ||
|
|
d2d0a1725c | ||
|
|
f38488b546 | ||
|
|
36761e37a4 | ||
|
|
32e8c9cbd6 | ||
|
|
2a2a32c3bd | ||
|
|
d4a6b54078 | ||
|
|
d8aa51185e | ||
|
|
73f9844022 | ||
|
|
1803727d4b | ||
|
|
8848d00107 | ||
|
|
623859f846 | ||
|
|
f55d21968f | ||
|
|
ad25b4e17f | ||
|
|
5dbdc3df46 | ||
|
|
cfb071d0df | ||
|
|
fce14356d9 | ||
|
|
ac2e355a7a | ||
|
|
a8c6a5a026 | ||
|
|
1965ee9cc0 | ||
|
|
0d44ac12c8 | ||
|
|
c7f320074a | ||
|
|
62311d187f | ||
|
|
a4bf68734c | ||
|
|
145f699823 | ||
|
|
8ee12a5b13 | ||
|
|
0a3eef1f4d | ||
|
|
0fd14f18bb | ||
|
|
ac9103906e | ||
|
|
320d52d26f | ||
|
|
2bd2910fae | ||
|
|
54ce53f263 | ||
|
|
f0b6c2765f | ||
|
|
d3b8162faf | ||
|
|
ac54ade6b7 | ||
|
|
1aa61570db | ||
|
|
3504141be5 | ||
|
|
79d10cf1ac | ||
|
|
3a9e392a7f | ||
|
|
07101946ff | ||
|
|
8111ce7708 | ||
|
|
a03ac48f3a | ||
|
|
1dd4487cf0 | ||
|
|
6319a3ceef | ||
|
|
4734d5cb76 | ||
|
|
d33db04ecc | ||
|
|
ab7240b3d4 | ||
|
|
55ad5b21f0 | ||
|
|
562b9a3f1d | ||
|
|
30cfd62669 | ||
|
|
255e9082da | ||
|
|
53e565045a | ||
|
|
a2b65b426c | ||
|
|
d1789f5f93 | ||
|
|
726d66bd19 | ||
|
|
74aa67ce44 | ||
|
|
f83779e65e | ||
|
|
10ef40b734 | ||
|
|
850f664fb1 | ||
|
|
3750e89ed1 | ||
|
|
c0cbc34911 | ||
|
|
dfc1c6d496 | ||
|
|
425d3a4ddf | ||
|
|
1a806b4275 | ||
|
|
47e1009ee0 | ||
|
|
50569ca88a | ||
|
|
c3f5903a86 | ||
|
|
9611fafe0b | ||
|
|
9f98a68049 | ||
|
|
822c4cbfb9 | ||
|
|
a492d082ed | ||
|
|
4ada7eaf38 | ||
|
|
63e2f48192 | ||
|
|
d376f5959f | ||
|
|
2c8f306e74 | ||
|
|
c4a2753a6e | ||
|
|
0d752d6e38 | ||
|
|
96d67955cb | ||
|
|
c45a6a77a0 | ||
|
|
88cf96b998 | ||
|
|
570375b5f1 | ||
|
|
e7be5af3f2 | ||
|
|
365bad8539 | ||
|
|
08eecb0190 | ||
|
|
f75d99baf6 | ||
|
|
db3a038b00 | ||
|
|
0907aaff67 | ||
|
|
1c2acee547 | ||
|
|
439af60c2a | ||
|
|
e97296a9e8 | ||
|
|
94b49b7fad | ||
|
|
ea4f34bf89 | ||
|
|
1141ecc43a | ||
|
|
8b2955eb0b | ||
|
|
94b3ac9cb3 | ||
|
|
8a1876e016 | ||
|
|
b4b6e1a738 | ||
|
|
885cab9fca | ||
|
|
6ed8879b28 | ||
|
|
c1975942ac | ||
|
|
a4354dc535 | ||
|
|
3002704d77 | ||
|
|
fe1c9b60f3 | ||
|
|
4f9453b8b2 | ||
|
|
229f4d72cb | ||
|
|
12576c565f | ||
|
|
b17b8b7689 | ||
|
|
0d5558c50c | ||
|
|
c6533e92f3 | ||
|
|
17e17b9ad5 | ||
|
|
689fa615e8 | ||
|
|
725c1ec3d2 | ||
|
|
84bfd6b9f4 | ||
|
|
6869bd8c18 | ||
|
|
a5f4ad0385 | ||
|
|
6eff0a7332 | ||
|
|
a00a910227 | ||
|
|
721de36692 | ||
|
|
be8bdb4dab | ||
|
|
bbac6289b3 | ||
|
|
cb42f27ee9 | ||
|
|
292eddc5a6 | ||
|
|
69dcf9b745 | ||
|
|
86b66172b1 | ||
|
|
0d17638a8d | ||
|
|
23f86dfce1 | ||
|
|
f10e6b7c8d | ||
|
|
e5b2de3c1b | ||
|
|
d5593ac3f0 | ||
|
|
38be09de9c | ||
|
|
598f5f617a | ||
|
|
f7550251ec | ||
|
|
2ba093b714 | ||
|
|
5b0d75a738 | ||
|
|
027f0a4201 | ||
|
|
96b8e1662c | ||
|
|
290f6d2c99 | ||
|
|
7c276fe43a | ||
|
|
741fe3663e | ||
|
|
924c8065b6 | ||
|
|
50d782850f | ||
|
|
60b7900bf9 | ||
|
|
0c1a2e6fee | ||
|
|
73593051fe | ||
|
|
3df44f1f8c | ||
|
|
54c857d714 | ||
|
|
3bf0fec097 | ||
|
|
d31079e26d | ||
|
|
2bf5f6232d | ||
|
|
c024ae31b9 | ||
|
|
8f0dff35ed | ||
|
|
f3cded51fd | ||
|
|
b45d9ee314 | ||
|
|
0aff83f61a | ||
|
|
b26e265083 | ||
|
|
aaa650265c | ||
|
|
a2a9e7d8b2 | ||
|
|
dcff190c62 | ||
|
|
a7d994d93b | ||
|
|
a1973ae202 | ||
|
|
f9d1b46d81 | ||
|
|
821b8658a8 | ||
|
|
6f5a444756 | ||
|
|
2e340c004a | ||
|
|
2a9b05de80 | ||
|
|
08ed9ac3ef | ||
|
|
2fb53b4279 | ||
|
|
42359fbf0f | ||
|
|
6ce7c31a1e | ||
|
|
e49b0707e9 | ||
|
|
77f8774a55 | ||
|
|
3a1c445c0b | ||
|
|
cfd3418bb7 | ||
|
|
faf3a2dbb2 | ||
|
|
002ab8b98d | ||
|
|
c69ddb1bb3 | ||
|
|
01b3f6eec1 | ||
|
|
6a30795bb8 | ||
|
|
0a364f537f | ||
|
|
d133a48866 | ||
|
|
434d0a63cd | ||
|
|
608bd25679 | ||
|
|
47909e39a9 | ||
|
|
740fc90dfd | ||
|
|
272592488b | ||
|
|
6269aefee5 | ||
|
|
9717196264 | ||
|
|
2817aab197 | ||
|
|
bbfece5720 | ||
|
|
e0501bbbd1 | ||
|
|
6ab68e8ac9 | ||
|
|
0ed21b1b76 | ||
|
|
02017a2531 | ||
|
|
0a375de55c | ||
|
|
4d8c984d08 | ||
|
|
c39d7260fb | ||
|
|
cb9a90d9ac | ||
|
|
412fa85a81 | ||
|
|
ac1e586156 | ||
|
|
e1c155be9e | ||
|
|
2a4f8f631b | ||
|
|
ba232f3385 | ||
|
|
5f6341a318 | ||
|
|
d005b3477a | ||
|
|
07a9a61743 | ||
|
|
86c8e4995d | ||
|
|
e5dc650443 | ||
|
|
9596b0a667 | ||
|
|
4a30cad91f | ||
|
|
7752ec42ee | ||
|
|
3a88fb0df0 | ||
|
|
f95c6b2b2b | ||
|
|
a0999bfcde | ||
|
|
4b5540f3c2 | ||
|
|
d120e0ddb6 | ||
|
|
b73afcdfdc | ||
|
|
c21a86beea | ||
|
|
bcb21344ba | ||
|
|
e87724499b | ||
|
|
42e1175830 | ||
|
|
afac47add2 | ||
|
|
d1582f33e8 | ||
|
|
1d84b1b1c0 | ||
|
|
f0a24a1a99 | ||
|
|
3d12c508d3 | ||
|
|
88dec71a41 | ||
|
|
6180c5fc49 | ||
|
|
dfb50c83fa | ||
|
|
3ad353b97f | ||
|
|
e05b42942d | ||
|
|
e601f8079f | ||
|
|
fd49843c5a | ||
|
|
a9a9f8e138 | ||
|
|
15c3b519a7 | ||
|
|
c27a707776 | ||
|
|
7602365bb8 | ||
|
|
1fa126cecf | ||
|
|
398a4bbefd | ||
|
|
3010e51f72 | ||
|
|
71b3ce56cf | ||
|
|
bf6bd01fcb | ||
|
|
3009727ec1 | ||
|
|
e2806578c0 | ||
|
|
231650dc00 | ||
|
|
675966a783 | ||
|
|
9ff3dc68ce | ||
|
|
01722af2fb | ||
|
|
46a28f77f0 | ||
|
|
aafc970ae8 | ||
|
|
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 |
12
.env.example
12
.env.example
@ -9,6 +9,13 @@
|
||||
# Generate with: openssl rand -base64 32
|
||||
AUTH_SECRET=replace-with-random-secret
|
||||
|
||||
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
|
||||
ACCESS_KEY_PEPPER=replace-with-random-secret
|
||||
|
||||
# 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.
|
||||
AUTH_URL=https://your-domain.com
|
||||
NEXTAUTH_URL=https://your-domain.com
|
||||
@ -146,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
|
||||
# Home Performance / Queues
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
CANVAS_IMAGE_TASK_CONCURRENCY=3
|
||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
|
||||
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
|
||||
CANVAS_IMAGE_TASK_CONCURRENCY=0
|
||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
|
||||
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
||||
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
||||
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
||||
|
||||
20
.gitattributes
vendored
Normal file
20
.gitattributes
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
# Keep source checkouts consistent across Windows and Linux.
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.cjs text eol=lf
|
||||
*.json text eol=lf
|
||||
*.jsonc text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
38
.gitignore
vendored
38
.gitignore
vendored
@ -13,6 +13,7 @@ coverage
|
||||
.next/
|
||||
.next-build/
|
||||
.next-previous/
|
||||
/.next-*/
|
||||
out/
|
||||
build
|
||||
|
||||
@ -50,6 +51,7 @@ yarn-error.log*
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/*
|
||||
/output/
|
||||
deploy.tar.gz
|
||||
libvips-8.14.5-linux-x64.tar.br
|
||||
.temp-packages/
|
||||
@ -66,10 +68,19 @@ migration-backup-complete/
|
||||
migration-*.log
|
||||
migration-*.txt
|
||||
|
||||
# Local release backups, model credentials and migration scratch data.
|
||||
/.model-config-backups/
|
||||
/backups/
|
||||
/tmp/
|
||||
|
||||
# 本地抓包/竞品参考产物,不能提交
|
||||
artifacts/liblib-*/
|
||||
analysis/qiaodou-extract/
|
||||
|
||||
# 本地运行时数据库与上传文件
|
||||
.local/
|
||||
public/uploads/local/
|
||||
|
||||
# 模型文件
|
||||
*.model
|
||||
*.pth
|
||||
@ -100,3 +111,30 @@ public/vendor/zoo-modeling-app/
|
||||
|
||||
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||
!lib/db/migrations/*.sql
|
||||
|
||||
# Private smart edit runtime and media/model caches
|
||||
.venv-smart-edit/
|
||||
.smart-edit-models/
|
||||
.cache/smart-edit/
|
||||
|
||||
services/video-render/dist/
|
||||
|
||||
# Downloaded and normalized subtitle fonts are runtime assets.
|
||||
.cache/smart-edit-fonts/
|
||||
services/smart-edit/__pycache__/
|
||||
|
||||
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
|
||||
.qa/
|
||||
.next-video-*/
|
||||
.next-tvstar-*/
|
||||
|
||||
services/video-upscale/dist/
|
||||
services/video-upscale/__pycache__/
|
||||
services/video-upscale/vendor/__pycache__/
|
||||
|
||||
/services/audio-processing/dist/
|
||||
/services/audio-processing/__pycache__/
|
||||
|
||||
# Python release-tool and test bytecode.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@ -94,7 +94,7 @@ pnpm db:generate
|
||||
推荐提交前至少运行:
|
||||
|
||||
```bash
|
||||
git fetch github main --prune
|
||||
git fetch origin --prune
|
||||
pnpm check:branch
|
||||
pnpm check:quality
|
||||
pnpm audit:security
|
||||
|
||||
@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({
|
||||
aiWashConfig,
|
||||
setAiWashConfig,
|
||||
aiWashModeIconUploading,
|
||||
canvasLocalEditConfig,
|
||||
setCanvasLocalEditConfig,
|
||||
extendConfig,
|
||||
setExtendConfig,
|
||||
watermarkRemoveConfig,
|
||||
@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({
|
||||
handleMoveEnhanceProPreset,
|
||||
handleAiWashModeIconUpload,
|
||||
handleSaveAiWashConfig,
|
||||
handleSaveCanvasLocalEditConfig,
|
||||
handleSaveExtendConfig,
|
||||
handleSaveWatermarkRemoveConfig,
|
||||
handleSaveAngleEditConfig,
|
||||
@ -156,12 +159,15 @@ export function LocalAiImagePipelineSections({
|
||||
},
|
||||
{
|
||||
label:
|
||||
rmbgConfig.apiKey ||
|
||||
rmbgConfig.provider === "vip98k"
|
||||
? "服务商 Key 已配置"
|
||||
: rmbgConfig.apiKey ||
|
||||
rmbgConfig.photoroomApiKey ||
|
||||
rmbgConfig.aosApiKey
|
||||
? "Key 已配置"
|
||||
: "Key 待配置",
|
||||
tone:
|
||||
rmbgConfig.provider === "vip98k" ||
|
||||
rmbgConfig.apiKey ||
|
||||
rmbgConfig.photoroomApiKey ||
|
||||
rmbgConfig.aosApiKey
|
||||
@ -176,7 +182,26 @@ export function LocalAiImagePipelineSections({
|
||||
{/* 服务提供商选择 */}
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||||
rmbgConfig.provider === "rmbg"
|
||||
@ -263,6 +288,13 @@ export function LocalAiImagePipelineSections({
|
||||
|
||||
<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 配置 */}
|
||||
{rmbgConfig.provider === "rmbg" && (
|
||||
<>
|
||||
@ -468,7 +500,7 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
|
||||
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("enhance")}
|
||||
@ -478,17 +510,21 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: enhanceConfig.useBackupApi
|
||||
label: enhanceConfig.provider === "vip98k"
|
||||
? "VIP98K"
|
||||
: enhanceConfig.useBackupApi
|
||||
? "备用 Real-ESRGAN"
|
||||
: "主 API",
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label:
|
||||
enhanceConfig.provider === "vip98k" ||
|
||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||
? "Key 已配置"
|
||||
: "Key 待配置",
|
||||
tone:
|
||||
enhanceConfig.provider === "vip98k" ||
|
||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||
? "enabled"
|
||||
: "warning",
|
||||
@ -498,6 +534,13 @@ export function LocalAiImagePipelineSections({
|
||||
}
|
||||
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切换开关 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
@ -622,7 +665,7 @@ export function LocalAiImagePipelineSections({
|
||||
</p>
|
||||
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
|
||||
<p>
|
||||
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
|
||||
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
|
||||
</p>
|
||||
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
|
||||
</div>
|
||||
@ -741,11 +784,13 @@ export function LocalAiImagePipelineSections({
|
||||
<p>• 支持2x、4x、8x放大倍数</p>
|
||||
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
|
||||
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
|
||||
<p>• 文件限制:最大50MB</p>
|
||||
<p>• 文件限制:最大20MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSaveEnhanceConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||||
@ -1421,6 +1466,112 @@ export function LocalAiImagePipelineSections({
|
||||
</Button>
|
||||
</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
|
||||
description={
|
||||
<>
|
||||
@ -2215,7 +2366,7 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
|
||||
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("extend")}
|
||||
@ -2225,8 +2376,11 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
|
||||
tone: extendConfig.apiKey ? "enabled" : "warning",
|
||||
label:
|
||||
extendConfig.provider === "vip98k"
|
||||
? "VIP98K 服务商"
|
||||
: "阿里云服务商",
|
||||
tone: "enabled",
|
||||
},
|
||||
{
|
||||
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
||||
@ -2237,22 +2391,18 @@ export function LocalAiImagePipelineSections({
|
||||
}
|
||||
title="图像拓展API配置"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extendApiKey">API密钥 *</Label>
|
||||
<Input
|
||||
id="extendApiKey"
|
||||
onChange={(e) =>
|
||||
setExtendConfig({ ...extendConfig, apiKey: e.target.value })
|
||||
}
|
||||
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
type="password"
|
||||
value={extendConfig.apiKey}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
请输入阿里云Dashscope API密钥,例如:
|
||||
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
{extendConfig.provider === "vip98k" ? (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
|
||||
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用阿里云 DashScope 的 image-out-painting 模型。API Key
|
||||
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
|
||||
仅在这里维护拓展比例。
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
|
||||
@ -2306,7 +2456,7 @@ export function LocalAiImagePipelineSections({
|
||||
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
|
||||
</p>
|
||||
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
|
||||
<p>• 最大图片大小:4MB</p>
|
||||
<p>• 最大图片大小:10MB</p>
|
||||
<p>• 处理时间:约5-15秒/张</p>
|
||||
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
|
||||
</div>
|
||||
|
||||
@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
|
||||
</Button>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
{/* 全站生图单模型并发 */}
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
|
||||
</>
|
||||
}
|
||||
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
|
||||
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
|
||||
id="local-ai-section-canvas-image-task-queue"
|
||||
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
|
||||
summary={
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label: `${(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
).toFixed(1)} 秒补位`,
|
||||
tone: "neutral",
|
||||
},
|
||||
{ label: "全站生图生效", tone: "enabled" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
全站生图单模型并发与批次间隔
|
||||
</span>
|
||||
}
|
||||
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
|
||||
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
|
||||
<div className="font-medium">生效范围</div>
|
||||
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
|
||||
<p>
|
||||
•
|
||||
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
|
||||
</p>
|
||||
<p>
|
||||
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
|
||||
</p>
|
||||
<p>
|
||||
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
|
||||
池继续细分。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="canvasImageTaskQueueConcurrency">
|
||||
单模型最大同时运行任务数
|
||||
</Label>
|
||||
<Input
|
||||
id="canvasImageTaskQueueConcurrency"
|
||||
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
|
||||
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
|
||||
onChange={(e) =>
|
||||
setCanvasImageTaskQueueConfig(
|
||||
normalizeCanvasImageTaskQueueConfig({
|
||||
...canvasImageTaskQueueConfig,
|
||||
concurrency: e.target.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
type="number"
|
||||
value={canvasImageTaskQueueConfig.concurrency}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
|
||||
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
|
||||
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
|
||||
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
|
||||
下一批排队任务发起间隔(秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="canvasImageTaskBatchDispatchIntervalSeconds"
|
||||
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
|
||||
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
|
||||
onChange={(e) =>
|
||||
setCanvasImageTaskQueueConfig(
|
||||
normalizeCanvasImageTaskQueueConfig({
|
||||
...canvasImageTaskQueueConfig,
|
||||
batchDispatchIntervalMs: Math.round(
|
||||
Number.parseFloat(e.target.value || "0") * 1000
|
||||
),
|
||||
})
|
||||
)
|
||||
}
|
||||
step="0.5"
|
||||
type="number"
|
||||
value={String(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
)}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
|
||||
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
|
||||
{(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
).toFixed(1)}{" "}
|
||||
秒,默认值为{" "}
|
||||
{(
|
||||
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
|
||||
).toFixed(1)}{" "}
|
||||
秒。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
{/* 批量处理配置 */}
|
||||
|
||||
@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
|
||||
rmbg: false,
|
||||
enhance: false,
|
||||
"enhance-pro": false,
|
||||
"canvas-local-edit": false,
|
||||
"ai-wash": false,
|
||||
extend: false,
|
||||
"watermark-remove": false,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
||||
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
|
||||
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
|
||||
import type { AiWashConfig } from "@/lib/ai-wash/config";
|
||||
import type {
|
||||
EnhanceProKreaConfig,
|
||||
@ -91,6 +92,7 @@ export type AdminAiProvider = {
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -247,7 +249,7 @@ export type EnhanceProKeyRow = {
|
||||
};
|
||||
|
||||
export type RmbgConfig = {
|
||||
provider: "rmbg" | "photoroom" | "aoscdn";
|
||||
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
photoroomApiKey: string;
|
||||
@ -259,6 +261,7 @@ export type RmbgConfig = {
|
||||
};
|
||||
|
||||
export type EnhanceConfig = {
|
||||
provider: "vip98k" | "legacy";
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
type: string;
|
||||
@ -271,7 +274,7 @@ export type EnhanceConfig = {
|
||||
};
|
||||
|
||||
export type ExtendConfig = {
|
||||
apiKey: string;
|
||||
provider: "vip98k" | "dashscope";
|
||||
xScale: string;
|
||||
yScale: string;
|
||||
};
|
||||
@ -330,6 +333,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
aiWashConfig: AiWashConfig;
|
||||
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
||||
aiWashModeIconUploading: boolean;
|
||||
canvasLocalEditConfig: CanvasLocalEditConfig;
|
||||
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
|
||||
extendConfig: ExtendConfig;
|
||||
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
||||
watermarkRemoveConfig: WatermarkRemoveConfig;
|
||||
@ -372,6 +377,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
||||
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
||||
handleSaveAiWashConfig: AsyncAction;
|
||||
handleSaveCanvasLocalEditConfig: AsyncAction;
|
||||
handleSaveExtendConfig: AsyncAction;
|
||||
handleSaveWatermarkRemoveConfig: AsyncAction;
|
||||
handleSaveAngleEditConfig: AsyncAction;
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
ImagePlus,
|
||||
Key,
|
||||
Layers,
|
||||
LayoutTemplate,
|
||||
Layers3,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
@ -102,7 +103,10 @@ import {
|
||||
} from "@/components/canvas/canvas-skill-icons";
|
||||
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
||||
import {
|
||||
DEFAULT_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
DEFAULT_OPENCLAW_CHAT_BASE_URL,
|
||||
MAX_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
MIN_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
type OpenClawChatConfig,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
@ -198,9 +202,16 @@ import {
|
||||
normalizeCanvasImageTaskQueueConfig,
|
||||
} from "@/lib/canvas/image-task-config";
|
||||
import {
|
||||
CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||
defaultCanvasLocalEditConfig,
|
||||
normalizeCanvasLocalEditConfig,
|
||||
} from "@/lib/canvas/local-edit-config";
|
||||
import {
|
||||
type CanvasSkillCategory,
|
||||
type CanvasSkillTag,
|
||||
DEFAULT_CANVAS_SKILL_TAGS,
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||
normalizeCanvasSkillCategories,
|
||||
normalizeCanvasSkillTags,
|
||||
} from "@/lib/canvas-skill-tags";
|
||||
import {
|
||||
@ -418,6 +429,13 @@ function createEmptyCanvasSkillTag(): CanvasSkillTag {
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyCanvasSkillCategory(): CanvasSkillCategory {
|
||||
return {
|
||||
id: `category-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: "新分类",
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyEnhanceProPreset(): EnhanceProPreset {
|
||||
const timestamp = Date.now();
|
||||
return {
|
||||
@ -627,6 +645,7 @@ type AiProvider = {
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -656,6 +675,9 @@ type ImageGenConfig = {
|
||||
supportsSeed?: boolean; // 是否支持随机种子
|
||||
supportedAspectRatios?: string[]; // 支持的图片比例
|
||||
supportedImageSizes?: string[]; // 支持的分辨率/K 数
|
||||
maxReferenceImages?: number; // 最大参考图数量
|
||||
maxVideoReferences?: number; // 最大参考视频数量
|
||||
supportsQuality?: boolean; // 是否支持质量档位
|
||||
creditCostPerImage?: number; // 每张图片积分消耗
|
||||
creditCostMultiplierBySize?: Record<string, number>; // 各图片尺寸积分倍率
|
||||
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折
|
||||
@ -665,6 +687,7 @@ type ImageGenConfig = {
|
||||
creditCostPerVideoByResolution?: Record<string, number>; // 各分辨率积分
|
||||
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
|
||||
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
|
||||
vip98k?: Record<string, unknown>; // VIP98K 目录元数据
|
||||
};
|
||||
|
||||
function createDefaultImageGenConfig(): ImageGenConfig {
|
||||
@ -1039,6 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
||||
imageRemoveBackground: Scissors,
|
||||
imageBoxCutout: Scan,
|
||||
imageLocalEdit: Wand2,
|
||||
imageLayoutReplace: LayoutTemplate,
|
||||
imageRemoveWatermark: Eraser,
|
||||
imageResize: Maximize2,
|
||||
imageTextEdit: Type,
|
||||
@ -1160,6 +1184,7 @@ type SiteSettings = {
|
||||
bannerEnabled: boolean;
|
||||
bannerText: string;
|
||||
bannerLink: string | null;
|
||||
bannerActionText: string;
|
||||
toolbox1Name: string;
|
||||
toolbox1Enabled: boolean;
|
||||
toolbox2Name: string;
|
||||
@ -1587,7 +1612,8 @@ const PROVIDER_TYPES = [
|
||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||
{ value: "bananaimage", label: "BananaImage 生图" },
|
||||
{ value: "visionary", label: "Visionary 生图" },
|
||||
{ value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
|
||||
{ value: "atlascloud", label: "Atlas Cloud 图片 / 视频 / 3D 模型" },
|
||||
{ value: "vip98k", label: "VIP98K 混合模型服务" },
|
||||
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
||||
@ -1625,6 +1651,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
bananaimage: "https://bananaimage.aigenmedia.art",
|
||||
visionary: "https://api.visionary.beer",
|
||||
atlascloud: "https://api.atlascloud.ai",
|
||||
vip98k: "https://api.vip98k.com",
|
||||
"stepfun-image": "https://api.stepfun.com",
|
||||
"bltcy-video": "https://api.bltcy.ai",
|
||||
"grsai-gpt-image": "https://grsai.dakka.com.cn",
|
||||
@ -1632,7 +1659,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
midjourney: "https://your-midjourney-api.example.com",
|
||||
"fal-video": "https://fal.run",
|
||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||
"worldlabs-3d": "https://api.worldlabs.ai",
|
||||
"worldlabs-3d": "https://api.vip98k.com",
|
||||
"kittycad-cad": "https://api.zoo.dev",
|
||||
runway: "https://api.dev.runwayml.com",
|
||||
krea: "https://api.krea.ai",
|
||||
@ -1881,6 +1908,13 @@ export function AdminSettingsClient() {
|
||||
const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{
|
||||
setting?: { value?: unknown };
|
||||
}>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher);
|
||||
const {
|
||||
data: canvasLocalEditConfigData,
|
||||
mutate: mutateCanvasLocalEditConfig,
|
||||
} = useSWR<{ setting?: { value?: unknown } }>(
|
||||
`/api/admin/settings?key=${CANVAS_LOCAL_EDIT_CONFIG_KEY}`,
|
||||
fetcher
|
||||
);
|
||||
const {
|
||||
data: canvasImageTaskQueueConfigData,
|
||||
mutate: mutateCanvasImageTaskQueueConfig,
|
||||
@ -2148,11 +2182,12 @@ export function AdminSettingsClient() {
|
||||
usageTimeEnabled: false,
|
||||
usageTimeStart: "08:00",
|
||||
usageTimeEnd: "20:00",
|
||||
usageTimeMessage: "由壹点累了也是需要休息的",
|
||||
usageTimeMessage: "BuYi 累了也是需要休息的",
|
||||
defaultTheme: "system",
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
bannerActionText: "立即查看",
|
||||
toolbox1Name: "工具箱",
|
||||
toolbox1Enabled: true,
|
||||
toolbox2Name: "工具箱2",
|
||||
@ -2189,7 +2224,7 @@ export function AdminSettingsClient() {
|
||||
agentWorkflowDefaultModelId: null,
|
||||
canvasDetailDefaultModelId: null,
|
||||
canvasDetailAgentModelId: null,
|
||||
lovartComposerPrefix: "让「由壹点AI」",
|
||||
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
|
||||
lovartComposerPreviewTexts: [
|
||||
"打造引人注目的社交媒体视觉",
|
||||
"帮我把这张产品图做成高级电商海报",
|
||||
@ -2205,8 +2240,8 @@ export function AdminSettingsClient() {
|
||||
lovartPromoText:
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||
surpriseCategories: ["全部", "精选"],
|
||||
membershipConfig: defaultMembershipConfig,
|
||||
membershipUserOverrides: defaultMembershipUserOverrides,
|
||||
@ -2223,7 +2258,7 @@ export function AdminSettingsClient() {
|
||||
enabled: false,
|
||||
baseUrl: DEFAULT_OPENCLAW_CHAT_BASE_URL,
|
||||
token: "",
|
||||
timeoutMs: 180_000,
|
||||
timeoutMs: DEFAULT_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
});
|
||||
const [openClawChatTokenConfigured, setOpenClawChatTokenConfigured] =
|
||||
useState(false);
|
||||
@ -2267,12 +2302,13 @@ export function AdminSettingsClient() {
|
||||
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
|
||||
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
|
||||
usageTimeMessage:
|
||||
siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的",
|
||||
siteSettingsData.usageTimeMessage || "BuYi 累了也是需要休息的",
|
||||
defaultTheme: siteSettingsData.defaultTheme || "system",
|
||||
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
|
||||
bannerText:
|
||||
siteSettingsData.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: siteSettingsData.bannerLink || null,
|
||||
bannerActionText: siteSettingsData.bannerActionText || "立即查看",
|
||||
toolbox1Name: siteSettingsData.toolbox1Name || "工具箱",
|
||||
toolbox1Enabled: siteSettingsData.toolbox1Enabled ?? true,
|
||||
toolbox2Name: siteSettingsData.toolbox2Name || "工具箱2",
|
||||
@ -2362,7 +2398,7 @@ export function AdminSettingsClient() {
|
||||
(siteSettingsData as any).canvasDetailAgentModelId || null,
|
||||
lovartComposerPrefix:
|
||||
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
|
||||
"让「由壹点AI」",
|
||||
"让「𝗕𝘂𝗬𝗶」",
|
||||
lovartComposerPreviewTexts: Array.isArray(
|
||||
siteSettingsData.lovartComposerPreviewTexts
|
||||
)
|
||||
@ -2464,9 +2500,9 @@ export function AdminSettingsClient() {
|
||||
lovartPromoActionText:
|
||||
siteSettingsData.lovartPromoActionText || "立即升级",
|
||||
lovartHeroTitle:
|
||||
siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
|
||||
siteSettingsData.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle:
|
||||
siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
||||
siteSettingsData.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
|
||||
? siteSettingsData.surpriseCategories
|
||||
.map((item: any) => String(item).trim())
|
||||
@ -2515,8 +2551,11 @@ export function AdminSettingsClient() {
|
||||
String(raw.baseUrl || "").trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL,
|
||||
token: "",
|
||||
timeoutMs: Math.min(
|
||||
300_000,
|
||||
Math.max(5_000, Math.round(Number(raw.timeoutMs) || 180_000))
|
||||
MAX_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
Math.max(
|
||||
MIN_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
Math.round(Number(raw.timeoutMs) || DEFAULT_OPENCLAW_TASK_TIMEOUT_MS)
|
||||
)
|
||||
),
|
||||
});
|
||||
setOpenClawChatTokenConfigured(
|
||||
@ -2549,7 +2588,7 @@ export function AdminSettingsClient() {
|
||||
|
||||
// 抠图API配置状态
|
||||
const [rmbgConfig, setRmbgConfig] = useState({
|
||||
provider: "rmbg" as "rmbg" | "photoroom" | "aoscdn", // 抠图服务提供商
|
||||
provider: "vip98k" as "vip98k" | "rmbg" | "photoroom" | "aoscdn", // 抠图服务提供商
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
// PhotoRoom 配置
|
||||
@ -2564,6 +2603,7 @@ export function AdminSettingsClient() {
|
||||
|
||||
// 变清晰API配置状态
|
||||
const [enhanceConfig, setEnhanceConfig] = useState({
|
||||
provider: "vip98k" as "vip98k" | "legacy",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
type: "clean", // "clean" | "face"
|
||||
@ -2587,10 +2627,13 @@ export function AdminSettingsClient() {
|
||||
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
|
||||
useState("");
|
||||
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
|
||||
const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState(
|
||||
defaultCanvasLocalEditConfig()
|
||||
);
|
||||
|
||||
// 图像拓展API配置状态
|
||||
const [extendConfig, setExtendConfig] = useState({
|
||||
apiKey: "",
|
||||
provider: "dashscope" as "vip98k" | "dashscope",
|
||||
xScale: "1.5", // X轴放大倍数
|
||||
yScale: "1.5", // Y轴放大倍数
|
||||
});
|
||||
@ -2754,7 +2797,7 @@ export function AdminSettingsClient() {
|
||||
useEffect(() => {
|
||||
if (rmbgConfigData?.setting?.value) {
|
||||
const config = rmbgConfigData.setting.value as {
|
||||
provider?: "rmbg" | "photoroom" | "aoscdn";
|
||||
provider?: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
photoroomApiKey?: string;
|
||||
@ -2765,7 +2808,7 @@ export function AdminSettingsClient() {
|
||||
aosFormat?: "png" | "jpg";
|
||||
};
|
||||
setRmbgConfig({
|
||||
provider: config.provider || "rmbg",
|
||||
provider: config.provider || "vip98k",
|
||||
apiUrl: config.apiUrl || "",
|
||||
apiKey: config.apiKey || "",
|
||||
photoroomApiKey: config.photoroomApiKey || "",
|
||||
@ -2791,6 +2834,7 @@ export function AdminSettingsClient() {
|
||||
useEffect(() => {
|
||||
if (enhanceConfigData?.setting?.value) {
|
||||
const config = enhanceConfigData.setting.value as {
|
||||
provider?: "vip98k" | "legacy";
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
type?: string;
|
||||
@ -2802,6 +2846,7 @@ export function AdminSettingsClient() {
|
||||
backupModel?: string;
|
||||
};
|
||||
setEnhanceConfig({
|
||||
provider: config.provider === "vip98k" ? "vip98k" : "legacy",
|
||||
apiUrl: config.apiUrl || "",
|
||||
apiKey: config.apiKey || "",
|
||||
type: config.type || "clean",
|
||||
@ -2828,6 +2873,13 @@ export function AdminSettingsClient() {
|
||||
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
|
||||
}, [aiWashConfigData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasLocalEditConfigData) return;
|
||||
setCanvasLocalEditConfig(
|
||||
normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value)
|
||||
);
|
||||
}, [canvasLocalEditConfigData]);
|
||||
|
||||
const enhanceProApiKeysDraftCount = useMemo(
|
||||
() =>
|
||||
normalizeEnhanceProKreaConfig({
|
||||
@ -2899,12 +2951,12 @@ export function AdminSettingsClient() {
|
||||
useEffect(() => {
|
||||
if (extendConfigData?.setting?.value) {
|
||||
const config = extendConfigData.setting.value as {
|
||||
apiKey?: string;
|
||||
provider?: "vip98k" | "dashscope";
|
||||
xScale?: number;
|
||||
yScale?: number;
|
||||
};
|
||||
setExtendConfig({
|
||||
apiKey: config.apiKey || "",
|
||||
provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
|
||||
xScale: config.xScale ? String(config.xScale) : "1.5",
|
||||
yScale: config.yScale ? String(config.yScale) : "1.5",
|
||||
});
|
||||
@ -3401,6 +3453,9 @@ export function AdminSettingsClient() {
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
||||
);
|
||||
const [canvasSkillTagsEnabled, setCanvasSkillTagsEnabled] = useState(true);
|
||||
const [canvasSkillCategories, setCanvasSkillCategories] = useState<
|
||||
CanvasSkillCategory[]
|
||||
>([]);
|
||||
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
|
||||
DEFAULT_CANVAS_SKILL_TAGS
|
||||
);
|
||||
@ -3418,6 +3473,7 @@ export function AdminSettingsClient() {
|
||||
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
||||
enabled?: boolean;
|
||||
title?: string;
|
||||
categories?: CanvasSkillCategory[];
|
||||
tags?: CanvasSkillTag[];
|
||||
}>("/api/canvas-skill-tags", fetcher);
|
||||
|
||||
@ -3644,6 +3700,9 @@ export function AdminSettingsClient() {
|
||||
String(canvasSkillTagsData.title || "").trim() ||
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
||||
);
|
||||
setCanvasSkillCategories(
|
||||
normalizeCanvasSkillCategories(canvasSkillTagsData.categories)
|
||||
);
|
||||
setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
|
||||
}
|
||||
}, [canvasSkillTagsData]);
|
||||
@ -3680,7 +3739,13 @@ export function AdminSettingsClient() {
|
||||
|
||||
const handleCanvasSkillTagChange = (
|
||||
index: number,
|
||||
field: "name" | "prompt" | "placeholder" | "iconName" | "iconUrl",
|
||||
field:
|
||||
| "name"
|
||||
| "prompt"
|
||||
| "placeholder"
|
||||
| "iconName"
|
||||
| "iconUrl"
|
||||
| "categoryId",
|
||||
value: string
|
||||
) => {
|
||||
setCanvasSkillTags((prev) =>
|
||||
@ -3933,6 +3998,36 @@ export function AdminSettingsClient() {
|
||||
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
||||
};
|
||||
|
||||
const handleAddCanvasSkillCategory = () => {
|
||||
setCanvasSkillCategories((prev) => [
|
||||
...prev,
|
||||
createEmptyCanvasSkillCategory(),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleCanvasSkillCategoryNameChange = (index: number, name: string) => {
|
||||
setCanvasSkillCategories((prev) =>
|
||||
prev.map((category, categoryIndex) =>
|
||||
categoryIndex === index ? { ...category, name } : category
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveCanvasSkillCategory = (index: number) => {
|
||||
const categoryId = canvasSkillCategories[index]?.id;
|
||||
setCanvasSkillCategories((prev) =>
|
||||
prev.filter((_, categoryIndex) => categoryIndex !== index)
|
||||
);
|
||||
if (!categoryId) return;
|
||||
setCanvasSkillTags((prev) =>
|
||||
prev.map((tag) => {
|
||||
if (tag.categoryId !== categoryId) return tag;
|
||||
const { categoryId: _categoryId, ...rest } = tag;
|
||||
return rest;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveCanvasSkillTag = (index: number) => {
|
||||
setCanvasSkillTags((prev) =>
|
||||
prev.filter((_, itemIndex) => itemIndex !== index)
|
||||
@ -3941,6 +4036,7 @@ export function AdminSettingsClient() {
|
||||
|
||||
const handleSaveCanvasSkillTags = async () => {
|
||||
try {
|
||||
const categories = normalizeCanvasSkillCategories(canvasSkillCategories);
|
||||
const payload = normalizeCanvasSkillTags(canvasSkillTags);
|
||||
const res = await fetch("/api/canvas-skill-tags", {
|
||||
method: "PUT",
|
||||
@ -3948,6 +4044,7 @@ export function AdminSettingsClient() {
|
||||
body: JSON.stringify({
|
||||
enabled: canvasSkillTagsEnabled,
|
||||
title: canvasSkillTagsTitle,
|
||||
categories,
|
||||
tags: payload,
|
||||
}),
|
||||
});
|
||||
@ -3959,6 +4056,9 @@ export function AdminSettingsClient() {
|
||||
String(data?.title || canvasSkillTagsTitle || "").trim() ||
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
|
||||
);
|
||||
setCanvasSkillCategories(
|
||||
normalizeCanvasSkillCategories(data?.categories ?? categories)
|
||||
);
|
||||
setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload));
|
||||
toast.success("快捷标签已更新");
|
||||
mutateCanvasSkillTags();
|
||||
@ -4057,6 +4157,7 @@ export function AdminSettingsClient() {
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -4369,8 +4470,14 @@ export function AdminSettingsClient() {
|
||||
openClawChatConfig.baseUrl.trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL,
|
||||
token: openClawChatConfig.token.trim(),
|
||||
timeoutMs: Math.min(
|
||||
300_000,
|
||||
Math.max(5_000, Math.round(Number(openClawChatConfig.timeoutMs) || 180_000))
|
||||
MAX_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
Math.max(
|
||||
MIN_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
Math.round(
|
||||
Number(openClawChatConfig.timeoutMs) ||
|
||||
DEFAULT_OPENCLAW_TASK_TIMEOUT_MS
|
||||
)
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
@ -5040,7 +5147,9 @@ export function AdminSettingsClient() {
|
||||
const handleSaveEnhanceConfig = async () => {
|
||||
try {
|
||||
// 如果启用了备用API,验证备用API配置
|
||||
if (enhanceConfig.useBackupApi) {
|
||||
if (enhanceConfig.provider === "vip98k") {
|
||||
// VIP98K credentials are read from AiProvider and never duplicated here.
|
||||
} else if (enhanceConfig.useBackupApi) {
|
||||
if (!enhanceConfig.backupApiUrl.trim()) {
|
||||
toast.error("请输入备用API地址");
|
||||
return;
|
||||
@ -5067,6 +5176,7 @@ export function AdminSettingsClient() {
|
||||
body: JSON.stringify({
|
||||
key: "enhance_api_config",
|
||||
value: {
|
||||
provider: enhanceConfig.provider,
|
||||
apiUrl: enhanceConfig.apiUrl.trim(),
|
||||
apiKey: enhanceConfig.apiKey.trim(),
|
||||
type: enhanceConfig.type,
|
||||
@ -5213,6 +5323,34 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCanvasLocalEditConfig = async () => {
|
||||
try {
|
||||
const normalizedConfig =
|
||||
normalizeCanvasLocalEditConfig(canvasLocalEditConfig);
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||
value: normalizedConfig,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
String(payload?.message || payload?.error || "保存失败")
|
||||
);
|
||||
}
|
||||
|
||||
mutateCanvasLocalEditConfig();
|
||||
toast.success("局部编辑模型配置保存成功");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "保存局部编辑模型配置失败"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEnhanceProPoolKey = (index: number) => {
|
||||
setEnhanceProConfig((prev) => ({
|
||||
...prev,
|
||||
@ -5415,11 +5553,6 @@ export function AdminSettingsClient() {
|
||||
// --- 图像拓展API配置逻辑 ---
|
||||
const handleSaveExtendConfig = async () => {
|
||||
try {
|
||||
if (!extendConfig.apiKey.trim()) {
|
||||
toast.error("请输入API密钥");
|
||||
return;
|
||||
}
|
||||
|
||||
const xScale = Number.parseFloat(extendConfig.xScale);
|
||||
const yScale = Number.parseFloat(extendConfig.yScale);
|
||||
|
||||
@ -5439,7 +5572,7 @@ export function AdminSettingsClient() {
|
||||
body: JSON.stringify({
|
||||
key: "extend_api_config",
|
||||
value: {
|
||||
apiKey: extendConfig.apiKey.trim(),
|
||||
provider: extendConfig.provider,
|
||||
xScale,
|
||||
yScale,
|
||||
},
|
||||
@ -6303,6 +6436,7 @@ export function AdminSettingsClient() {
|
||||
const isBananaImageProvider = providerForm.type === "bananaimage";
|
||||
const isVisionaryProvider = providerForm.type === "visionary";
|
||||
const isAtlasCloudProvider = providerForm.type === "atlascloud";
|
||||
const isVip98kProvider = providerForm.type === "vip98k";
|
||||
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
|
||||
const isLemonProvider =
|
||||
providerForm.type === "openai" &&
|
||||
@ -6473,7 +6607,18 @@ export function AdminSettingsClient() {
|
||||
} catch (modelError) {
|
||||
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
|
||||
toast.warning(
|
||||
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
|
||||
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 模型"按钮'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isVip98kProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddVip98kModels(providerId, { skipConfirm: true });
|
||||
} catch (modelError) {
|
||||
console.error("[Auto Sync VIP98K Models] Error:", modelError);
|
||||
toast.warning(
|
||||
'VIP98K 模型同步失败,请手动点击"同步 VIP98K 全部模型"按钮'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7534,7 +7679,7 @@ export function AdminSettingsClient() {
|
||||
if (
|
||||
!options?.skipConfirm &&
|
||||
!confirm(
|
||||
"将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
|
||||
"将从 Atlas Cloud 官方目录同步图片、视频与 3D 模型,并读取每个模型的参数 Schema。已删除的模型不会被恢复。同步数量较多,可能需要几十秒。\n\n继续吗?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@ -7553,7 +7698,7 @@ export function AdminSettingsClient() {
|
||||
const totalSynced =
|
||||
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
||||
toast.success(
|
||||
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
||||
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 视频 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
||||
);
|
||||
mutateModels();
|
||||
if (data.errors?.length) {
|
||||
@ -7561,7 +7706,48 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Quick Add Atlas Cloud Models] Error:", error);
|
||||
toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
|
||||
toast.error(error.message || "同步 Atlas Cloud 模型失败");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickAddVip98kModels = async (
|
||||
providerId: string,
|
||||
options?: { skipConfirm?: boolean }
|
||||
) => {
|
||||
if (
|
||||
!options?.skipConfirm &&
|
||||
!confirm(
|
||||
"将同步 VIP98K 当前可用目录,停用服务商已关闭的模型,并排除纯文生图和纯文生视频模型。图片编辑、图生视频、参考媒体、多模态、3D 与工具模型会保留;你此前手动删除的模型不会恢复。\n\n继续吗?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.info("正在读取 VIP98K 模型目录...");
|
||||
const response = await fetch("/api/admin/vip98k-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "同步失败");
|
||||
|
||||
const totalSynced =
|
||||
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
||||
const totalCatalog = Number(data.counts?.total || totalSynced);
|
||||
const totalDisabled = Number(data.disabledCount || 0);
|
||||
toast.success(
|
||||
`已同步 ${totalSynced} 个 VIP98K 模型,停用 ${totalDisabled} 个旧模型(可用目录 ${totalCatalog} 个)`
|
||||
);
|
||||
mutateModels();
|
||||
if (data.errors?.length) {
|
||||
toast.warning(`${data.errors.length} 个 VIP98K 模型同步失败`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Quick Add VIP98K Models] Error:", error);
|
||||
toast.error(error.message || "同步 VIP98K 模型失败");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@ -8720,9 +8906,7 @@ export function AdminSettingsClient() {
|
||||
{
|
||||
key: "realtime-canvas",
|
||||
title: "实时与队列",
|
||||
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
).toFixed(1)} 秒`,
|
||||
description: "生图并发不限,任务即时发起",
|
||||
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
|
||||
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
|
||||
icon: SlidersHorizontal,
|
||||
@ -8805,7 +8989,7 @@ export function AdminSettingsClient() {
|
||||
key: "extend",
|
||||
label: "图像拓展",
|
||||
group: "图像处理",
|
||||
tone: extendConfig.apiKey ? "enabled" : "warning",
|
||||
tone: "enabled",
|
||||
},
|
||||
{
|
||||
key: "watermark-remove",
|
||||
@ -10329,7 +10513,7 @@ export function AdminSettingsClient() {
|
||||
lovartComposerPrefix: e.target.value.slice(0, 40),
|
||||
})
|
||||
}
|
||||
placeholder="让「由壹点AI」"
|
||||
placeholder="让「𝗕𝘂𝗬𝗶」"
|
||||
value={siteSettings.lovartComposerPrefix}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
@ -10531,7 +10715,7 @@ export function AdminSettingsClient() {
|
||||
lovartHeroTitle: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="由壹点AI 让设计更简单"
|
||||
placeholder="𝗕𝘂𝗬𝗶 让设计触手可及"
|
||||
value={siteSettings.lovartHeroTitle}
|
||||
/>
|
||||
</div>
|
||||
@ -10545,7 +10729,7 @@ export function AdminSettingsClient() {
|
||||
lovartHeroSubtitle: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="懂你的设计代理,帮你搞定一切"
|
||||
placeholder="下达指令,即刻呈现"
|
||||
value={siteSettings.lovartHeroSubtitle}
|
||||
/>
|
||||
</div>
|
||||
@ -12254,7 +12438,7 @@ export function AdminSettingsClient() {
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="bannerEnabled">广告横幅</Label>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
在无限画布等页面的聊天面板中显示广告横幅
|
||||
在首页顶部和无限画布聊天面板中显示广告横幅
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@ -12284,6 +12468,21 @@ export function AdminSettingsClient() {
|
||||
value={siteSettings.bannerText}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor="bannerActionText">按钮文字</Label>
|
||||
<Input
|
||||
id="bannerActionText"
|
||||
maxLength={32}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
bannerActionText: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="例如:立即升级"
|
||||
value={siteSettings.bannerActionText}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor="bannerLink">横幅链接(可选)</Label>
|
||||
<Input
|
||||
@ -12298,7 +12497,7 @@ export function AdminSettingsClient() {
|
||||
value={siteSettings.bannerLink || ""}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
如果不填写链接,点击横幅将不会跳转
|
||||
首页按钮和无限画布横幅将打开此链接;不填写时只展示文案
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@ -12671,21 +12870,27 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openClawChatTimeoutMs">
|
||||
请求超时(毫秒)
|
||||
任务最长执行时间(毫秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="openClawChatTimeoutMs"
|
||||
min={5000}
|
||||
max={300000}
|
||||
min={MIN_OPENCLAW_TASK_TIMEOUT_MS}
|
||||
max={MAX_OPENCLAW_TASK_TIMEOUT_MS}
|
||||
onChange={(event) =>
|
||||
setOpenClawChatConfig((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: Number(event.target.value) || 180000,
|
||||
timeoutMs:
|
||||
Number(event.target.value) ||
|
||||
DEFAULT_OPENCLAW_TASK_TIMEOUT_MS,
|
||||
}))
|
||||
}
|
||||
step={60_000}
|
||||
type="number"
|
||||
value={openClawChatConfig.timeoutMs}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
网络断开会自动续接,该时间只限制业务任务的后台等待周期。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -14764,6 +14969,61 @@ export function AdminSettingsClient() {
|
||||
value={canvasSkillTagsTitle}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<Label>技能分类</Label>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
分类会显示在 Claw 快捷技能区,用户可按分类筛选技能。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddCanvasSkillCategory}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> 添加分类
|
||||
</Button>
|
||||
</div>
|
||||
{canvasSkillCategories.length > 0 ? (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{canvasSkillCategories.map((category, categoryIndex) => (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
key={category.id}
|
||||
>
|
||||
<Input
|
||||
aria-label={`分类名称 ${categoryIndex + 1}`}
|
||||
onChange={(event) =>
|
||||
handleCanvasSkillCategoryNameChange(
|
||||
categoryIndex,
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
placeholder={`分类名称 ${categoryIndex + 1}`}
|
||||
value={category.name}
|
||||
/>
|
||||
<Button
|
||||
aria-label={`删除分类 ${category.name}`}
|
||||
onClick={() =>
|
||||
handleRemoveCanvasSkillCategory(categoryIndex)
|
||||
}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md bg-muted/40 px-3 py-2 text-muted-foreground text-xs">
|
||||
暂未配置分类,所有技能会显示在“全部”中。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canvasSkillTags.map((tag, index) => {
|
||||
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
|
||||
return (
|
||||
@ -14771,7 +15031,7 @@ export function AdminSettingsClient() {
|
||||
className="space-y-2 rounded-lg border p-3"
|
||||
key={tag.id || index}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
handleCanvasSkillTagChange(
|
||||
@ -14783,7 +15043,32 @@ export function AdminSettingsClient() {
|
||||
placeholder={`标签名称 ${index + 1}`}
|
||||
value={tag.name}
|
||||
/>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
handleCanvasSkillTagChange(
|
||||
index,
|
||||
"categoryId",
|
||||
value === "__uncategorized__" ? "" : value
|
||||
)
|
||||
}
|
||||
value={tag.categoryId || "__uncategorized__"}
|
||||
>
|
||||
<SelectTrigger className="w-full shrink-0 md:w-[180px]">
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__uncategorized__">
|
||||
未分类
|
||||
</SelectItem>
|
||||
{canvasSkillCategories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="self-end md:self-auto"
|
||||
disabled={canvasSkillTags.length <= 1}
|
||||
onClick={() => handleRemoveCanvasSkillTag(index)}
|
||||
size="icon"
|
||||
@ -15789,6 +16074,13 @@ export function AdminSettingsClient() {
|
||||
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "vip98k" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
VIP98K 使用 Bearer API Key 认证,Base URL 只填写
|
||||
https://api.vip98k.com。保存后会自动同步图片、视频、3D、
|
||||
多模态和工具模型。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "bfl-image" &&
|
||||
isBltcyBflBaseUrl(providerForm.baseUrl) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@ -15943,7 +16235,18 @@ export function AdminSettingsClient() {
|
||||
variant="secondary"
|
||||
>
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
同步 Atlas Cloud 图片 / 3D 模型
|
||||
同步 Atlas Cloud 模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "vip98k" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() => handleQuickAddVip98kModels(provider.id)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<Sparkles className="mr-1 h-4 w-4" />
|
||||
同步 VIP98K 全部模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "bltcy-video" && (
|
||||
@ -16464,10 +16767,7 @@ export function AdminSettingsClient() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label>
|
||||
前台隐藏(仅 Agent/故障迁移可用,第三方 API
|
||||
不可用)
|
||||
</Label>
|
||||
<Label>vip模型</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后,该模型不会出现在前端,也不会参与第三方生图
|
||||
@ -18468,6 +18768,7 @@ export function AdminSettingsClient() {
|
||||
angleEditConfig={angleEditConfig}
|
||||
aiWashConfig={aiWashConfig}
|
||||
aiWashModeIconUploading={aiWashModeIconUploading}
|
||||
canvasLocalEditConfig={canvasLocalEditConfig}
|
||||
draftEnhanceProKeyRows={draftEnhanceProKeyRows}
|
||||
draggingEnhanceProPresetId={draggingEnhanceProPresetId}
|
||||
enhanceConfig={enhanceConfig}
|
||||
@ -18488,6 +18789,7 @@ export function AdminSettingsClient() {
|
||||
handleResetEnhanceProPresets={handleResetEnhanceProPresets}
|
||||
handleSaveAngleEditConfig={handleSaveAngleEditConfig}
|
||||
handleSaveAiWashConfig={handleSaveAiWashConfig}
|
||||
handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig}
|
||||
handleSaveEnhanceConfig={handleSaveEnhanceConfig}
|
||||
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
|
||||
handleSaveExtendConfig={handleSaveExtendConfig}
|
||||
@ -18509,6 +18811,7 @@ export function AdminSettingsClient() {
|
||||
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
|
||||
setAngleEditConfig={setAngleEditConfig}
|
||||
setAiWashConfig={setAiWashConfig}
|
||||
setCanvasLocalEditConfig={setCanvasLocalEditConfig}
|
||||
setEnhanceConfig={setEnhanceConfig}
|
||||
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
|
||||
setEnhanceProConfig={setEnhanceProConfig}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
@ -24,10 +25,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import {
|
||||
getVideoCanvasFunctionBillingCopy,
|
||||
@ -87,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) {
|
||||
return `${name}${id}`;
|
||||
}
|
||||
|
||||
function isImageModelOption(model: AiModelOption) {
|
||||
return isImageGenerationModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
function isVideoModelOption(model: AiModelOption) {
|
||||
return isVideoModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
capabilities: model.capabilities,
|
||||
supportsImageGen: model.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
function filterModelsForFunctionKind(
|
||||
models: AiModelOption[],
|
||||
kind: VideoCanvasFunctionDefinition["kind"],
|
||||
) {
|
||||
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
|
||||
let filtered = enabled;
|
||||
if (kind === "image" || kind === "image-edit") {
|
||||
filtered = enabled.filter(isImageModelOption);
|
||||
} else if (kind === "video") {
|
||||
filtered = enabled.filter(isVideoModelOption);
|
||||
} else if (kind === "multimodal") {
|
||||
filtered = enabled.filter(
|
||||
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
|
||||
);
|
||||
}
|
||||
return filtered.length ? filtered : enabled;
|
||||
}
|
||||
|
||||
function ModelSelect({
|
||||
allowInherit = false,
|
||||
kind,
|
||||
functionKey,
|
||||
models,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
allowInherit?: boolean;
|
||||
kind: VideoCanvasFunctionDefinition["kind"];
|
||||
functionKey?: string;
|
||||
models: AiModelOption[];
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}) {
|
||||
const filteredModels = filterModelsForFunctionKind(models, kind);
|
||||
const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text";
|
||||
const filteredModels = filterCanvasModels(models, modelKind);
|
||||
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
|
||||
const hasCurrent =
|
||||
normalizedValue === "auto" ||
|
||||
@ -851,13 +815,14 @@ export function VideoCanvasSettingsClient() {
|
||||
<div className="grid gap-4 md:grid-cols-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
|
||||
<ModelSelect
|
||||
{["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
|
||||
allowInherit
|
||||
functionKey={item.key}
|
||||
kind={item.kind}
|
||||
models={modelOptions}
|
||||
onChange={(value) => updateFunction(item.key, { model: value })}
|
||||
value={item.model || ""}
|
||||
/>
|
||||
/> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
|
||||
@ -4,7 +4,8 @@ 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";
|
||||
const ZOO_MODELING_APP_SRC =
|
||||
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@ -2,9 +2,31 @@ import { createLogger } from "@/lib/observability/logger";
|
||||
import { generateText } from "ai";
|
||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import {
|
||||
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
||||
normalizeDetailReplacementImageModelConfig,
|
||||
} from "@/lib/ai/detail-replacement-image-model-config";
|
||||
import {
|
||||
compositeReplacementWithReferenceLocks,
|
||||
parseReplacementLayoutLockRegions,
|
||||
type NormalizedReplacementBox,
|
||||
} from "@/lib/ai/replacement-layout-lock";
|
||||
import {
|
||||
buildReplacementProductIdentityAnalysisPrompt,
|
||||
buildReplacementProductStructureLockLines,
|
||||
formatReplacementProductIdentityProfile,
|
||||
normalizeReplacementProductImageIndexes,
|
||||
parseReplacementProductIdentityProfile,
|
||||
type ReplacementProductIdentityProfile,
|
||||
} from "@/lib/ai/replacement-product-identity";
|
||||
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
|
||||
import { resolveDetailReplacementVisionRuntime } from "@/lib/ai/detail-replacement-vision-runtime";
|
||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||
import { getEnabledAiModelsWithProvider, getSiteSettings } from "@/lib/db/queries";
|
||||
import {
|
||||
getEnabledAiModelsWithProvider,
|
||||
getSiteSettings,
|
||||
getSystemSetting,
|
||||
} from "@/lib/db/queries";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { bufferToArrayBuffer } from "./persistence";
|
||||
import { isVideoProviderType } from "./video-models";
|
||||
@ -234,6 +256,8 @@ export async function normalizeReplacementResultImage(params: {
|
||||
referenceImageUrl: string;
|
||||
referenceGeometry?: ReplacementReferenceGeometry | null;
|
||||
scenePlan?: ReplacementScenePlan | null;
|
||||
preserveReferenceOutsideSubject?: boolean;
|
||||
preserveReferenceText?: boolean;
|
||||
uploadPrefix: string;
|
||||
}): Promise<{
|
||||
originalUrl: string;
|
||||
@ -242,6 +266,11 @@ export async function normalizeReplacementResultImage(params: {
|
||||
}> {
|
||||
const sharp = (await import("sharp")).default;
|
||||
const generatedBuffer = await fetchImageBufferFromUrl(params.generatedImageUrl);
|
||||
let referenceBuffer: Buffer | null = null;
|
||||
const getReferenceBuffer = async () => {
|
||||
referenceBuffer ||= await fetchImageBufferFromUrl(params.referenceImageUrl);
|
||||
return referenceBuffer;
|
||||
};
|
||||
let targetWidth = params.referenceGeometry?.width;
|
||||
let targetHeight = params.referenceGeometry?.height;
|
||||
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
|
||||
@ -250,8 +279,8 @@ export async function normalizeReplacementResultImage(params: {
|
||||
|
||||
if (!targetWidth || !targetHeight) {
|
||||
try {
|
||||
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl);
|
||||
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
|
||||
const resolvedReferenceBuffer = await getReferenceBuffer();
|
||||
const referenceMetadata = await sharp(resolvedReferenceBuffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.metadata();
|
||||
targetWidth = referenceMetadata.width;
|
||||
@ -298,7 +327,7 @@ export async function normalizeReplacementResultImage(params: {
|
||||
: shouldPreserveFullGeneratedCanvas
|
||||
? "contain"
|
||||
: "cover";
|
||||
const normalizedBuffer =
|
||||
let normalizedBuffer =
|
||||
targetWidth && targetHeight
|
||||
? await imagePipeline
|
||||
.resize(targetWidth, targetHeight, {
|
||||
@ -312,6 +341,82 @@ export async function normalizeReplacementResultImage(params: {
|
||||
.png()
|
||||
.toBuffer()
|
||||
: await imagePipeline.png().toBuffer();
|
||||
|
||||
if (
|
||||
targetWidth &&
|
||||
targetHeight &&
|
||||
(params.preserveReferenceOutsideSubject || params.preserveReferenceText)
|
||||
) {
|
||||
let layoutLocks = {
|
||||
subjectBoundingBox: params.scenePlan?.subjectBoundingBox || null,
|
||||
textBoundingBoxes: params.scenePlan?.textBoundingBoxes || [],
|
||||
analyzed: params.scenePlan?.layoutLockAnalyzed === true,
|
||||
};
|
||||
const needsLayoutLockFallback =
|
||||
!layoutLocks.analyzed ||
|
||||
(params.preserveReferenceOutsideSubject && !layoutLocks.subjectBoundingBox);
|
||||
|
||||
if (needsLayoutLockFallback) {
|
||||
const fallbackLocks = await resolveReplacementLayoutLockRegions({
|
||||
referenceImageUrl: params.referenceImageUrl,
|
||||
referenceBuffer: await getReferenceBuffer(),
|
||||
});
|
||||
layoutLocks = {
|
||||
subjectBoundingBox:
|
||||
layoutLocks.subjectBoundingBox || fallbackLocks.subjectBoundingBox,
|
||||
textBoundingBoxes:
|
||||
layoutLocks.textBoundingBoxes.length > 0
|
||||
? layoutLocks.textBoundingBoxes
|
||||
: fallbackLocks.textBoundingBoxes,
|
||||
analyzed: layoutLocks.analyzed || fallbackLocks.analyzed,
|
||||
};
|
||||
}
|
||||
|
||||
const shouldUseSafeReferenceFallback = Boolean(
|
||||
(params.preserveReferenceOutsideSubject && !layoutLocks.subjectBoundingBox) ||
|
||||
(params.preserveReferenceText && !layoutLocks.analyzed),
|
||||
);
|
||||
normalizedBuffer = shouldUseSafeReferenceFallback
|
||||
? await sharp(await getReferenceBuffer(), { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(targetWidth, targetHeight, { fit: "fill" })
|
||||
.png()
|
||||
.toBuffer()
|
||||
: await compositeReplacementWithReferenceLocks({
|
||||
generatedBuffer: normalizedBuffer,
|
||||
referenceBuffer: await getReferenceBuffer(),
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
preserveReferenceOutsideSubject: Boolean(
|
||||
params.preserveReferenceOutsideSubject && layoutLocks.subjectBoundingBox,
|
||||
),
|
||||
subjectBoundingBox: layoutLocks.subjectBoundingBox,
|
||||
textBoundingBoxes: params.preserveReferenceText
|
||||
? layoutLocks.textBoundingBoxes
|
||||
: [],
|
||||
});
|
||||
if (shouldUseSafeReferenceFallback) {
|
||||
chatApiLogger.warn(
|
||||
"[Chat API] Replacement layout lock analysis unavailable; preserved the reference image instead of returning a layout-damaged result",
|
||||
{
|
||||
preserveReferenceOutsideSubject: params.preserveReferenceOutsideSubject,
|
||||
preserveReferenceText: params.preserveReferenceText,
|
||||
hasSubjectBoundingBox: Boolean(layoutLocks.subjectBoundingBox),
|
||||
layoutLockAnalyzed: layoutLocks.analyzed,
|
||||
},
|
||||
);
|
||||
}
|
||||
chatApiLogger.info("[Chat API] Replacement reference layout locks applied:", {
|
||||
preserveReferenceOutsideSubject: Boolean(
|
||||
params.preserveReferenceOutsideSubject && layoutLocks.subjectBoundingBox,
|
||||
),
|
||||
preservedTextRegionCount: params.preserveReferenceText
|
||||
? layoutLocks.textBoundingBoxes.length
|
||||
: 0,
|
||||
usedFallbackAnalysis: needsLayoutLockFallback,
|
||||
usedSafeReferenceFallback: shouldUseSafeReferenceFallback,
|
||||
});
|
||||
}
|
||||
const thumbnailBuffer = await createAgentImageThumbnailBuffer(normalizedBuffer);
|
||||
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@ -399,6 +504,12 @@ export type ReplacementScenePlan = {
|
||||
generationPrompt: string;
|
||||
selectedProductImageIndexes: number[];
|
||||
productSelectionReason: string;
|
||||
productIdentitySummary?: string;
|
||||
productStructureSummary?: string;
|
||||
productStructuralRisks?: string;
|
||||
subjectBoundingBox?: NormalizedReplacementBox | null;
|
||||
textBoundingBoxes?: NormalizedReplacementBox[];
|
||||
layoutLockAnalyzed?: boolean;
|
||||
analysisModelName?: string | null;
|
||||
analysisSource: "local_moonshot" | "configured_vision" | "heuristic";
|
||||
};
|
||||
@ -439,6 +550,188 @@ export type ReplacementScenePlanningRuntime =
|
||||
|
||||
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
|
||||
|
||||
const replacementProductIdentityProfileCache = new Map<
|
||||
string,
|
||||
{
|
||||
expiresAt: number;
|
||||
promise: Promise<ReplacementProductIdentityProfile | null>;
|
||||
}
|
||||
>();
|
||||
|
||||
async function resolveReplacementProductIdentityProfile(params: {
|
||||
productImageUrls: string[];
|
||||
planningRuntime: Exclude<ReplacementScenePlanningRuntime, null>;
|
||||
}) {
|
||||
const productImageUrls = Array.from(
|
||||
new Set(
|
||||
params.productImageUrls
|
||||
.map((url) => String(url || "").trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url)),
|
||||
),
|
||||
).slice(0, 8);
|
||||
if (productImageUrls.length === 0) return null;
|
||||
|
||||
const cacheKey = [
|
||||
params.planningRuntime.kind,
|
||||
params.planningRuntime.modelName,
|
||||
...productImageUrls,
|
||||
].join("|");
|
||||
const now = Date.now();
|
||||
const cached = replacementProductIdentityProfileCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
if (replacementProductIdentityProfileCache.size >= 100) {
|
||||
const oldestKey = replacementProductIdentityProfileCache.keys().next().value;
|
||||
if (oldestKey) replacementProductIdentityProfileCache.delete(oldestKey);
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const { inlineDataUrls } = await inlineVisionImages(productImageUrls, {
|
||||
maxImages: productImageUrls.length,
|
||||
maxSide: 800,
|
||||
timeoutMs: 12_000,
|
||||
retries: 2,
|
||||
loggerPrefix: "[Chat API] Replacement product identity",
|
||||
trimTransparentPadding: true,
|
||||
});
|
||||
const effectiveImages =
|
||||
inlineDataUrls.length === productImageUrls.length
|
||||
? inlineDataUrls
|
||||
: productImageUrls;
|
||||
const { text } = await generateText({
|
||||
model: params.planningRuntime.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildReplacementProductIdentityAnalysisPrompt(
|
||||
productImageUrls.length,
|
||||
),
|
||||
},
|
||||
...effectiveImages.map((image) => ({
|
||||
type: "image" as const,
|
||||
image,
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
system:
|
||||
"你是高精度商品结构分析器。多张图片属于同一商品;请跨视角还原唯一结构身份,并明确每个视角最清楚的构件。",
|
||||
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
|
||||
maxTokens: 1800,
|
||||
abortSignal: AbortSignal.timeout(
|
||||
params.planningRuntime.kind === "local_moonshot" ? 90_000 : 60_000,
|
||||
),
|
||||
} as any);
|
||||
const profile = parseReplacementProductIdentityProfile(
|
||||
text,
|
||||
productImageUrls.length,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new Error("商品结构画像缺少必需字段");
|
||||
}
|
||||
return profile;
|
||||
} catch (error) {
|
||||
chatApiLogger.warn("[Chat API] Replacement product identity analysis failed:", {
|
||||
productImageCount: productImageUrls.length,
|
||||
modelName: params.planningRuntime.modelName,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
replacementProductIdentityProfileCache.set(cacheKey, {
|
||||
expiresAt: now + 30 * 60 * 1000,
|
||||
promise,
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
const replacementLayoutLockCache = new Map<
|
||||
string,
|
||||
{
|
||||
expiresAt: number;
|
||||
promise: Promise<ReturnType<typeof parseReplacementLayoutLockRegions>>;
|
||||
}
|
||||
>();
|
||||
|
||||
async function resolveReplacementLayoutLockRegions(params: {
|
||||
referenceImageUrl: string;
|
||||
referenceBuffer: Buffer;
|
||||
}) {
|
||||
const now = Date.now();
|
||||
const cached = replacementLayoutLockCache.get(params.referenceImageUrl);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
if (replacementLayoutLockCache.size >= 200) {
|
||||
const oldestKey = replacementLayoutLockCache.keys().next().value;
|
||||
if (oldestKey) {
|
||||
replacementLayoutLockCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const sharp = (await import("sharp")).default;
|
||||
const inlineBuffer = await sharp(params.referenceBuffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(1600, 1600, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.flatten({ background: "#ffffff" })
|
||||
.jpeg({ quality: 88 })
|
||||
.toBuffer();
|
||||
const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl");
|
||||
const result = await callQwen3Vl({
|
||||
imageUrl: `data:image/jpeg;base64,${inlineBuffer.toString("base64")}`,
|
||||
prompt: [
|
||||
"你是电商详情图的布局区域定位器。",
|
||||
"只分析当前图像,返回严格 JSON,不要解释。",
|
||||
"所有坐标使用 0到1000 的归一化坐标,格式必须是 [x1,y1,x2,y2],绝对不是 x/y/宽/高。",
|
||||
"subjectBoundingBox:只框出图中当前旧商品主体的可视外轮廓区域,包含完整商品和它的自然阴影,不要包含文字;没有商品则为 null。",
|
||||
"textBoundingBoxes:框出所有可见文案块,包括中英文标题、卖点、参数、标签、数字、品牌字样、按钮文字和紧邻的图标;没有则返回空数组。",
|
||||
'{"subjectBoundingBox":[0,0,1000,1000],"textBoundingBoxes":[[0,0,1000,1000]]}',
|
||||
].join("\n"),
|
||||
temperature: 0,
|
||||
maxTokens: 1600,
|
||||
timeoutMs: 60_000,
|
||||
retries: 1,
|
||||
});
|
||||
const parsedLocks = parseReplacementLayoutLockRegions(result.text);
|
||||
if (!parsedLocks.analyzed) {
|
||||
throw new Error("布局区域定位结果缺少必需字段");
|
||||
}
|
||||
return parsedLocks;
|
||||
} catch (error) {
|
||||
replacementLayoutLockCache.delete(params.referenceImageUrl);
|
||||
chatApiLogger.warn("[Chat API] Replacement layout lock fallback analysis failed:", {
|
||||
referenceImageUrl: params.referenceImageUrl,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
return {
|
||||
subjectBoundingBox: null,
|
||||
textBoundingBoxes: [],
|
||||
analyzed: false,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
replacementLayoutLockCache.set(params.referenceImageUrl, {
|
||||
expiresAt: now + 30 * 60 * 1000,
|
||||
promise,
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function normalizeDetailReplacementStrategy(
|
||||
value: unknown,
|
||||
): AgentDetailReplacementStrategy {
|
||||
@ -1441,7 +1734,27 @@ export function parseReplacementScenePlan(
|
||||
visualProfile: params.visualProfile,
|
||||
});
|
||||
|
||||
const productImageSelection = buildDefaultProductImageSelection(params.productImageCount);
|
||||
const defaultProductImageSelection = buildDefaultProductImageSelection(
|
||||
params.productImageCount,
|
||||
);
|
||||
const selectedProductImageIndexes = normalizeReplacementProductImageIndexes(
|
||||
parsed.selectedProductImageIndexes ??
|
||||
parsed.selectedProductIndexes ??
|
||||
parsed.productImageIndexes,
|
||||
params.productImageCount,
|
||||
);
|
||||
const productImageSelection =
|
||||
selectedProductImageIndexes.length > 0
|
||||
? {
|
||||
selectedProductImageIndexes,
|
||||
productSelectionReason:
|
||||
truncateReplacementAnalysisText(
|
||||
parsed.productSelectionReason ?? parsed.selectionReason,
|
||||
240,
|
||||
) || "按当前详情底板机位选择最匹配的产品视角。",
|
||||
}
|
||||
: defaultProductImageSelection;
|
||||
const layoutLocks = parseReplacementLayoutLockRegions(parsed);
|
||||
let scenePlan: ReplacementScenePlan = {
|
||||
pageKind,
|
||||
generationRoute,
|
||||
@ -1486,6 +1799,9 @@ export function parseReplacementScenePlan(
|
||||
generationPrompt,
|
||||
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
|
||||
productSelectionReason: productImageSelection.productSelectionReason,
|
||||
subjectBoundingBox: layoutLocks.subjectBoundingBox,
|
||||
textBoundingBoxes: layoutLocks.textBoundingBoxes,
|
||||
layoutLockAnalyzed: layoutLocks.analyzed,
|
||||
analysisModelName: null,
|
||||
analysisSource: "heuristic",
|
||||
};
|
||||
@ -1844,6 +2160,27 @@ export async function resolveDirectReplacementModelRef(params: {
|
||||
finalModelId?: string | null;
|
||||
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
||||
}) {
|
||||
const models = params.models || (await getEnabledAiModelsWithProvider(false));
|
||||
const imageModels = models.filter(
|
||||
({ model, provider }) =>
|
||||
model.supportsImageGen &&
|
||||
!model.isSubModel &&
|
||||
!isVideoProviderType(provider?.type, model),
|
||||
);
|
||||
const configuredModelSetting = await getSystemSetting({
|
||||
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const configuredModelId = normalizeDetailReplacementImageModelConfig(
|
||||
configuredModelSetting?.value,
|
||||
).modelId;
|
||||
const configuredModel = imageModels.find(
|
||||
({ model }) =>
|
||||
model.id === configuredModelId || model.modelId === configuredModelId,
|
||||
);
|
||||
if (configuredModel) {
|
||||
return configuredModel.model.id;
|
||||
}
|
||||
|
||||
const explicitModel = String(params.selectedChatModel || "").trim();
|
||||
if (explicitModel && explicitModel !== "auto") {
|
||||
return explicitModel;
|
||||
@ -1853,13 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
|
||||
return routedModel;
|
||||
}
|
||||
|
||||
const models = params.models || (await getEnabledAiModelsWithProvider(false));
|
||||
const imageModels = models.filter(
|
||||
({ model, provider }) =>
|
||||
model.supportsImageGen &&
|
||||
!model.isSubModel &&
|
||||
!isVideoProviderType(provider?.type, model),
|
||||
);
|
||||
const preferred =
|
||||
imageModels.find(({ model }) => model.isDefault) ||
|
||||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
|
||||
@ -2121,6 +2451,16 @@ export async function analyzeReplacementReferenceImage(params: {
|
||||
export async function resolveReplacementScenePlanningRuntime(params: {
|
||||
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
||||
}): Promise<ReplacementScenePlanningRuntime> {
|
||||
try {
|
||||
const dedicatedVisionRuntime = await resolveDetailReplacementVisionRuntime();
|
||||
if (dedicatedVisionRuntime) return dedicatedVisionRuntime;
|
||||
} catch (error) {
|
||||
chatApiLogger.warn(
|
||||
"[Chat API] Failed to resolve dedicated detail replacement vision model:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const siteSettings = await getSiteSettings().catch(() => null);
|
||||
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
|
||||
@ -2191,6 +2531,13 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
const referenceVisualProfile = isDetailReplacement
|
||||
? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl)
|
||||
: null;
|
||||
const productIdentityProfile =
|
||||
params.planningRuntime && params.productImageUrls.length > 0
|
||||
? await resolveReplacementProductIdentityProfile({
|
||||
productImageUrls: params.productImageUrls,
|
||||
planningRuntime: params.planningRuntime,
|
||||
})
|
||||
: null;
|
||||
if (params.planningRuntime) {
|
||||
try {
|
||||
const replacementScenePlanningTimeoutMs =
|
||||
@ -2221,7 +2568,10 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
|
||||
: "你是电商详情替换流程中的“场景理解规划器”。",
|
||||
`当前要分析的参考图:${params.referenceLabel}`,
|
||||
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
|
||||
"当前视觉输入只包含这一张详情参考图;产品图已经单独做过跨视角结构分析,请根据下面的产品结构画像选择最匹配当前机位的产品视角,不要臆测产品结构。",
|
||||
productIdentityProfile
|
||||
? `【产品结构画像】\n${formatReplacementProductIdentityProfile(productIdentityProfile)}`
|
||||
: "产品结构画像暂不可用:仍需按当前参考图机位选择产品图,但不得把旧产品的腿型、支架或构件拓扑当成新产品结构。",
|
||||
referenceVisualProfile
|
||||
? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, topLight=${referenceVisualProfile.topLightLowSaturationPixelRatio.toFixed(2)}, borderLight=${referenceVisualProfile.borderLightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}, likelyWhiteScene=${referenceVisualProfile.likelyWhiteScene ? "true" : "false"}。${referenceVisualProfile.reason}`
|
||||
: "",
|
||||
@ -2231,7 +2581,7 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
!referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene
|
||||
? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。"
|
||||
: "",
|
||||
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
|
||||
"产品图和模特图会在后续生图阶段单独提供给生图模型。你必须依据产品结构画像输出 selectedProductImageIndexes,选择最多两张最能匹配当前参考图机位并共同说明完整结构的产品图;不得固定选择前两张,也不要要求强行使用全部产品图。",
|
||||
params.modelImageUrl && !isProductOnlyDetailReplacement
|
||||
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
|
||||
: "本轮没有模特图,useModelImage 必须为 false。",
|
||||
@ -2278,12 +2628,18 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
isDetailReplacement
|
||||
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
|
||||
: "",
|
||||
isDetailReplacement
|
||||
? "5a. 必须输出 subjectBoundingBox 和 textBoundingBoxes。所有坐标为 0到1000 归一化坐标 [x1,y1,x2,y2],不是 x/y/宽/高。subjectBoundingBox 紧密框住旧商品主体及自然阴影,不含文字,无商品时为 null;textBoundingBoxes 覆盖所有中英文文案、标题、卖点、参数、标签、数字、品牌字样、按钮文字及紧邻图标,无文字时为 []。"
|
||||
: "",
|
||||
isDetailReplacement
|
||||
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
|
||||
: "",
|
||||
isDetailReplacement
|
||||
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
|
||||
: "",
|
||||
isDetailReplacement
|
||||
? "7a. 输出 selectedProductImageIndexes:根据当前详情图机位,从产品结构画像的图片编号中选择最多两张最相关的产品图;第一张应最接近当前机位,第二张补足腿、支架、横梁、侧框或其他容易变形的结构。输出 productSelectionReason 说明选择依据。"
|
||||
: "",
|
||||
isProductOnlyDetailReplacement
|
||||
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
|
||||
: isDetailReplacement
|
||||
@ -2309,7 +2665,7 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
: "",
|
||||
"只返回严格 JSON,不要任何额外解释。",
|
||||
`JSON 格式:
|
||||
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","pageKind":"prompt_pack_scene|white_scene|white_layout|copy_locked|people_scene|action_poster|dimension_spec|material_detail|unknown","generationRoute":"${isDetailReplacement ? "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked" : "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough"}","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`,
|
||||
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","pageKind":"prompt_pack_scene|white_scene|white_layout|copy_locked|people_scene|action_poster|dimension_spec|material_detail|unknown","generationRoute":"${isDetailReplacement ? "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked" : "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough"}","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"subjectBoundingBox":[0,0,1000,1000],"textBoundingBoxes":[[0,0,1000,1000]],"selectedProductImageIndexes":[1,2],"productSelectionReason":"不超过80字","routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
@ -2334,7 +2690,7 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
system:
|
||||
"你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。",
|
||||
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
|
||||
maxTokens: 1200,
|
||||
maxTokens: 1600,
|
||||
abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs),
|
||||
} as any);
|
||||
|
||||
@ -2368,6 +2724,9 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
: "configured_vision",
|
||||
scenePlan: {
|
||||
...normalizedPlan.scenePlan,
|
||||
productIdentitySummary: productIdentityProfile?.identitySummary || "",
|
||||
productStructureSummary: productIdentityProfile?.structureSummary || "",
|
||||
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
|
||||
analysisModelName: params.planningRuntime.modelName,
|
||||
analysisSource:
|
||||
params.planningRuntime.kind === "local_moonshot"
|
||||
@ -2413,7 +2772,12 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
);
|
||||
return {
|
||||
...normalizedFallbackPlan.decision,
|
||||
scenePlan: normalizedFallbackPlan.scenePlan,
|
||||
scenePlan: {
|
||||
...normalizedFallbackPlan.scenePlan,
|
||||
productIdentitySummary: productIdentityProfile?.identitySummary || "",
|
||||
productStructureSummary: productIdentityProfile?.structureSummary || "",
|
||||
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -2537,11 +2901,17 @@ export function buildAgentReplacementPrompt(params: {
|
||||
]
|
||||
: [];
|
||||
const productAngleLockLines = [
|
||||
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。",
|
||||
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。",
|
||||
"【产品角度锁定规则】最终商品/主体的拍摄角度、朝向、占比、重心和透视消失方向以最后一张参考图为准;商品身份、外轮廓、腿型、支架拓扑、构件数量和连接方式以前面的产品输入图为准。",
|
||||
"必须对齐最后一张参考图的正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向和可见侧面数量,但不得照搬旧商品的腿部、横梁、侧框、把手、转轴或内部构件设计。",
|
||||
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
|
||||
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
|
||||
];
|
||||
const productStructureLockLines = buildReplacementProductStructureLockLines({
|
||||
productImageCount: params.productImageCount,
|
||||
identitySummary: params.scenePlan?.productIdentitySummary,
|
||||
structureSummary: params.scenePlan?.productStructureSummary,
|
||||
structuralRisks: params.scenePlan?.productStructuralRisks,
|
||||
});
|
||||
const normalizedBasePrompt = String(params.basePrompt || "").trim();
|
||||
const promptPackInstructionLines =
|
||||
params.modeType === "detail_replacement" &&
|
||||
@ -2641,6 +3011,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
...promptPackInstructionLines,
|
||||
...scenePlanLines,
|
||||
...marketplaceBrandRemovalLines,
|
||||
...productStructureLockLines,
|
||||
...productAngleLockLines,
|
||||
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
|
||||
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
|
||||
@ -2670,6 +3041,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
...promptPackInstructionLines,
|
||||
...scenePlanLines,
|
||||
...marketplaceBrandRemovalLines,
|
||||
...productStructureLockLines,
|
||||
...productAngleLockLines,
|
||||
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
|
||||
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
|
||||
@ -2785,6 +3157,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
"【图片顺序规则】",
|
||||
...imageOrderLines,
|
||||
...scenePlanLines,
|
||||
...productStructureLockLines,
|
||||
...productAngleLockLines,
|
||||
params.pairedProductIndex !== undefined
|
||||
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRequestErrorMessage } from "@/lib/request-error";
|
||||
import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const chatApiLogger = createLogger("chat.errors");
|
||||
@ -20,6 +20,14 @@ export function getChatStreamErrorMessage(error: unknown): string {
|
||||
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
|
||||
const normalized = rawMessage.toLowerCase();
|
||||
|
||||
if (isProviderBalanceError(error)) {
|
||||
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
||||
}
|
||||
|
||||
if (isRateLimitError(error)) {
|
||||
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("token quota is not enough") ||
|
||||
normalized.includes("pre_consume_token_quota_failed") ||
|
||||
@ -47,6 +55,16 @@ function resolveChatApiFailure(error: unknown) {
|
||||
const detailMessage = getRequestErrorMessage(error);
|
||||
const normalized = String(detailMessage || "").toLowerCase();
|
||||
|
||||
if (isProviderBalanceError(error)) {
|
||||
return {
|
||||
code: "server_error:chat",
|
||||
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
|
||||
status: 503,
|
||||
category: "provider_balance",
|
||||
detailMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("payload too large") ||
|
||||
normalized.includes("request entity too large") ||
|
||||
|
||||
@ -4,6 +4,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { convertMessagesWithImages } from "./image-media";
|
||||
import { isProviderBalanceError } from "@/lib/request-error";
|
||||
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||
|
||||
const chatApiLogger = createLogger("chat.imageFailover");
|
||||
|
||||
@ -18,6 +20,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
@ -56,7 +59,21 @@ export function resolveImageModelRuntimeId(
|
||||
export function normalizeFailoverErrorMessage(error: unknown): string {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message || String(error);
|
||||
if (error instanceof Error) {
|
||||
const record = error as Error & {
|
||||
status?: unknown;
|
||||
statusCode?: unknown;
|
||||
httpStatus?: unknown;
|
||||
response?: { status?: unknown };
|
||||
};
|
||||
const status =
|
||||
record.status ??
|
||||
record.statusCode ??
|
||||
record.httpStatus ??
|
||||
record.response?.status;
|
||||
const message = error.message || String(error);
|
||||
return status ? `[HTTP ${String(status)}] ${message}` : message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
@ -67,6 +84,7 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
|
||||
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
isProviderBalanceError(errorMessage) ||
|
||||
msg.includes("model not found") ||
|
||||
msg.includes("model_not_found") ||
|
||||
msg.includes("no available channel for model") ||
|
||||
@ -75,6 +93,13 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
|
||||
msg.includes("api key is missing") ||
|
||||
msg.includes("invalid api key") ||
|
||||
msg.includes("unauthorized") ||
|
||||
/(?:^|\D)401(?:\D|$)/.test(msg) ||
|
||||
msg === "forbidden" ||
|
||||
msg.includes("access forbidden") ||
|
||||
msg.includes("access denied") ||
|
||||
msg.includes("permission denied") ||
|
||||
/(?:^|\D)403(?:\D|$)/.test(msg) ||
|
||||
msg.includes("vip98k task failed") ||
|
||||
msg.includes("cannot convert argument to a bytestring") ||
|
||||
msg.includes("model maintenance") ||
|
||||
msg.includes("maintenance") ||
|
||||
@ -249,6 +274,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "vip98k":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
@ -300,9 +326,9 @@ export function buildImageFallbackCandidates(
|
||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||
return false;
|
||||
}
|
||||
if (isImageFallbackModelCoolingDown(item.model.id)) {
|
||||
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@ -462,9 +488,9 @@ export function getImageCrossProviderCandidates(
|
||||
}
|
||||
if (m.model.id === options.currentModelId) return false;
|
||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||
if (isImageFallbackModelCoolingDown(m.model.id)) {
|
||||
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@ -527,6 +527,7 @@ export async function convertMessagesWithImages(
|
||||
normalizedProviderType === "bananaimage" ||
|
||||
normalizedProviderType === "visionary" ||
|
||||
normalizedProviderType === "atlascloud" ||
|
||||
normalizedProviderType === "vip98k" ||
|
||||
normalizedProviderType === "stepfun-image" ||
|
||||
normalizedProviderType === "midjourney" ||
|
||||
normalizedProviderType === "runway" ||
|
||||
|
||||
@ -191,6 +191,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
||||
normalized === "bananaimage" ||
|
||||
normalized === "visionary" ||
|
||||
normalized === "atlascloud" ||
|
||||
normalized === "vip98k" ||
|
||||
normalized === "stepfun-image" ||
|
||||
normalized === "midjourney" ||
|
||||
normalized === "runway" ||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
||||
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { geolocation } from "@vercel/functions";
|
||||
import {
|
||||
@ -179,6 +181,7 @@ import { ChatSDKError } from "@/lib/errors";
|
||||
import {
|
||||
getFriendlyRateLimitMessage,
|
||||
getRequestErrorMessage,
|
||||
getUserFacingImageGenerationErrorMessage,
|
||||
isRateLimitError,
|
||||
} from "@/lib/request-error";
|
||||
import {
|
||||
@ -193,10 +196,12 @@ import {
|
||||
} from "@/lib/ai/runtime/agent-media-markdown";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
||||
import { withAuthenticationFailover } from "@/lib/ai/runtime/authentication-failover";
|
||||
import {
|
||||
recordAiModelHealthFailure,
|
||||
recordAiModelHealthSuccess,
|
||||
shouldFailoverAiModelError,
|
||||
shouldFailoverVip98kRequestRejection,
|
||||
} from "@/lib/ai/runtime/model-health";
|
||||
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
|
||||
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
|
||||
@ -363,6 +368,10 @@ import {
|
||||
resolveReplacementReferenceGeometry,
|
||||
resolveReplacementScenePlanningRuntime,
|
||||
} from "./_lib/detail-replacement";
|
||||
import {
|
||||
selectDetailReplacementGenerationInputs,
|
||||
shouldIncludeDetailReplacementModelImage,
|
||||
} from "@/lib/ai/detail-replacement-inputs";
|
||||
import {
|
||||
normalizeAgentReportForDisplay,
|
||||
sanitizeAgentOutputSettingsSection,
|
||||
@ -417,8 +426,6 @@ const chatApiLogger = createLogger("chat.route");
|
||||
|
||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
||||
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
||||
|
||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||
const normalizedMarkdown = String(markdown || "").trim();
|
||||
@ -435,41 +442,13 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
|
||||
|
||||
function getPublicImageGenerationErrorText(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
if (!message || INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message)) {
|
||||
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
||||
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
||||
return "图片生成失败,请稍后重试";
|
||||
}
|
||||
return message;
|
||||
return publicMessage;
|
||||
}
|
||||
|
||||
function replacementScenePlanShouldIncludeModelImage(scenePlan: any) {
|
||||
if (!scenePlan) return false;
|
||||
if (scenePlan.useModelImage === true) return true;
|
||||
if (
|
||||
scenePlan.peoplePolicy &&
|
||||
scenePlan.peoplePolicy !== "none"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (scenePlan.generationRoute === "people_relation_locked") return true;
|
||||
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = [
|
||||
scenePlan.modelImageReason,
|
||||
scenePlan.routeReason,
|
||||
scenePlan.sceneSummary,
|
||||
scenePlan.cameraSummary,
|
||||
scenePlan.placementSummary,
|
||||
scenePlan.preserveSummary,
|
||||
scenePlan.styleSummary,
|
||||
scenePlan.avoidSummary,
|
||||
scenePlan.generationPrompt,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text);
|
||||
}
|
||||
|
||||
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||
1,
|
||||
@ -928,6 +907,15 @@ export async function POST(request: Request) {
|
||||
const json = await request.json();
|
||||
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
|
||||
requestBody = postRequestBodySchema.parse(json);
|
||||
// Agent/Auto Agent has one execution owner: the isolated native ZCode entry.
|
||||
// This also upgrades existing browser tabs without routing Claw here.
|
||||
if (requestBody.composerMode === "agent" && requestBody.contextPayload?.source === "canvas" && requestBody.contextPayload.projectId) {
|
||||
const { POST: nativeAgentPost } = await import("@/app/api/canvas/agent-chat/route");
|
||||
return nativeAgentPost(new Request(new URL("/api/canvas/agent-chat", request.url), {
|
||||
method: "POST", headers: request.headers, body: JSON.stringify(json),
|
||||
}));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] Schema validation failed:", error);
|
||||
return new ChatSDKError("bad_request:api").toResponse();
|
||||
@ -1222,6 +1210,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunAgentConversation && preferredAgentModelId && !shouldKeepSelectedModel) {
|
||||
const preferred = await getAiModelById({ id: preferredAgentModelId }).catch(() => null);
|
||||
if (!preferred?.isEnabled || preferred.healthStatus === "auto_disabled") {
|
||||
preferredAgentModelId = undefined;
|
||||
configuredAgentConversationModelId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunAgentConversation && !preferredAgentModelId) {
|
||||
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
|
||||
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
|
||||
@ -1342,6 +1338,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const allowAgentVideoGeneration =
|
||||
(contextPayload?.source === "canvas" && inferVideoTaskRequirements(requestTextContent).expectsVideo) ||
|
||||
Boolean(requestBody.videoParams) ||
|
||||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
|
||||
let smartAutoWorkflowDecision:
|
||||
@ -5723,12 +5720,15 @@ export async function POST(request: Request) {
|
||||
sourceImageUrls: string[];
|
||||
streamLabel: string;
|
||||
promptForPersistence?: string | null;
|
||||
isFallback?: boolean;
|
||||
}) => {
|
||||
const persistedImageSize =
|
||||
String(params.imageSizeLabel || "").trim() || resolvedImageSize;
|
||||
const assistantText = prependImageModelBadge(
|
||||
[
|
||||
`<!-- AGENT_REPLACEMENT_CARD_ID:${String(params.reference.id || "").trim()} -->`,
|
||||
`<!-- AGENT_REPLACEMENT_RUN_ID:${message.id} -->`,
|
||||
`<!-- AGENT_REPLACEMENT_STATUS:${params.isFallback ? "failed" : "completed"} -->`,
|
||||
`### ${params.reference.label}`,
|
||||
String(params.bodyText || "").trim(),
|
||||
buildAgentToolImagesMarkdown([
|
||||
@ -5745,44 +5745,55 @@ export async function POST(request: Request) {
|
||||
);
|
||||
const assistantMessageId = generateUUID();
|
||||
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: assistantMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: assistantText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: assistantMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: assistantText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await saveGeneratedImages(
|
||||
assistantText,
|
||||
session.user.id,
|
||||
id,
|
||||
assistantMessageId,
|
||||
params.promptForPersistence || baseReplacementPrompt,
|
||||
resolvedModelRef,
|
||||
{
|
||||
sourceAssetIds,
|
||||
sourceImageUrls: params.sourceImageUrls,
|
||||
relationType: "edited_from",
|
||||
sourceType: "detail_replacement",
|
||||
projectId: contextPayload?.projectId || null,
|
||||
userPrompt: requestTextContent,
|
||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||
requestedImageCount: 1,
|
||||
aspectRatio: resolvedAspectRatio,
|
||||
imageSize: persistedImageSize,
|
||||
},
|
||||
);
|
||||
if (!params.isFallback) {
|
||||
await saveGeneratedImages(
|
||||
assistantText,
|
||||
session.user.id,
|
||||
id,
|
||||
assistantMessageId,
|
||||
params.promptForPersistence || baseReplacementPrompt,
|
||||
resolvedModelRef,
|
||||
{
|
||||
sourceAssetIds,
|
||||
sourceImageUrls: params.sourceImageUrls,
|
||||
relationType: "edited_from",
|
||||
sourceType: "detail_replacement",
|
||||
projectId: contextPayload?.projectId || null,
|
||||
userPrompt: requestTextContent,
|
||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||
requestedImageCount: 1,
|
||||
aspectRatio: resolvedAspectRatio,
|
||||
imageSize: persistedImageSize,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (persistError) {
|
||||
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
||||
chatId: id,
|
||||
referenceId: params.reference.id,
|
||||
referenceLabel: params.reference.label,
|
||||
error: persistError,
|
||||
});
|
||||
}
|
||||
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
@ -5810,6 +5821,7 @@ export async function POST(request: Request) {
|
||||
Array.from(replacementReferences.entries()),
|
||||
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
|
||||
async ([referenceIndex, reference]) => {
|
||||
const itemStartedAt = Date.now();
|
||||
if (reference.passthrough === true) {
|
||||
const referenceSourceImageUrls = Array.from(
|
||||
new Set([
|
||||
@ -5830,9 +5842,29 @@ export async function POST(request: Request) {
|
||||
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
|
||||
});
|
||||
successCount += 1;
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement item passthrough completed:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackSourceImageUrls = Array.from(
|
||||
new Set([
|
||||
...productInputImageUrls,
|
||||
...(modelInputImageUrl ? [modelInputImageUrl] : []),
|
||||
...(reference.rerunReferenceImages || [])
|
||||
.map((image) => String(image.url || "").trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
.slice(0, 6),
|
||||
reference.imageUrl,
|
||||
]),
|
||||
);
|
||||
|
||||
try {
|
||||
const planningStartedAt = Date.now();
|
||||
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
|
||||
modeType: "detail_replacement",
|
||||
replacementStrategy: detailReplacementStrategy,
|
||||
@ -5847,10 +5879,12 @@ export async function POST(request: Request) {
|
||||
planningRuntime: replacementScenePlanningRuntime,
|
||||
models: replacementVisionModels,
|
||||
});
|
||||
const planningElapsedMs = Date.now() - planningStartedAt;
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
planningElapsedMs,
|
||||
shouldReplace: replacementExecutionPlan.shouldReplace,
|
||||
reason: replacementExecutionPlan.reason,
|
||||
detectionSource: replacementExecutionPlan.detectionSource,
|
||||
@ -5901,20 +5935,18 @@ export async function POST(request: Request) {
|
||||
const shouldIncludeModelImage =
|
||||
detailReplacementStrategy !== "product_only" &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
(shouldIncludeDetailReplacementModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
selectedProductContextImageUrls.length === 0);
|
||||
const sourceImageUrls = Array.from(
|
||||
new Set([
|
||||
...selectedProductContextImageUrls,
|
||||
...(shouldIncludeModelImage && modelInputImageUrl
|
||||
? [modelInputImageUrl]
|
||||
: []),
|
||||
...rerunReferenceImageUrls,
|
||||
reference.imageUrl,
|
||||
]),
|
||||
);
|
||||
const generationInputs = selectDetailReplacementGenerationInputs({
|
||||
productImageUrls: selectedProductContextImageUrls,
|
||||
modelImageUrl: modelInputImageUrl,
|
||||
includeModelImage: shouldIncludeModelImage,
|
||||
rerunReferenceImageUrls,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
});
|
||||
const sourceImageUrls = generationInputs.imageUrls;
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement image generation started:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
@ -5922,10 +5954,10 @@ export async function POST(request: Request) {
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
selectedProductImages: selectedProductContextImageUrls.length,
|
||||
selectedProductImages: generationInputs.productImageUrls.length,
|
||||
sourceProductImages: productInputImageUrls.length,
|
||||
includeModelImage: shouldIncludeModelImage,
|
||||
rerunReferenceImages: rerunReferenceImageUrls.length,
|
||||
includeModelImage: Boolean(generationInputs.modelImageUrl),
|
||||
rerunReferenceImages: generationInputs.rerunImageUrls.length,
|
||||
sourceImageCount: sourceImageUrls.length,
|
||||
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
|
||||
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
|
||||
@ -5940,10 +5972,10 @@ export async function POST(request: Request) {
|
||||
referenceLabel: reference.label,
|
||||
referenceIndex,
|
||||
totalReferences: replacementReferences.length,
|
||||
hasModelImage: shouldIncludeModelImage,
|
||||
productImageCount: selectedProductContextImageUrls.length,
|
||||
hasModelImage: Boolean(generationInputs.modelImageUrl),
|
||||
productImageCount: generationInputs.productImageUrls.length,
|
||||
originalProductImageCount: productInputImageUrls.length,
|
||||
rerunReferenceCount: rerunReferenceImageUrls.length,
|
||||
rerunReferenceCount: generationInputs.rerunImageUrls.length,
|
||||
templateReferenceCount: 0,
|
||||
scenePlan: replacementExecutionPlan.scenePlan,
|
||||
referenceGeometry,
|
||||
@ -5960,7 +5992,12 @@ export async function POST(request: Request) {
|
||||
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
||||
});
|
||||
|
||||
let generationStartedAt = 0;
|
||||
let generationElapsedMs: number | null = null;
|
||||
let normalizeStartedAt = 0;
|
||||
let normalizeElapsedMs: number | null = null;
|
||||
try {
|
||||
generationStartedAt = Date.now();
|
||||
const result = await generateCanvasImage({
|
||||
prompt: replacementPrompt,
|
||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||
@ -5970,6 +6007,10 @@ export async function POST(request: Request) {
|
||||
imageSize: resolvedImageSize,
|
||||
imageCount: 1,
|
||||
referenceImages: sourceImageUrls,
|
||||
promptExtend: false,
|
||||
providerImageParams: {
|
||||
inputImageRoleByUrl: generationInputs.inputImageRoleByUrl,
|
||||
},
|
||||
debugLabel: "agent-detail-replacement",
|
||||
debugMeta: {
|
||||
referenceLabel: reference.label,
|
||||
@ -6000,6 +6041,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
generationElapsedMs = Date.now() - generationStartedAt;
|
||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||
const firstImage = result.images[0];
|
||||
if (!firstImage) {
|
||||
@ -6009,12 +6051,16 @@ export async function POST(request: Request) {
|
||||
let finalImage = firstImage;
|
||||
let finalImageSizeLabel: string = resolvedImageSize;
|
||||
try {
|
||||
normalizeStartedAt = Date.now();
|
||||
const normalizedImage = await normalizeReplacementResultImage({
|
||||
generatedImageUrl: firstImage.originalUrl,
|
||||
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
referenceGeometry,
|
||||
scenePlan: replacementExecutionPlan.scenePlan,
|
||||
preserveReferenceOutsideSubject:
|
||||
detailReplacementStrategy === "product_only",
|
||||
preserveReferenceText: true,
|
||||
uploadPrefix: "agent-detail-replacement",
|
||||
});
|
||||
finalImage = {
|
||||
@ -6022,7 +6068,11 @@ export async function POST(request: Request) {
|
||||
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
||||
};
|
||||
finalImageSizeLabel = normalizedImage.imageSize;
|
||||
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||
} catch (normalizeError) {
|
||||
if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
|
||||
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||
}
|
||||
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
|
||||
referenceLabel: reference.label,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
@ -6040,6 +6090,22 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement image generation completed:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
modelDisplayName: resolvedModelDisplayName,
|
||||
resultImageSize: finalImageSizeLabel,
|
||||
sourceImageCount: sourceImageUrls.length,
|
||||
generationAspectRatio,
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
planningElapsedMs,
|
||||
generationElapsedMs,
|
||||
normalizeElapsedMs,
|
||||
});
|
||||
await emitReplacementResultMessage({
|
||||
reference,
|
||||
image: finalImage,
|
||||
@ -6051,9 +6117,16 @@ export async function POST(request: Request) {
|
||||
});
|
||||
successCount += 1;
|
||||
} catch (replacementItemError) {
|
||||
if (generationStartedAt > 0 && generationElapsedMs === null) {
|
||||
generationElapsedMs = Date.now() - generationStartedAt;
|
||||
}
|
||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
|
||||
referenceIndex,
|
||||
referenceLabel: reference.label,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
planningElapsedMs,
|
||||
generationElapsedMs,
|
||||
normalizeElapsedMs,
|
||||
error: replacementItemError,
|
||||
});
|
||||
await emitReplacementResultMessage({
|
||||
@ -6070,8 +6143,32 @@ export async function POST(request: Request) {
|
||||
modelDisplayName: null,
|
||||
sourceImageUrls: referenceSourceImageUrls,
|
||||
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||
isFallback: true,
|
||||
});
|
||||
}
|
||||
} catch (replacementPlanError) {
|
||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
||||
referenceIndex,
|
||||
referenceLabel: reference.label,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
error: replacementPlanError,
|
||||
});
|
||||
await emitReplacementResultMessage({
|
||||
reference,
|
||||
bodyText: `生成失败,当前先沿用参考图:${
|
||||
replacementPlanError instanceof Error
|
||||
? replacementPlanError.message
|
||||
: "请稍后重试"
|
||||
}`,
|
||||
image: {
|
||||
originalUrl: reference.imageUrl,
|
||||
thumbnailUrl: reference.imageUrl,
|
||||
},
|
||||
modelDisplayName: null,
|
||||
sourceImageUrls: fallbackSourceImageUrls,
|
||||
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||
isFallback: true,
|
||||
});
|
||||
successCount += 1;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
@ -6399,9 +6496,20 @@ export async function POST(request: Request) {
|
||||
...(selectedAgentPreset.defaultParams || {}),
|
||||
...(replacementSlot?.defaultParams || {}),
|
||||
} as Record<string, any>;
|
||||
const resolvedModelRef =
|
||||
String(selectedChatModel || "").trim() &&
|
||||
String(selectedChatModel || "").trim() !== "auto"
|
||||
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
|
||||
(error) => {
|
||||
chatApiLogger.warn("[Chat API] Failed to load configured vision models for replacement analysis:", error);
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
const resolvedModelRef = isDetailReplacementPreset
|
||||
? await resolveDirectReplacementModelRef({
|
||||
selectedChatModel,
|
||||
finalModelId: presetModelId,
|
||||
models: replacementVisionModels,
|
||||
})
|
||||
: String(selectedChatModel || "").trim() &&
|
||||
String(selectedChatModel || "").trim() !== "auto"
|
||||
? String(selectedChatModel || "").trim()
|
||||
: presetModelId;
|
||||
const configuredReplacementAspectRatio =
|
||||
@ -6423,12 +6531,6 @@ export async function POST(request: Request) {
|
||||
configuredAspectRatio: configuredReplacementAspectRatio,
|
||||
});
|
||||
}
|
||||
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
|
||||
(error) => {
|
||||
chatApiLogger.warn("[Chat API] Failed to load configured vision models for replacement analysis:", error);
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
const replacementScenePlanningRuntime =
|
||||
await resolveReplacementScenePlanningRuntime({
|
||||
models: replacementVisionModels,
|
||||
@ -6726,24 +6828,36 @@ export async function POST(request: Request) {
|
||||
!(isDetailReplacementPreset &&
|
||||
detailReplacementStrategy === "product_only") &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
(shouldIncludeDetailReplacementModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
(isDetailReplacementPreset &&
|
||||
selectedProductContextImageUrls.length === 0));
|
||||
const sourceImageUrls = Array.from(
|
||||
new Set(
|
||||
[
|
||||
...selectedProductContextImageUrls,
|
||||
...(shouldIncludeModelImage && modelInputImageUrl
|
||||
? [modelInputImageUrl]
|
||||
: []),
|
||||
...replacementTemplateReferenceImageUrls,
|
||||
...rerunReferenceImageUrls,
|
||||
reference.imageUrl,
|
||||
].filter(Boolean),
|
||||
),
|
||||
);
|
||||
const detailGenerationInputs = isDetailReplacementPreset
|
||||
? selectDetailReplacementGenerationInputs({
|
||||
productImageUrls: selectedProductContextImageUrls,
|
||||
modelImageUrl: modelInputImageUrl,
|
||||
includeModelImage: shouldIncludeModelImage,
|
||||
templateReferenceImageUrls: replacementTemplateReferenceImageUrls,
|
||||
rerunReferenceImageUrls,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
})
|
||||
: null;
|
||||
const sourceImageUrls = detailGenerationInputs
|
||||
? detailGenerationInputs.imageUrls
|
||||
: Array.from(
|
||||
new Set(
|
||||
[
|
||||
...selectedProductContextImageUrls,
|
||||
...(shouldIncludeModelImage && modelInputImageUrl
|
||||
? [modelInputImageUrl]
|
||||
: []),
|
||||
...replacementTemplateReferenceImageUrls,
|
||||
...rerunReferenceImageUrls,
|
||||
reference.imageUrl,
|
||||
].filter(Boolean),
|
||||
),
|
||||
);
|
||||
const replacementPrompt = buildAgentReplacementPrompt({
|
||||
basePrompt:
|
||||
baseReplacementPrompt ||
|
||||
@ -6760,11 +6874,19 @@ export async function POST(request: Request) {
|
||||
referenceLabel: reference.label,
|
||||
referenceIndex,
|
||||
totalReferences: replacementReferences.length,
|
||||
hasModelImage: shouldIncludeModelImage,
|
||||
productImageCount: selectedProductContextImageUrls.length,
|
||||
hasModelImage: detailGenerationInputs
|
||||
? Boolean(detailGenerationInputs.modelImageUrl)
|
||||
: shouldIncludeModelImage,
|
||||
productImageCount: detailGenerationInputs
|
||||
? detailGenerationInputs.productImageUrls.length
|
||||
: selectedProductContextImageUrls.length,
|
||||
originalProductImageCount: productInputImageUrls.length,
|
||||
rerunReferenceCount: rerunReferenceImageUrls.length,
|
||||
templateReferenceCount: replacementTemplateReferenceImageUrls.length,
|
||||
rerunReferenceCount: detailGenerationInputs
|
||||
? detailGenerationInputs.rerunImageUrls.length
|
||||
: rerunReferenceImageUrls.length,
|
||||
templateReferenceCount: detailGenerationInputs
|
||||
? detailGenerationInputs.templateImageUrls.length
|
||||
: replacementTemplateReferenceImageUrls.length,
|
||||
pairedProductIndex,
|
||||
scenePlan: replacementExecutionPlan.scenePlan,
|
||||
referenceGeometry,
|
||||
@ -6785,11 +6907,20 @@ export async function POST(request: Request) {
|
||||
sourceImageCount: sourceImageUrls.length,
|
||||
generationAspectRatio,
|
||||
imageOrder: {
|
||||
productImages: selectedProductContextImageUrls.length,
|
||||
productImages:
|
||||
detailGenerationInputs !== null
|
||||
? detailGenerationInputs.productImageUrls.length
|
||||
: selectedProductContextImageUrls.length,
|
||||
originalProductImages: productInputImageUrls.length,
|
||||
hasModelImage: shouldIncludeModelImage,
|
||||
templateReferenceImages: replacementTemplateReferenceImageUrls.length,
|
||||
rerunReferenceImages: rerunReferenceImageUrls.length,
|
||||
hasModelImage: detailGenerationInputs
|
||||
? Boolean(detailGenerationInputs.modelImageUrl)
|
||||
: shouldIncludeModelImage,
|
||||
templateReferenceImages: detailGenerationInputs
|
||||
? detailGenerationInputs.templateImageUrls.length
|
||||
: replacementTemplateReferenceImageUrls.length,
|
||||
rerunReferenceImages: detailGenerationInputs
|
||||
? detailGenerationInputs.rerunImageUrls.length
|
||||
: rerunReferenceImageUrls.length,
|
||||
finalReferenceImageIndex: sourceImageUrls.length,
|
||||
},
|
||||
});
|
||||
@ -6807,6 +6938,17 @@ export async function POST(request: Request) {
|
||||
imageSize: resolvedImageSize,
|
||||
imageCount: 1,
|
||||
referenceImages: sourceImageUrls,
|
||||
promptExtend: isDetailReplacementPreset
|
||||
? false
|
||||
: typeof mergedImageParams.promptExtend === "boolean"
|
||||
? mergedImageParams.promptExtend
|
||||
: undefined,
|
||||
providerImageParams: detailGenerationInputs
|
||||
? {
|
||||
inputImageRoleByUrl:
|
||||
detailGenerationInputs.inputImageRoleByUrl,
|
||||
}
|
||||
: undefined,
|
||||
debugLabel: `agent-${replacementSourceType}`,
|
||||
debugMeta: {
|
||||
referenceLabel: reference.label,
|
||||
@ -6853,6 +6995,10 @@ export async function POST(request: Request) {
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
referenceGeometry,
|
||||
scenePlan: replacementExecutionPlan.scenePlan,
|
||||
preserveReferenceOutsideSubject:
|
||||
isDetailReplacementPreset &&
|
||||
detailReplacementStrategy === "product_only",
|
||||
preserveReferenceText: isDetailReplacementPreset,
|
||||
uploadPrefix: isDetailReplacementPreset
|
||||
? "agent-detail-replacement"
|
||||
: "agent-sku-replacement",
|
||||
@ -8740,7 +8886,7 @@ export async function POST(request: Request) {
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
|
||||
let activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
|
||||
resolvedRuntimeModelLookupId,
|
||||
).catch(() => null);
|
||||
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
|
||||
@ -9132,6 +9278,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
"grsai-gpt-image": 8,
|
||||
runway: 9,
|
||||
@ -9526,6 +9673,7 @@ export async function POST(request: Request) {
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -9543,6 +9691,11 @@ export async function POST(request: Request) {
|
||||
alibabaModelConfig?.fallbackModelId2,
|
||||
].filter(Boolean) as string[];
|
||||
const allModels = await getEnabledAiModelsWithProvider();
|
||||
const currentModelEntry = resolveModelEntryByRuntimeModelId(
|
||||
allModels,
|
||||
actualModelId,
|
||||
finalModelId,
|
||||
);
|
||||
const fallbackCandidates = buildImageFallbackCandidates(allModels, {
|
||||
currentProviderId: alibabaModelConfig?.providerId,
|
||||
currentModelId: alibabaModelConfig?.id,
|
||||
@ -9754,6 +9907,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: (languageModel as any).provider === "atlascloud"
|
||||
? "atlascloud"
|
||||
: (languageModel as any).provider === "vip98k"
|
||||
? "vip98k"
|
||||
: (languageModel as any).provider === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: (languageModel as any).provider === "midjourney"
|
||||
@ -9786,6 +9941,7 @@ export async function POST(request: Request) {
|
||||
|
||||
let imageMarkdown = "";
|
||||
let usedModelId = actualModelId;
|
||||
const generationStartedAt = Date.now();
|
||||
|
||||
// 🆕 图片生成真正开始时发出标记(用于前端显示占位图)
|
||||
writeAssistantTextChunk(
|
||||
@ -9812,14 +9968,43 @@ export async function POST(request: Request) {
|
||||
} catch (primaryError) {
|
||||
chatApiLogger.error("[Chat API] Alibaba primary model failed:", primaryError);
|
||||
|
||||
if (!shouldFailoverAiModelError(primaryError)) {
|
||||
await recordAiModelHealthFailure({
|
||||
model: currentModelEntry?.model || alibabaModelConfig,
|
||||
provider: currentModelEntry?.provider,
|
||||
error: primaryError,
|
||||
context: "chat_image_primary",
|
||||
elapsedMs: Date.now() - generationStartedAt,
|
||||
metadata: {
|
||||
runtimeModelId: actualModelId,
|
||||
finalModelId,
|
||||
providerType: (languageModel as any).provider,
|
||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||
imageSize: effectiveImageGenerationParams.imageSize,
|
||||
imageCount:
|
||||
effectiveImageGenerationParams.imageCount ||
|
||||
defaultImageQualityParams.imageCount ||
|
||||
1,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
chatApiLogger.warn("[Chat API] Record image primary health failure failed:", healthError);
|
||||
});
|
||||
|
||||
if (
|
||||
!shouldFailoverAiModelError(primaryError) &&
|
||||
!shouldFailoverVip98kRequestRejection(
|
||||
primaryError,
|
||||
(languageModel as any).provider,
|
||||
)
|
||||
) {
|
||||
chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error.");
|
||||
throw primaryError;
|
||||
}
|
||||
|
||||
let fallbackSuccess = false;
|
||||
const triedFallbackModelIds = new Set<string>();
|
||||
for (const candidate of fallbackCandidates) {
|
||||
try {
|
||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||
candidate,
|
||||
@ -9844,6 +10029,22 @@ export async function POST(request: Request) {
|
||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
await recordAiModelHealthFailure({
|
||||
model: candidate.model,
|
||||
provider: candidate.provider,
|
||||
error: fallbackError,
|
||||
context: "chat_image_fallback",
|
||||
elapsedMs: Date.now() - generationStartedAt,
|
||||
metadata: {
|
||||
primaryModelId: actualModelId,
|
||||
fallbackModelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||
imageSize: effectiveImageGenerationParams.imageSize,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
|
||||
});
|
||||
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
}
|
||||
}
|
||||
@ -9860,6 +10061,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -9871,10 +10073,11 @@ export async function POST(request: Request) {
|
||||
currentModelKey: actualModelId,
|
||||
currentProviderId: alibabaModelConfig?.providerId,
|
||||
providerPriority,
|
||||
});
|
||||
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
|
||||
|
||||
for (const candidate of crossProviderCandidates) {
|
||||
try {
|
||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||
if (
|
||||
candidate.provider.type === "fal-image" &&
|
||||
@ -9909,6 +10112,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -9956,6 +10161,22 @@ export async function POST(request: Request) {
|
||||
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||
}
|
||||
await recordAiModelHealthFailure({
|
||||
model: candidate.model,
|
||||
provider: candidate.provider,
|
||||
error: crossError,
|
||||
context: "chat_image_cross_fallback",
|
||||
elapsedMs: Date.now() - generationStartedAt,
|
||||
metadata: {
|
||||
primaryModelId: actualModelId,
|
||||
fallbackModelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
aspectRatio: effectiveImageGenerationParams.aspectRatio,
|
||||
imageSize: effectiveImageGenerationParams.imageSize,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
|
||||
});
|
||||
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
|
||||
}
|
||||
}
|
||||
@ -9969,6 +10190,19 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length);
|
||||
chatApiLogger.info("[Chat API] Alibaba used model:", usedModelId);
|
||||
|
||||
const usedModelEntry = resolveModelEntryByRuntimeModelId(
|
||||
allModels,
|
||||
usedModelId,
|
||||
usedModelId === actualModelId ? finalModelId : undefined,
|
||||
);
|
||||
await recordAiModelHealthSuccess({
|
||||
model:
|
||||
usedModelEntry?.model ||
|
||||
(usedModelId === actualModelId ? alibabaModelConfig : null),
|
||||
}).catch((healthError) => {
|
||||
chatApiLogger.warn("[Chat API] Record image health success failed:", healthError);
|
||||
});
|
||||
|
||||
const usedModelDisplayName =
|
||||
resolveModelDisplayNameByModelId(allModels, usedModelId) ||
|
||||
alibabaModelConfig?.displayName ||
|
||||
@ -10298,6 +10532,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 6,
|
||||
visionary: 6,
|
||||
atlascloud: 6,
|
||||
vip98k: 6,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
@ -10348,6 +10583,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -10561,7 +10798,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
||||
if (
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
|
||||
typeof (languageModel as any).doGenerate === "function"
|
||||
) {
|
||||
const currentVideoProvider = (languageModel as any).provider as
|
||||
@ -10570,7 +10807,8 @@ export async function POST(request: Request) {
|
||||
| "ruxa-video"
|
||||
| "runway"
|
||||
| "krea"
|
||||
| "bltcy-video";
|
||||
| "bltcy-video"
|
||||
| "vip98k";
|
||||
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
|
||||
|
||||
const videoGenerationStream = createUIMessageStream({
|
||||
@ -11005,7 +11243,7 @@ export async function POST(request: Request) {
|
||||
requestHeaders: request.headers,
|
||||
requestUrl: request.url,
|
||||
defaultModelId: finalModelId,
|
||||
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
|
||||
authorizeDestructive: (nodeIds) => isExplicitDestructiveIntent(requestTextContent) && nodeIds.length > 0 && nodeIds.every((nodeId) => selectedCanvasImageNodeIds.includes(nodeId)),
|
||||
callbacks: {
|
||||
onClientCommand: (command, capability) => {
|
||||
writeCanvasCapabilityCommandChunk(
|
||||
@ -11226,6 +11464,7 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -11278,6 +11517,8 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -12031,8 +12272,70 @@ export async function POST(request: Request) {
|
||||
const shouldDisableSmoothStream =
|
||||
resolvedModelSupportsImageGen || isVolcanoVideoModel;
|
||||
|
||||
let activeConversationModel = currentModelConfig;
|
||||
let activeConversationProvider = currentProviderConfig;
|
||||
const recordedAuthFailures = new Set<string>();
|
||||
const recordConversationAuthFailure = async (error: unknown) => {
|
||||
const key = activeConversationModel?.id;
|
||||
if (!key || recordedAuthFailures.has(key)) return;
|
||||
recordedAuthFailures.add(key);
|
||||
recordProviderHealthFailure({
|
||||
providerKey: activeConversationProvider?.id,
|
||||
providerType: activeConversationProvider?.type,
|
||||
error,
|
||||
});
|
||||
await recordAiModelHealthFailure({
|
||||
model: activeConversationModel,
|
||||
provider: activeConversationProvider,
|
||||
error,
|
||||
context: "canvas_agent_conversation_auth",
|
||||
}).catch((healthError) => chatApiLogger.warn("[Chat API] Could not persist model authentication failure:", healthError));
|
||||
};
|
||||
const needsVisionFallback = convertedMessages.some((item: any) =>
|
||||
Array.isArray(item.content) && item.content.some((part: any) =>
|
||||
part.type === "image" || (part.type === "file" && String(part.mediaType || "").startsWith("image/"))
|
||||
)
|
||||
);
|
||||
const streamModel = shouldRunAgentConversation && !shouldKeepSelectedModel &&
|
||||
currentModelConfig?.excludeFromFailover !== true &&
|
||||
!resolvedModelSupportsImageGen && !isVolcanoVideoModel
|
||||
? withAuthenticationFailover({
|
||||
primary: languageModel as any,
|
||||
onAuthenticationFailure: recordConversationAuthFailure,
|
||||
resolveFallback: async () => {
|
||||
const enabled = await getEnabledAiModelsWithProvider(false);
|
||||
const preferredIds = [currentModelConfig?.fallbackModelId1, currentModelConfig?.fallbackModelId2];
|
||||
const candidates = enabled.filter(({ model, provider }) =>
|
||||
model.id !== activeConversationModel?.id && model.isEnabled && provider.isEnabled &&
|
||||
!model.excludeFromFailover && !model.isSubModel && model.healthStatus !== "auto_disabled" &&
|
||||
!isImageGenerationModelType(provider.type, model) && !isVideoProviderType(provider.type, model) &&
|
||||
(!needsVisionFallback || model.supportsVision) &&
|
||||
(isAiModelAgentAuto(model) || preferredIds.includes(model.id)) &&
|
||||
!shouldAvoidProvider(getProviderHealthSnapshot(provider.id))
|
||||
).sort((a, b) => {
|
||||
const rank = (id: string, isDefault: boolean) => {
|
||||
const index = preferredIds.indexOf(id);
|
||||
return index >= 0 ? index : isDefault ? 2 : 3;
|
||||
};
|
||||
return rank(a.model.id, a.model.isDefault) - rank(b.model.id, b.model.isDefault);
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
const fallback = await getLanguageModelById(candidate.model.id);
|
||||
if (!fallback || typeof fallback === "string") continue;
|
||||
activeConversationModel = candidate.model;
|
||||
activeConversationProvider = candidate.provider;
|
||||
finalModelId = candidate.model.id;
|
||||
actualModelId = candidate.model.modelId;
|
||||
activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(candidate.model.id).catch(() => null);
|
||||
return fallback as any;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
})
|
||||
: languageModel;
|
||||
const result = streamText({
|
||||
model: languageModel,
|
||||
model: streamModel,
|
||||
maxRetries: shouldRunAgentConversation ? 0 : undefined,
|
||||
system: effectiveConversationSystemPrompt,
|
||||
messages: convertedMessages,
|
||||
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
HeadObjectCommand,
|
||||
ListPartsCommand,
|
||||
PutObjectCommand,
|
||||
UploadPartCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
@ -12,6 +13,10 @@ import { z } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
||||
import {
|
||||
IMAGE_UPLOAD_MAX_BYTES,
|
||||
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||
} from "@/lib/image-upload-limits";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
buildS3PublicUrl,
|
||||
@ -21,12 +26,22 @@ import {
|
||||
maybeEnqueueStorageMigrationTask,
|
||||
resolveS3UploadTarget,
|
||||
} from "@/lib/storage/service";
|
||||
import {
|
||||
getOpenClawChatFileContentType,
|
||||
isOpenClawChatFolderFileAllowed,
|
||||
OPENCLAW_CHAT_FILE_MAX_BYTES,
|
||||
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
|
||||
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
|
||||
} from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
const logger = createLogger("direct-upload-api");
|
||||
const DIRECT_UPLOAD_MAX_BYTES = 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 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";
|
||||
|
||||
function elapsedMs(startedAt: number) {
|
||||
@ -39,6 +54,13 @@ const allowedDirectUploadTypes = new Set([
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
const allowedOpenClawVideoUploadTypes = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-m4v",
|
||||
"video/x-matroska",
|
||||
]);
|
||||
|
||||
const InitiateSchema = z.object({
|
||||
action: z.literal("initiate"),
|
||||
@ -68,6 +90,18 @@ const CompleteSchema = z.object({
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const ResumeSchema = z.object({
|
||||
action: z.literal("resume"),
|
||||
key: z.string().min(1).max(512),
|
||||
uploadId: z.string().min(1).max(512),
|
||||
filename: z.string().min(1).max(256),
|
||||
contentType: z.string().min(1).max(128),
|
||||
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
|
||||
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
|
||||
purpose: z.string().max(64).optional(),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const AbortSchema = z.object({
|
||||
action: z.literal("abort"),
|
||||
key: z.string().min(1).max(512),
|
||||
@ -77,6 +111,7 @@ const AbortSchema = z.object({
|
||||
|
||||
const RequestSchema = z.discriminatedUnion("action", [
|
||||
InitiateSchema,
|
||||
ResumeSchema,
|
||||
CompleteSchema,
|
||||
AbortSchema,
|
||||
]);
|
||||
@ -104,19 +139,52 @@ function assertUserOwnedKey(key: string, userId: string) {
|
||||
const normalized = String(key || "").replace(/^\/+/, "");
|
||||
const prefix = `${userUploadPrefix(userId)}/`;
|
||||
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;
|
||||
}
|
||||
|
||||
function isAllowedDirectUpload(contentType: string) {
|
||||
return allowedDirectUploadTypes.has(contentType);
|
||||
function isAllowedDirectUpload(
|
||||
contentType: string,
|
||||
purpose: string | undefined,
|
||||
filename: string
|
||||
) {
|
||||
return (
|
||||
allowedDirectUploadTypes.has(contentType) ||
|
||||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
allowedOpenClawVideoUploadTypes.has(contentType)) ||
|
||||
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
|
||||
Boolean(
|
||||
getOpenClawChatFileContentType({ name: filename, type: contentType })
|
||||
)) ||
|
||||
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
|
||||
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
|
||||
);
|
||||
}
|
||||
|
||||
function getPurposeMaxBytes(purpose?: string, contentType?: string) {
|
||||
if (String(contentType || "").toLowerCase().startsWith("image/")) {
|
||||
return IMAGE_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
|
||||
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
|
||||
}
|
||||
if (
|
||||
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
) {
|
||||
return DIRECT_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
|
||||
}
|
||||
|
||||
function computePartSize(size: number) {
|
||||
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
|
||||
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
|
||||
return 16 * 1024 * 1024;
|
||||
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
|
||||
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
|
||||
return 64 * 1024 * 1024;
|
||||
}
|
||||
|
||||
async function createPartUrls(params: {
|
||||
@ -124,24 +192,24 @@ async function createPartUrls(params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partCount: number;
|
||||
partNumbers: number[];
|
||||
}) {
|
||||
const urls = [];
|
||||
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: params.bucket,
|
||||
Key: params.key,
|
||||
UploadId: params.uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
urls.push({
|
||||
partNumber,
|
||||
url: await getSignedUrl(params.client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return urls;
|
||||
return Promise.all(
|
||||
params.partNumbers.map(async (partNumber) => {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: params.bucket,
|
||||
Key: params.key,
|
||||
UploadId: params.uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
return {
|
||||
partNumber,
|
||||
url: await getSignedUrl(params.client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -204,7 +272,7 @@ export async function POST(request: Request) {
|
||||
payload.filename
|
||||
);
|
||||
|
||||
if (!isAllowedDirectUpload(contentType)) {
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
logger.warn("unsupported_direct_upload_type", {
|
||||
contentType,
|
||||
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
||||
@ -213,10 +281,24 @@ export async function POST(request: Request) {
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
||||
{
|
||||
error:
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const maxBytes = getPurposeMaxBytes(payload.purpose, contentType);
|
||||
if (payload.size > maxBytes) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: contentType.startsWith("image/")
|
||||
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
|
||||
: `文件不能超过 ${maxBytes / 1024 / 1024}MB`,
|
||||
},
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = sanitizeDisplayName(payload.filename);
|
||||
const key = buildStorageObjectKey(
|
||||
@ -224,7 +306,13 @@ export async function POST(request: Request) {
|
||||
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({
|
||||
Bucket: bucket,
|
||||
@ -284,7 +372,7 @@ export async function POST(request: Request) {
|
||||
bucket,
|
||||
key,
|
||||
uploadId,
|
||||
partCount,
|
||||
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
|
||||
});
|
||||
logger.info("direct_upload_multipart_prepared", {
|
||||
elapsedMs: elapsedMs(multipartStartedAt),
|
||||
@ -308,6 +396,105 @@ 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, contentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: contentType.startsWith("image/")
|
||||
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
|
||||
: "文件超过允许的上传大小",
|
||||
},
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
const expectedPartSize = computePartSize(payload.size);
|
||||
if (payload.partSize !== expectedPartSize) {
|
||||
return NextResponse.json(
|
||||
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const listed = await client.send(
|
||||
new ListPartsCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: payload.uploadId,
|
||||
MaxParts: 1000,
|
||||
})
|
||||
);
|
||||
const uploadedParts = (listed.Parts || []).flatMap((part) => {
|
||||
if (!part.ETag || typeof part.PartNumber !== "number") return [];
|
||||
return [
|
||||
{
|
||||
ETag: part.ETag,
|
||||
PartNumber: part.PartNumber,
|
||||
size: Number(part.Size || 0),
|
||||
},
|
||||
];
|
||||
});
|
||||
const uploadedPartNumbers = new Set(
|
||||
uploadedParts.map((part) => part.PartNumber)
|
||||
);
|
||||
const partCount = Math.ceil(payload.size / expectedPartSize);
|
||||
const missingPartNumbers = Array.from(
|
||||
{ length: partCount },
|
||||
(_, index) => index + 1
|
||||
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
|
||||
const parts = await createPartUrls({
|
||||
client,
|
||||
bucket,
|
||||
key,
|
||||
uploadId: payload.uploadId,
|
||||
partNumbers: missingPartNumbers,
|
||||
});
|
||||
|
||||
logger.info("direct_upload_multipart_resumed", {
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
size: payload.size,
|
||||
uploadedPartCount: uploadedParts.length,
|
||||
missingPartCount: missingPartNumbers.length,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json({
|
||||
mode: "multipart",
|
||||
key,
|
||||
uploadId: payload.uploadId,
|
||||
partSize: expectedPartSize,
|
||||
partCount,
|
||||
parts,
|
||||
uploadedParts,
|
||||
resumed: true,
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
} catch (error) {
|
||||
const head = await client
|
||||
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
|
||||
.catch(() => null);
|
||||
if (head?.ContentLength === payload.size) {
|
||||
return NextResponse.json({
|
||||
mode: "completed",
|
||||
key,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
logger.warn("direct_upload_resume_session_missing", error, {
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.action === "abort") {
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const abortStartedAt = Date.now();
|
||||
@ -328,12 +515,25 @@ export async function POST(request: Request) {
|
||||
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||
if (!isAllowedDirectUpload(contentType)) {
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
||||
{
|
||||
error:
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: contentType.startsWith("image/")
|
||||
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
|
||||
: "文件超过允许的上传大小",
|
||||
},
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.uploadId) {
|
||||
const completeMultipartStartedAt = Date.now();
|
||||
@ -379,8 +579,10 @@ export async function POST(request: Request) {
|
||||
|
||||
const actualSize =
|
||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||
if (actualSize !== null && actualSize <= 0) {
|
||||
throw new Error("Uploaded object is empty");
|
||||
if (actualSize !== null && actualSize !== payload.size) {
|
||||
throw new Error(
|
||||
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = buildS3PublicUrl(target.config, key);
|
||||
@ -389,15 +591,43 @@ export async function POST(request: Request) {
|
||||
const assetStartedAt = Date.now();
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
sourceType: "upload",
|
||||
mediaType:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "file"
|
||||
: resolvedContentType.startsWith("video/")
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType:
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_video_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_file_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_folder_file_upload"
|
||||
: "upload",
|
||||
status: "ready",
|
||||
originalUrl: url,
|
||||
previewUrl: url,
|
||||
thumbnailUrl: url,
|
||||
previewUrl:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? null
|
||||
: url,
|
||||
thumbnailUrl:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? null
|
||||
: url,
|
||||
mimeType: resolvedContentType,
|
||||
displayName: payload.filename,
|
||||
sourceTable: "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: {
|
||||
pathname: key,
|
||||
size: actualSize ?? payload.size,
|
||||
@ -444,7 +674,13 @@ export async function POST(request: Request) {
|
||||
error: "Direct upload failed",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 }
|
||||
{
|
||||
status:
|
||||
error instanceof Error &&
|
||||
error.name === "InvalidUploadObjectKeyError"
|
||||
? 403
|
||||
: 500,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||
isImageUploadTooLarge,
|
||||
} from "@/lib/image-upload-limits";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
||||
@ -7,6 +11,7 @@ import { upsertMediaAsset } from "@/lib/db/queries";
|
||||
import { isSiteAdminType } from "@/lib/middleware/admin";
|
||||
import { getContentType, uploadFile } from "@/lib/storage/service";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
const logger = createLogger("upload-api");
|
||||
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||
@ -14,6 +19,9 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
@ -23,8 +31,20 @@ const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
]);
|
||||
const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-m4v",
|
||||
"video/x-matroska",
|
||||
]);
|
||||
|
||||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||||
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
]);
|
||||
|
||||
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||
const normalized = contentType.trim().toLowerCase();
|
||||
@ -40,8 +60,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
|
||||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||||
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
||||
}
|
||||
if (
|
||||
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
||||
extension === "avif" ||
|
||||
extension === "tif" ||
|
||||
extension === "tiff"
|
||||
) {
|
||||
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
|
||||
}
|
||||
|
||||
return "File type should be JPEG, PNG, WebP or SVG";
|
||||
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
|
||||
}
|
||||
|
||||
function isHeicUpload(contentType: string, filename: string) {
|
||||
@ -53,10 +81,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) {
|
||||
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
||||
if (/\.(heic|heif)$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
|
||||
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
|
||||
}
|
||||
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
||||
@ -76,6 +114,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
||||
}
|
||||
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智能优化图片(保持原格式)
|
||||
* 策略:避免双重压缩,只在需要时优化
|
||||
@ -205,9 +258,18 @@ export async function POST(request: Request) {
|
||||
const isAtlasCloudReferenceVideo =
|
||||
uploadPurpose === "canvas-video-process" &&
|
||||
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
|
||||
const isOpenClawChatVideoUpload =
|
||||
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
|
||||
const clientUploadId =
|
||||
String(formData.get("clientUploadId") || "").trim() || null;
|
||||
const isSvgUpload = contentType === "image/svg+xml";
|
||||
if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) {
|
||||
return NextResponse.json(
|
||||
{ error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
isSvgUpload &&
|
||||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
||||
@ -237,7 +299,8 @@ export async function POST(request: Request) {
|
||||
|
||||
if (
|
||||
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
||||
!isAtlasCloudReferenceVideo
|
||||
!isAtlasCloudReferenceVideo &&
|
||||
!isOpenClawChatVideoUpload
|
||||
) {
|
||||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||||
logger.warn("[Upload API] Unsupported upload content type", {
|
||||
@ -288,6 +351,38 @@ export async function POST(request: Request) {
|
||||
{ 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") {
|
||||
@ -372,15 +467,28 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: isAtlasCloudReferenceVideo ? "video" : "image",
|
||||
sourceType: "upload",
|
||||
mediaType:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
status: "ready",
|
||||
originalUrl: data.url,
|
||||
previewUrl: isAtlasCloudReferenceVideo ? null : data.url,
|
||||
thumbnailUrl: isAtlasCloudReferenceVideo ? null : data.url,
|
||||
previewUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
thumbnailUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
mimeType: data.contentType || contentType || null,
|
||||
displayName: filename,
|
||||
sourceTable: "upload",
|
||||
sourceTable: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
metadata: {
|
||||
pathname: data.pathname,
|
||||
size: optimizedBuffer.length,
|
||||
|
||||
@ -102,8 +102,8 @@ export async function GET(request: Request) {
|
||||
},
|
||||
{
|
||||
id: "local-e2e-video-model",
|
||||
modelId: "kling-3-local",
|
||||
displayName: "可灵 3.0",
|
||||
modelId: "veo-3.1-fast",
|
||||
displayName: "Veo 3.1 Fast(本地布局测试)",
|
||||
avatarUrl: null,
|
||||
description: "本地视频画布测试视频模型",
|
||||
supportsVision: true,
|
||||
@ -111,18 +111,18 @@ export async function GET(request: Request) {
|
||||
isMultimodal: true,
|
||||
isDefault: false,
|
||||
providerName: "Local E2E",
|
||||
providerType: "local",
|
||||
providerType: "vip98k",
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
capabilities: { video: true },
|
||||
capabilities: { kind: "video", supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"], supportedRatios:["16:9"], supportedResolutions:["720p","1080p"], durationOptions:[8], maxReferenceImages:3 },
|
||||
videoToolCapability: {
|
||||
supported: true,
|
||||
durations: [5, 10],
|
||||
durations: [8],
|
||||
resolutions: ["720p", "1080p"],
|
||||
},
|
||||
imageGenConfig: {
|
||||
creditCostPerVideo: 20,
|
||||
videoDurationOptions: [5, 10],
|
||||
videoDurationOptions: [8],
|
||||
},
|
||||
sortOrder: 1,
|
||||
selectable: true,
|
||||
|
||||
@ -3,10 +3,5 @@ export default function FlowBoardLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
import { ChatShell } from "@/components/chat-shell";
|
||||
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
|
||||
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||
import { auth } from "../(auth)/auth";
|
||||
import {
|
||||
@ -8,10 +9,14 @@ import {
|
||||
getTutorialVideos,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<Suspense
|
||||
fallback={<CanvasLogoLoadingView label="正在加载页面..." />}
|
||||
>
|
||||
<SidebarWrapper>{children}</SidebarWrapper>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
|
||||
@ -1,79 +1,75 @@
|
||||
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";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { VideoCanvasHome, type VideoCanvasHomeProject } from "@/components/canvas/VideoCanvasHome";
|
||||
import { db } from "@/lib/db";
|
||||
import { getSiteSettings } from "@/lib/db/queries";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
|
||||
type VideoCanvasRedirectPageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
type Props = { searchParams?: Promise<Record<string, string | string[] | undefined>> };
|
||||
|
||||
function getSearchParam(
|
||||
params: Record<string, string | string[] | undefined>,
|
||||
key: string
|
||||
) {
|
||||
const value = params[key];
|
||||
function first(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function buildVideoCanvasUrl(
|
||||
canvasId: string,
|
||||
params: Record<string, string | string[] | undefined>
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
query.set("mode", "video");
|
||||
query.set("workspaceType", "video");
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (
|
||||
key === "new" ||
|
||||
key === "mode" ||
|
||||
key === "workspaceType" ||
|
||||
value === undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => query.append(key, item));
|
||||
continue;
|
||||
}
|
||||
query.set(key, value);
|
||||
function canvasHref(id: string, incoming: Record<string, string | string[] | undefined>) {
|
||||
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue;
|
||||
for (const item of Array.isArray(value) ? value : [value]) params.append(key, item);
|
||||
}
|
||||
|
||||
return `/canvas/${canvasId}?${query.toString()}`;
|
||||
return `/canvas/${id}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function getLatestVideoCanvasId(userId: string) {
|
||||
const [latestProject] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, userId),
|
||||
eq(canvasProject.workspaceType, "video")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasProject.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
return latestProject?.id || null;
|
||||
function projectPreview(thumbnail: string | null, canvasData: unknown) {
|
||||
const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData
|
||||
? (canvasData as { nodes?: unknown }).nodes : null;
|
||||
const candidates = [thumbnail];
|
||||
if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) {
|
||||
if (!node || typeof node !== "object") continue;
|
||||
const item = node as { inputType?: string; draftValues?: Record<string, unknown> };
|
||||
const draft = item.draftValues || {};
|
||||
candidates.push(
|
||||
String(draft.poster || draft.previewImageUrl || draft.thumbnailUrl || draft.resultImage || (item.inputType === "image" ? draft.mediaUrl || (Array.isArray(draft.url) ? draft.url[0] : "") : "") || ""),
|
||||
);
|
||||
}
|
||||
return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null;
|
||||
}
|
||||
|
||||
export default async function VideoCanvasRedirectPage({
|
||||
searchParams,
|
||||
}: VideoCanvasRedirectPageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const shouldCreateNew = getSearchParam(params, "new") === "1";
|
||||
|
||||
if (shouldCreateNew) {
|
||||
redirect(buildVideoCanvasUrl(randomUUID(), params));
|
||||
}
|
||||
|
||||
export default async function VideoCanvasHomePage({ searchParams }: Props) {
|
||||
const session = await auth();
|
||||
const latestCanvasId = session?.user?.id
|
||||
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
|
||||
: null;
|
||||
if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas");
|
||||
|
||||
redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params));
|
||||
const params = (await searchParams) || {};
|
||||
if (first(params.new) === "1") {
|
||||
const [created] = await db.insert(canvasProject).values({
|
||||
userId: session.user.id,
|
||||
title: "未命名视频画布",
|
||||
workspaceType: "video",
|
||||
canvasData: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
}).returning({ id: canvasProject.id });
|
||||
redirect(canvasHref(created.id, params));
|
||||
}
|
||||
|
||||
const [rows, siteSettings] = await Promise.all([db.select({
|
||||
id: canvasProject.id,
|
||||
title: canvasProject.title,
|
||||
thumbnail: canvasProject.thumbnail,
|
||||
canvasData: canvasProject.canvasData,
|
||||
createdAt: canvasProject.createdAt,
|
||||
updatedAt: canvasProject.updatedAt,
|
||||
}).from(canvasProject).where(and(
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
eq(canvasProject.workspaceType, "video"),
|
||||
)).orderBy(desc(canvasProject.updatedAt)).limit(25), getSiteSettings()]);
|
||||
|
||||
const projects: VideoCanvasHomeProject[] = rows.slice(0, 24).map(row => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
thumbnail: projectPreview(row.thumbnail, row.canvasData),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}));
|
||||
|
||||
return <VideoCanvasHome initialHasMore={rows.length > 24} initialProjects={projects} siteLogo={siteSettings.siteLogo} siteLogoDark={siteSettings.siteLogoDark} siteName={siteSettings.siteName} userName={session.user.name || ""} />;
|
||||
}
|
||||
|
||||
@ -33,6 +33,11 @@ const imageGenConfigSchema = z.object({
|
||||
supportsNegativePrompt: z.boolean().optional(),
|
||||
supportsWatermark: 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(),
|
||||
@ -45,6 +50,7 @@ const imageGenConfigSchema = z.object({
|
||||
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
||||
enableLoadingPlaceholder: z.boolean().optional(),
|
||||
loadingColor: z.string().optional(),
|
||||
vip98k: z.record(z.string(), z.unknown()).optional(),
|
||||
}).optional();
|
||||
|
||||
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
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 = [
|
||||
@ -35,6 +36,7 @@ const providerTypes = [
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"fal-video",
|
||||
@ -79,11 +81,12 @@ 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 BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||
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 RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
||||
const KREA_BASE_URL = "https://api.krea.ai";
|
||||
@ -249,6 +252,8 @@ export async function POST(request: Request) {
|
||||
? 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.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||
: data.type === "midjourney"
|
||||
@ -311,6 +316,12 @@ export async function PUT(request: Request) {
|
||||
...data,
|
||||
apiKey: normalizedApiKey,
|
||||
type: normalizeProviderTypeForStorage(data.type),
|
||||
baseUrl:
|
||||
data.baseUrl !== undefined
|
||||
? data.baseUrl
|
||||
: data.type === "vip98k"
|
||||
? VIP98K_PROVIDER_BASE_URL
|
||||
: undefined,
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
|
||||
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";
|
||||
@ -12,7 +16,7 @@ export const maxDuration = 300;
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const result = await fetchAtlasCloudImagePresets();
|
||||
const result = await fetchAtlasCloudModelPresets();
|
||||
return NextResponse.json({
|
||||
presets: result.presets,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
@ -29,7 +33,7 @@ export async function GET() {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch Atlas Cloud image models",
|
||||
: "Failed to fetch Atlas Cloud models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@ -58,31 +62,50 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = await fetchAtlasCloudImagePresets();
|
||||
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 = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
),
|
||||
});
|
||||
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: preset.displayName,
|
||||
displayName: resolveAtlasCloudDisplayName(
|
||||
existing?.displayName,
|
||||
preset.displayName,
|
||||
preset.modelId
|
||||
),
|
||||
avatarUrl: preset.avatarUrl || null,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
sortOrder: existing?.sortOrder ?? preset.sortOrder,
|
||||
capabilities: preset.capabilities,
|
||||
defaultParams: preset.defaultParams,
|
||||
imageGenConfig: preset.imageGenConfig,
|
||||
@ -108,6 +131,8 @@ export async function POST(request: Request) {
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
...values,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
isDefault: false,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
@ -138,6 +163,8 @@ export async function POST(request: Request) {
|
||||
requested: presets.length,
|
||||
total: catalog.catalogCount,
|
||||
available: catalog.presets.length,
|
||||
skippedUnsupported: catalog.skipped.length,
|
||||
preservedDeleted: preservedDeletedCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -147,7 +174,7 @@ export async function POST(request: Request) {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to sync Atlas Cloud image models",
|
||||
: "Failed to sync Atlas Cloud models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@ -94,6 +94,8 @@ export async function POST(request: Request) {
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
...values,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
isDefault: false,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
|
||||
@ -53,6 +53,10 @@ import {
|
||||
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
||||
normalizeCanvasImageTaskQueueConfig,
|
||||
} from "@/lib/canvas/image-task-config";
|
||||
import {
|
||||
CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||
normalizeCanvasLocalEditConfig,
|
||||
} from "@/lib/canvas/local-edit-config";
|
||||
import {
|
||||
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||
normalizeDingTalkRobotNotificationConfig,
|
||||
@ -126,6 +130,12 @@ export async function GET(request: Request) {
|
||||
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) {
|
||||
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
|
||||
return Response.json({
|
||||
@ -369,6 +379,10 @@ export async function POST(request: Request) {
|
||||
nextValue = normalizeCanvasImageTaskQueueConfig(value);
|
||||
}
|
||||
|
||||
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
|
||||
nextValue = normalizeCanvasLocalEditConfig(value);
|
||||
}
|
||||
|
||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||
nextValue = normalizeDingTalkRobotNotificationConfig(value);
|
||||
}
|
||||
|
||||
@ -76,8 +76,6 @@ export async function POST(request: Request) {
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities || null,
|
||||
defaultParams: preset.defaultParams || null,
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
295
app/api/agents/detail-replacement/batches/route.ts
Normal file
295
app/api/agents/detail-replacement/batches/route.ts
Normal file
@ -0,0 +1,295 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
|
||||
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
ensureCanvasImageTaskActive,
|
||||
getCanvasImageTaskForUser,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import {
|
||||
checkUserImageQuota,
|
||||
getChatById,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
saveChat,
|
||||
} from "@/lib/db/queries";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement";
|
||||
import {
|
||||
buildAgentSlotPrompt,
|
||||
normalizeAgentSupplementPrompt,
|
||||
} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow";
|
||||
import {
|
||||
detailReplacementSubmissionKey,
|
||||
isDetailReplacementBatchRequest,
|
||||
isDetailReplacementUuid,
|
||||
type DetailReplacementBatchReference,
|
||||
type DetailReplacementBatchRequest,
|
||||
type DetailReplacementBatchResult,
|
||||
} from "@/lib/agents/detail-replacement-batch";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
function scopedChatId(projectId: string) {
|
||||
const normalized = projectId.toLowerCase().replace(/-/g, "");
|
||||
const scoped = `${normalized.slice(0, 20)}a93e17a60001`;
|
||||
return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-");
|
||||
}
|
||||
|
||||
function taskResponse(task: Awaited<ReturnType<typeof getCanvasImageTaskForUser>>) {
|
||||
const result = task?.result as DetailReplacementBatchResult | null;
|
||||
const request = task?.request as DetailReplacementBatchRequest | null;
|
||||
return {
|
||||
id: task?.id,
|
||||
status: task?.status,
|
||||
attempts: task?.attempts,
|
||||
error: task?.error || null,
|
||||
cards: Array.isArray(result?.cards) ? result.cards : [],
|
||||
currentCardId: result?.currentCardId || null,
|
||||
requestedCardIds: request?.references?.map((item) => item.id) || [],
|
||||
createdAt: task?.createdAt || null,
|
||||
queueStatus: task?.queueStatus || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findActiveBatchId(params: {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
submissionKey: string;
|
||||
}) {
|
||||
const [row] = await db.select({ id: canvasImageTask.id })
|
||||
.from(canvasImageTask)
|
||||
.where(and(
|
||||
eq(canvasImageTask.userId, params.userId),
|
||||
inArray(canvasImageTask.status, ["pending", "running"]),
|
||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
||||
sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`,
|
||||
sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`,
|
||||
))
|
||||
.orderBy(desc(canvasImageTask.createdAt)).limit(1);
|
||||
return row?.id || null;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) {
|
||||
if ((cursor as { code?: unknown }).code === "23505") return true;
|
||||
cursor = (cursor as { cause?: unknown }).cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const params = new URL(request.url).searchParams;
|
||||
const projectId = String(params.get("projectId") || "").trim();
|
||||
const taskId = String(params.get("taskId") || "").trim();
|
||||
if (!isDetailReplacementUuid(projectId)) {
|
||||
return NextResponse.json({ error: "项目编号无效" }, { status: 400 });
|
||||
}
|
||||
const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and(
|
||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
||||
)).limit(1);
|
||||
if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
||||
|
||||
const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request })
|
||||
.from(canvasImageTask)
|
||||
.where(and(
|
||||
eq(canvasImageTask.userId, session.user.id),
|
||||
...(isDetailReplacementUuid(taskId) ? [eq(canvasImageTask.id, taskId)] : []),
|
||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
||||
sql`${canvasImageTask.request}->>'projectId' = ${projectId}`,
|
||||
))
|
||||
// A batch that is still working matters more than a newer finished one, so the
|
||||
// workspace keeps showing live progress after a rerun of a single card.
|
||||
.orderBy(
|
||||
sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`,
|
||||
desc(canvasImageTask.createdAt),
|
||||
).limit(1);
|
||||
if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) {
|
||||
return NextResponse.json({ task: null });
|
||||
}
|
||||
let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
||||
if (task && (task.status === "failed" ||
|
||||
(task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) {
|
||||
await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id });
|
||||
task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
||||
}
|
||||
return NextResponse.json({ task: taskResponse(task) });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const projectId = String(body.projectId || "").trim();
|
||||
const taskId = String(body.requestId || "").trim();
|
||||
if (!isDetailReplacementUuid(projectId) || !isDetailReplacementUuid(taskId)) {
|
||||
return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 });
|
||||
}
|
||||
const [project] = await db.select().from(canvasProject).where(and(
|
||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
||||
)).limit(1);
|
||||
if (!project || !isAgentProjectRow(project)) {
|
||||
return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const state = extractAgentProjectStateFromCanvasData(project.canvasData);
|
||||
const knownReferences = new Map(
|
||||
(state.workflowState.agentModeState?.detailReplacement?.references || [])
|
||||
.map((item) => [String(item.id), String(item.imageUrl)]),
|
||||
);
|
||||
const requested = Array.isArray(body.references) ? body.references : [];
|
||||
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
|
||||
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
||||
return {
|
||||
id: String(item.id || "").slice(0, 128),
|
||||
label: String(item.label || "详情参考图").slice(0, 255),
|
||||
imageUrl: String(item.imageUrl || "").trim(),
|
||||
candidateId: String(item.candidateId || "").slice(0, 128) || null,
|
||||
passthrough: item.passthrough === true,
|
||||
rerunReferenceImages: Array.isArray(item.rerunReferenceImages)
|
||||
? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") }))
|
||||
: [],
|
||||
};
|
||||
});
|
||||
if (
|
||||
references.length === 0 || references.length > 40 ||
|
||||
new Set(references.map((item) => item.id)).size !== references.length ||
|
||||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
|
||||
) {
|
||||
return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 });
|
||||
}
|
||||
const productImageUrls = Array.isArray(body.productImageUrls)
|
||||
? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim())
|
||||
.filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[]
|
||||
: [];
|
||||
if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) {
|
||||
return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 });
|
||||
}
|
||||
const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace";
|
||||
const prompt = String(body.prompt || "").trim().slice(0, 24_000);
|
||||
if (strategy !== "product_only" && !prompt) {
|
||||
return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 });
|
||||
}
|
||||
const models = await getEnabledAiModelsWithProvider();
|
||||
const model = await resolveDirectReplacementModelRef({
|
||||
selectedChatModel: String(body.model || "auto"),
|
||||
finalModelId: String(body.model || "auto"),
|
||||
models,
|
||||
});
|
||||
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
|
||||
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
|
||||
}
|
||||
const quota = await checkUserImageQuota(session.user.id);
|
||||
if (!quota.allowed) {
|
||||
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
|
||||
}
|
||||
const requestedImageCount = references.filter((item) => !item.passthrough).length;
|
||||
if (quota.limit != null && quota.used + requestedImageCount > quota.limit) {
|
||||
return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 });
|
||||
}
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) {
|
||||
const selectedModel = models.find((entry) => entry.model.id === model)?.model;
|
||||
const unitCost = getModelGenerationCreditCost(
|
||||
selectedModel as Parameters<typeof getModelGenerationCreditCost>[0], "image", {
|
||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
||||
});
|
||||
const user = await getUserById({ id: session.user.id });
|
||||
const expectedCost = unitCost * requestedImageCount;
|
||||
if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) {
|
||||
return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 });
|
||||
}
|
||||
}
|
||||
const chatId = scopedChatId(projectId);
|
||||
const existingChat = await getChatById({ id: chatId });
|
||||
if (existingChat && existingChat.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 });
|
||||
}
|
||||
if (!existingChat) await saveChat({
|
||||
id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private",
|
||||
});
|
||||
const taskRequest: DetailReplacementBatchRequest = {
|
||||
taskKind: "agent-detail-replacement-batch",
|
||||
projectId,
|
||||
chatId,
|
||||
model,
|
||||
prompt: strategy === "product_only"
|
||||
? "仅替换参考详情图中的产品主体"
|
||||
: buildAgentSlotPrompt({
|
||||
workflowPrompt: prompt,
|
||||
slotPrompt: "",
|
||||
userSupplementPrompt: normalizeAgentSupplementPrompt(
|
||||
String(body.userPrompt || ""), "详情参考图替换",
|
||||
),
|
||||
includeSlotTitle: false,
|
||||
modeType: "detail_replacement",
|
||||
}),
|
||||
userPrompt: String(body.userPrompt || "").slice(0, 12_000),
|
||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
||||
replacementStrategy: strategy,
|
||||
productImageUrls,
|
||||
modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || ""))
|
||||
? String(body.modelImageUrl) : null,
|
||||
references,
|
||||
};
|
||||
const submissionKey = detailReplacementSubmissionKey(taskRequest);
|
||||
taskRequest.submissionKey = submissionKey;
|
||||
|
||||
const duplicateId = await findActiveBatchId({
|
||||
userId: session.user.id, projectId, submissionKey,
|
||||
});
|
||||
if (duplicateId) {
|
||||
await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id });
|
||||
const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id });
|
||||
return NextResponse.json({ task: taskResponse(duplicate) });
|
||||
}
|
||||
|
||||
let task: Awaited<ReturnType<typeof createCanvasImageTask>>;
|
||||
try {
|
||||
task = await createCanvasImageTask({
|
||||
taskId, userId: session.user.id, request: taskRequest as unknown as Record<string, unknown>,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
// Two identical submissions raced past the lookup above; the database
|
||||
// unique index kept one of them, so answer with that batch.
|
||||
const racedId = await findActiveBatchId({
|
||||
userId: session.user.id, projectId, submissionKey,
|
||||
});
|
||||
if (!racedId) throw error;
|
||||
const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id });
|
||||
return NextResponse.json({ task: taskResponse(raced) });
|
||||
}
|
||||
const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id });
|
||||
const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null;
|
||||
if (
|
||||
!isDetailReplacementBatchRequest(storedRequest) ||
|
||||
storedRequest.projectId !== projectId ||
|
||||
(storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey)
|
||||
) {
|
||||
return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 });
|
||||
}
|
||||
await db.insert(message).values({
|
||||
id: taskId,
|
||||
chatId,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
}).onConflictDoNothing({ target: message.id });
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
||||
return NextResponse.json({ task: taskResponse(current) });
|
||||
} catch (error) {
|
||||
console.error("[Detail Replacement Batch] Failed to start", error);
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelEntries,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||
|
||||
const requestSchema = z.object({
|
||||
prompt: z.string().min(1).max(5000),
|
||||
@ -52,6 +53,16 @@ export async function POST(request: Request) {
|
||||
{ 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 unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
||||
|
||||
@ -1,385 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createMediaAssetRelation,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
getPointsSystemConfig,
|
||||
getSystemSetting,
|
||||
upsertMediaAsset,
|
||||
} from "@/lib/db/queries";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
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");
|
||||
|
||||
// 角度编辑API配置类型
|
||||
type AngleEditConfig = {
|
||||
enabled?: boolean;
|
||||
falApiKey?: string;
|
||||
loraScale?: number;
|
||||
inferenceSteps?: number;
|
||||
guidanceScale?: number;
|
||||
maxImageSize?: number;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
// 设置API路由最大执行时间为3分钟(180秒)
|
||||
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
|
||||
/**
|
||||
* 压缩图片生成缩略图
|
||||
*/
|
||||
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const sharp = await import("sharp");
|
||||
|
||||
const thumbnailBuffer = await sharp.default(buffer)
|
||||
.resize(500, null, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
logger.debug("thumbnail_created", {
|
||||
originalBytes: buffer.length,
|
||||
thumbnailBytes: thumbnailBuffer.length,
|
||||
});
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
logger.warn("thumbnail_creation_failed", error);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 图片上传到 S3
|
||||
*/
|
||||
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).slice(2, 8);
|
||||
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
|
||||
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
|
||||
|
||||
logger.debug("source_image_prepared", { originalBytes: buffer.length });
|
||||
|
||||
const thumbnailBuffer = await createThumbnail(buffer);
|
||||
|
||||
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
|
||||
thumbnailBuffer.byteOffset,
|
||||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const originalArrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
|
||||
logger.debug("storage_upload_started", {
|
||||
hasOriginal: originalArrayBuffer.byteLength > 0,
|
||||
hasThumbnail: thumbnailArrayBuffer.byteLength > 0,
|
||||
prefix,
|
||||
});
|
||||
const [thumbnailResult, originalResult] = await Promise.all([
|
||||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||||
uploadFile(originalFilename, originalArrayBuffer),
|
||||
]);
|
||||
|
||||
logger.info("storage_upload_completed", {
|
||||
originalUrl: originalResult.url,
|
||||
thumbnailUrl: thumbnailResult.url,
|
||||
});
|
||||
|
||||
return {
|
||||
thumbnail: thumbnailResult.url,
|
||||
original: originalResult.url,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("storage_upload_failed", error);
|
||||
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 验证用户登录
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
logger.info("request_received", { authenticated: true });
|
||||
|
||||
// 2. 从数据库获取配置
|
||||
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
|
||||
const config = (angleEditConfig?.value as AngleEditConfig) || {};
|
||||
|
||||
// 验证功能是否启用
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 API Key
|
||||
if (!config.falApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("config_loaded", {
|
||||
enabled: Boolean(config.enabled),
|
||||
hasApiKey: Boolean(config.falApiKey),
|
||||
loraScale: config.loraScale || 1,
|
||||
});
|
||||
|
||||
// 3. 解析请求
|
||||
const body = await request.json();
|
||||
const {
|
||||
image_url,
|
||||
image_base64,
|
||||
// 数值参数 (官方 API 格式)
|
||||
horizontal_angle, // 0-360 度
|
||||
vertical_angle, // -30 到 90 度
|
||||
zoom, // 0-10
|
||||
lora_scale,
|
||||
} = body;
|
||||
|
||||
if (!image_url && !image_base64) {
|
||||
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 使用请求中的参数或配置中的默认值
|
||||
const finalLoraScale = lora_scale ?? config.loraScale ?? 1;
|
||||
const finalHorizontalAngle = horizontal_angle ?? 0;
|
||||
const finalVerticalAngle = vertical_angle ?? 0;
|
||||
const finalZoom = zoom ?? 5;
|
||||
|
||||
logger.info("request_parameters_normalized", {
|
||||
hasBase64Input: Boolean(image_base64),
|
||||
hasUrlInput: Boolean(image_url),
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
loraScale: finalLoraScale,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
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. 准备图片输入
|
||||
let imageInput: string;
|
||||
if (image_url) {
|
||||
imageInput = image_url;
|
||||
} else {
|
||||
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
|
||||
imageInput = `data:${mimeType};base64,${image_base64}`;
|
||||
}
|
||||
|
||||
// 5. 动态导入 fal.ai SDK 并调用官方多角度 API
|
||||
logger.info("provider_call_started", {
|
||||
modelId: "fal-ai/qwen-image-edit-2511-multiple-angles",
|
||||
});
|
||||
|
||||
const fal = await import("@fal-ai/serverless-client");
|
||||
|
||||
fal.config({
|
||||
credentials: config.falApiKey,
|
||||
});
|
||||
|
||||
// 使用官方多角度 API 端点
|
||||
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
|
||||
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
|
||||
input: {
|
||||
image_urls: [imageInput], // 官方使用 image_urls (数组)
|
||||
horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
|
||||
vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
|
||||
zoom: finalZoom, // 缩放 0-10
|
||||
lora_scale: finalLoraScale, // LoRA 强度
|
||||
num_inference_steps: config.inferenceSteps || 28,
|
||||
guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5
|
||||
enable_safety_checker: true,
|
||||
output_format: "png",
|
||||
},
|
||||
logs: true,
|
||||
onQueueUpdate: (update) => {
|
||||
if (update.status === "IN_PROGRESS") {
|
||||
logger.debug("provider_progress", { status: update.status });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 6. 处理结果
|
||||
const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
|
||||
const outputImage = apiResult.images?.[0];
|
||||
if (!outputImage?.url) {
|
||||
throw new Error("API 未返回有效图片");
|
||||
}
|
||||
|
||||
const outputImageUrl = outputImage.url;
|
||||
logger.info("provider_call_completed", {
|
||||
hasGeneratedPrompt: Boolean(apiResult.prompt),
|
||||
imageCount: apiResult.images?.length ?? 0,
|
||||
outputImageUrl,
|
||||
});
|
||||
|
||||
// 7. 下载图片并上传到我们的 S3
|
||||
const imageResponse = await fetch(outputImageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
|
||||
}
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const imageBase64Result = imageBuffer.toString("base64");
|
||||
|
||||
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
|
||||
logger.info("result_image_persisted", {
|
||||
originalUrl: s3Result.original,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
});
|
||||
|
||||
// 8. 保存到数据库
|
||||
const processingTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
const [savedImage] = await db
|
||||
.insert(generatedImage)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
originalUrl: s3Result.original,
|
||||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.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 {
|
||||
const sourceAssets =
|
||||
typeof image_url === "string" && image_url.trim()
|
||||
? await getMediaAssetsByUserAndUrls({
|
||||
userId: session.user.id,
|
||||
urls: [image_url],
|
||||
})
|
||||
: [];
|
||||
const media = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
sourceType: "edited",
|
||||
status: "ready",
|
||||
originalUrl: s3Result.original,
|
||||
previewUrl: s3Result.original,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80),
|
||||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
sourceTable: "GeneratedImage",
|
||||
sourceRowId: savedImage.id,
|
||||
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||||
metadata: {
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
});
|
||||
if (media) {
|
||||
for (const sourceAsset of sourceAssets) {
|
||||
await createMediaAssetRelation({
|
||||
userId: session.user.id,
|
||||
fromAssetId: sourceAsset.id,
|
||||
toAssetId: media.id,
|
||||
relationType: "edited_from",
|
||||
metadata: {
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (assetError) {
|
||||
logger.warn("media_asset_registration_failed", assetError);
|
||||
}
|
||||
|
||||
logger.info("request_completed", {
|
||||
processingSeconds: Number(processingTime.toFixed(2)),
|
||||
});
|
||||
|
||||
// 9. 返回结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
image_url: s3Result.original,
|
||||
thumbnail_url: s3Result.thumbnail,
|
||||
image_base64: imageBase64Result,
|
||||
processing_time: processingTime,
|
||||
angles: {
|
||||
horizontal_angle: finalHorizontalAngle,
|
||||
vertical_angle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
prompt: apiResult.prompt,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error("request_failed", error);
|
||||
|
||||
// 输出详细的错误信息
|
||||
if (error && typeof error === "object" && "status" in error) {
|
||||
const apiError = error as { status?: number; body?: unknown; message?: string };
|
||||
logger.warn("provider_error_summary", {
|
||||
bodyType: typeof apiError.body,
|
||||
hasBody: apiError.body != null,
|
||||
message: apiError.message,
|
||||
status: apiError.status,
|
||||
});
|
||||
|
||||
if (apiError.status === 403) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。",
|
||||
details: apiError.body,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "角度编辑处理失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return executeAngleEditPOST(request, session.user.id);
|
||||
}
|
||||
|
||||
@ -7,9 +7,9 @@ import {
|
||||
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
|
||||
CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||
CANVAS_SKILL_TAGS_SETTING_KEY,
|
||||
type CanvasSkillTag,
|
||||
normalizeCanvasSkillTagSettings,
|
||||
} 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}`;
|
||||
@ -32,18 +32,6 @@ function normalizeSkillTagOrder(value: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function applySkillTagOrder(tags: CanvasSkillTag[], order: string[]) {
|
||||
if (tags.length < 2 || order.length === 0) return tags;
|
||||
|
||||
const tagById = new Map(tags.map((tag) => [tag.id, tag]));
|
||||
const ordered = order
|
||||
.map((id) => tagById.get(id))
|
||||
.filter((tag): tag is CanvasSkillTag => Boolean(tag));
|
||||
const orderedIds = new Set(ordered.map((tag) => tag.id));
|
||||
const remaining = tags.filter((tag) => !orderedIds.has(tag.id));
|
||||
return [...ordered, ...remaining];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth().catch(() => null);
|
||||
@ -71,7 +59,7 @@ export async function GET() {
|
||||
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
|
||||
return NextResponse.json({
|
||||
...settings,
|
||||
tags: applySkillTagOrder(settings.tags, userOrder),
|
||||
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
|
||||
userOrder,
|
||||
});
|
||||
} catch (error) {
|
||||
@ -133,7 +121,7 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
userOrder: tagIds,
|
||||
tags: applySkillTagOrder(settings.tags, tagIds),
|
||||
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasSkillTags API] Save order error:", error);
|
||||
@ -152,10 +140,12 @@ export async function PUT(request: NextRequest) {
|
||||
const normalized = normalizeCanvasSkillTagSettings(body);
|
||||
const enabled = normalized.enabled;
|
||||
const title = normalized.title;
|
||||
const categories = normalized.categories;
|
||||
const tags = normalized.tags;
|
||||
const nextValue = {
|
||||
enabled,
|
||||
title,
|
||||
categories,
|
||||
tags,
|
||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||
};
|
||||
@ -183,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) {
|
||||
console.error("[CanvasSkillTags API] Update error:", error);
|
||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||
|
||||
@ -14,6 +14,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
||||
"worldlabs.ai",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
"scqny.jiansun.vip",
|
||||
"blob.vercel-storage.com",
|
||||
"aliyuncs.com",
|
||||
"amazonaws.com",
|
||||
|
||||
65
app/api/canvas/[id]/image-archives/route.ts
Normal file
65
app/api/canvas/[id]/image-archives/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
|
||||
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
|
||||
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
|
||||
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const [project] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
|
||||
.limit(1);
|
||||
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
||||
const body = await request.json();
|
||||
if (
|
||||
!Array.isArray(body.urls) ||
|
||||
body.urls.length > 100 ||
|
||||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
|
||||
) {
|
||||
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
|
||||
}
|
||||
const urls: string[] = [...new Set<string>(body.urls)];
|
||||
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
|
||||
const source = imageArchiveTask.sourceUrl;
|
||||
const rows = await db
|
||||
.select({
|
||||
sourceUrl: imageArchiveTask.sourceUrl,
|
||||
targetUrl: imageArchiveTask.targetUrl,
|
||||
status: imageArchiveTask.status,
|
||||
})
|
||||
.from(imageArchiveTask)
|
||||
.where(
|
||||
and(
|
||||
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
|
||||
sql`(
|
||||
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
|
||||
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
|
||||
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
|
||||
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
|
||||
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
|
||||
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
|
||||
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
|
||||
)`,
|
||||
),
|
||||
);
|
||||
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
|
||||
const replacements: Record<string, string> = {};
|
||||
const pending: string[] = [];
|
||||
for (const url of urls) {
|
||||
const row = byKey.get(imageArchiveSourceKey(url));
|
||||
if (row?.targetUrl) replacements[url] = row.targetUrl;
|
||||
else if (row && row.status !== "abandoned") pending.push(url);
|
||||
}
|
||||
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
|
||||
return NextResponse.json(
|
||||
{ replacements, pending },
|
||||
{ headers: { "Cache-Control": "private, no-store" } },
|
||||
);
|
||||
}
|
||||
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" } }
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { auth } from '@/app/(auth)/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { canvasProject } from '@/lib/db/schema';
|
||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
buildStaleCanvasSaveConflictPayload,
|
||||
@ -102,7 +103,7 @@ export async function GET(
|
||||
// 更新画布(支持 upsert:如果不存在则创建)
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
@ -116,29 +117,34 @@ export async function PUT(
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { title, description, thumbnail, canvasData } = body;
|
||||
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const { title, description, canvasData } = body;
|
||||
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
|
||||
const baseCanvasRevision =
|
||||
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const workspaceType =
|
||||
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
|
||||
? resolveWorkspaceType(body.workspaceType)
|
||||
: undefined;
|
||||
|
||||
// 先检查画布是否存在
|
||||
const [existingProject] = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
return await db.transaction(async (tx) => {
|
||||
const [existingProject] = await tx
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if (existingProject) {
|
||||
.for("update");
|
||||
if (
|
||||
existingProject &&
|
||||
canvasData !== undefined &&
|
||||
baseCanvasRevision &&
|
||||
isStaleCanvasSaveBaseRevision({
|
||||
@ -149,71 +155,66 @@ export async function PUT(
|
||||
) {
|
||||
return NextResponse.json(
|
||||
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
|
||||
{ status: 409 }
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingProject) {
|
||||
// The parent must exist before node/asset foreign keys are synchronized.
|
||||
const [created] = await tx
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
id,
|
||||
userId: session.user.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
||||
workspaceType: workspaceType || "canvas",
|
||||
canvasData: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
.onConflictDoNothing({ target: canvasProject.id })
|
||||
.returning();
|
||||
if (!created)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布已被创建,请重新加载后保存" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const syncedCanvasData =
|
||||
canvasData !== undefined
|
||||
? await syncCanvasNodesForProject({
|
||||
projectId: id,
|
||||
userId: session.user.id,
|
||||
canvasData,
|
||||
})
|
||||
? await syncCanvasNodesForProject(
|
||||
{ projectId: id, userId: session.user.id, canvasData },
|
||||
tx,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// 更新现有画布
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
||||
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
|
||||
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
|
||||
|
||||
const [updatedProject] = await db
|
||||
if (syncedCanvasData !== undefined)
|
||||
updateData.canvasData = syncedCanvasData;
|
||||
const [saved] = await tx
|
||||
.update(canvasProject)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
)
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, data: updatedProject });
|
||||
} else {
|
||||
const syncedCanvasData =
|
||||
canvasData !== undefined
|
||||
? await syncCanvasNodesForProject({
|
||||
projectId: id,
|
||||
userId: session.user.id,
|
||||
canvasData,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// 创建新画布(使用指定 ID)
|
||||
const [newProject] = await db
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
id, // 使用指定的 ID
|
||||
userId: session.user.id,
|
||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
||||
description,
|
||||
workspaceType: workspaceType || "canvas",
|
||||
canvasData: syncedCanvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, data: newProject });
|
||||
}
|
||||
return NextResponse.json({ success: true, data: saved });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新/创建画布失败:', error);
|
||||
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
|
||||
console.error("更新/创建画布失败:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "更新画布失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
app/api/canvas/[id]/tvstar-sessions/route.ts
Normal file
57
app/api/canvas/[id]/tvstar-sessions/route.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { and, desc, eq, lte } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { tvstarSession } from "@/lib/db/schema";
|
||||
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
|
||||
import { tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function authorize(projectId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
|
||||
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
|
||||
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
|
||||
}
|
||||
return { userId: session.user.id };
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const access = await authorize(id);
|
||||
if (access.error) return access.error;
|
||||
const rows = await db.select({ snapshot: tvstarSession.snapshot, shareToken: tvstarSession.shareToken })
|
||||
.from(tvstarSession)
|
||||
.where(and(eq(tvstarSession.userId, access.userId!), eq(tvstarSession.projectId, id)))
|
||||
.orderBy(desc(tvstarSession.clientUpdatedAt)).limit(20);
|
||||
return NextResponse.json({ sessions: rows.map(({ snapshot, shareToken }) => ({
|
||||
...(snapshot as Record<string, unknown>), shareToken: shareToken || undefined,
|
||||
})) }, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const access = await authorize(id);
|
||||
if (access.error) return access.error;
|
||||
const raw = await request.text();
|
||||
if (raw.length > 900_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
|
||||
let body: unknown;
|
||||
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
|
||||
const parsed = z.object({ sessions: z.array(tvstarSessionSchema).max(20) }).safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
|
||||
for (const session of parsed.data.sessions) {
|
||||
const clientUpdatedAt = new Date(Math.min(session.updatedAt, Date.now() + 60_000));
|
||||
await db.insert(tvstarSession).values({
|
||||
id: session.id, projectId: id, userId: access.userId!, title: session.title,
|
||||
snapshot: session, clientUpdatedAt,
|
||||
}).onConflictDoNothing({ target: tvstarSession.id });
|
||||
await db.update(tvstarSession).set({ title: session.title, snapshot: session, clientUpdatedAt, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(tvstarSession.id, session.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!),
|
||||
lte(tvstarSession.clientUpdatedAt, clientUpdatedAt),
|
||||
));
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
58
app/api/canvas/[id]/tvstar-sessions/share/route.ts
Normal file
58
app/api/canvas/[id]/tvstar-sessions/share/route.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { tvstarSession } from "@/lib/db/schema";
|
||||
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
|
||||
import { publicTVstarSnapshot, tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function owner(projectId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
|
||||
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
|
||||
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
|
||||
}
|
||||
return { userId: session.user.id };
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const access = await owner(id);
|
||||
if (access.error) return access.error;
|
||||
const raw = await request.text();
|
||||
if (raw.length > 300_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
|
||||
let body: unknown;
|
||||
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
|
||||
const parsed = z.object({ session: tvstarSessionSchema }).safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
|
||||
const current = parsed.data.session;
|
||||
const condition = and(eq(tvstarSession.id, current.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!));
|
||||
await db.insert(tvstarSession).values({
|
||||
id: current.id, projectId: id, userId: access.userId!, title: current.title,
|
||||
snapshot: current, clientUpdatedAt: new Date(Math.min(current.updatedAt, Date.now() + 60_000)),
|
||||
}).onConflictDoNothing({ target: tvstarSession.id });
|
||||
const existing = (await db.select({ shareToken: tvstarSession.shareToken }).from(tvstarSession).where(condition).limit(1))[0];
|
||||
if (!existing) return NextResponse.json({ error: "对话不存在" }, { status: 404 });
|
||||
const token = existing.shareToken || randomBytes(24).toString("base64url");
|
||||
if (!existing.shareToken) {
|
||||
await db.update(tvstarSession).set({
|
||||
shareToken: token, sharedSnapshot: publicTVstarSnapshot(current), sharedAt: new Date(), updatedAt: new Date(),
|
||||
}).where(condition);
|
||||
}
|
||||
return NextResponse.json({ url: `/tvstar/share/${token}`, token });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const access = await owner(id);
|
||||
if (access.error) return access.error;
|
||||
const sessionId = new URL(request.url).searchParams.get("sessionId") || "";
|
||||
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "对话 ID 无效" }, { status: 400 });
|
||||
await db.update(tvstarSession).set({ shareToken: null, sharedSnapshot: null, sharedAt: null, updatedAt: new Date() })
|
||||
.where(and(eq(tvstarSession.id, sessionId), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!)));
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
40
app/api/canvas/agent-capabilities/route.ts
Normal file
40
app/api/canvas/agent-capabilities/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const parsed = z.string().uuid().safeParse(new URL(request.url).searchParams.get("projectId"));
|
||||
if (!parsed.success) return NextResponse.json({ error: "画布编号无效" }, { status: 400 });
|
||||
const [project] = await db
|
||||
.select({ id: canvasProject.id, workspace: canvasProject.workspaceType })
|
||||
.from(canvasProject)
|
||||
.where(and(eq(canvasProject.id, parsed.data), eq(canvasProject.userId, session.user.id)))
|
||||
.limit(1);
|
||||
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
||||
const config = normalizeVideoCanvasFunctionConfig(
|
||||
(await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }))?.value,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
workspace: project.workspace,
|
||||
projectServices: PROJECT_AGENT_OPERATIONS.map(item => ({ ...item, authorized: projectOperationAuthorized(item) })),
|
||||
utilityTools: ["getWeather", "createDocument", "updateDocument", "requestSuggestions", "loadProjectSkill"],
|
||||
capabilities: getCanvasAgentAccessManifest(config).filter(
|
||||
(item) => item.workspace === (project.workspace === "video" ? "video" : "canvas"),
|
||||
),
|
||||
},
|
||||
{ headers: { "Cache-Control": "private, no-store" } },
|
||||
);
|
||||
}
|
||||
491
app/api/canvas/agent-chat/route.ts
Normal file
491
app/api/canvas/agent-chat/route.ts
Normal file
@ -0,0 +1,491 @@
|
||||
import { agentModelCandidates } from "@/lib/ai/runtime/agent-model-failover";
|
||||
import { agentModelCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
|
||||
import { createAgentVisionExecutor } from "@/lib/ai/runtime/agent-vision-failover";
|
||||
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
||||
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
|
||||
import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools";
|
||||
import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization";
|
||||
import {
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
generateText,
|
||||
} from "ai";
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { siteSettings, canvasProject } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { postRequestBodySchema } from "@/app/(chat)/api/chat/schema";
|
||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import {
|
||||
getAgentAiModelWithProvider,
|
||||
getAccessibleAgentPresetById,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getChatById,
|
||||
getMessagesByChatId,
|
||||
getMessageCountByUserId,
|
||||
getSiteSettings,
|
||||
saveChat,
|
||||
saveMessages,
|
||||
} from "@/lib/db/queries";
|
||||
import { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
|
||||
import { canvasAgentReplyText } from "@/lib/ai/zcode-agent/assistant-output";
|
||||
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
|
||||
import { newAgentRunSnapshot, publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||
import {
|
||||
acquireAgentRun,
|
||||
agentRunView,
|
||||
AgentRunError,
|
||||
checkpointAgentRun,
|
||||
consumeAgentRunInputs,
|
||||
heartbeatAgentRun,
|
||||
} from "@/lib/ai/canvas-agent-runtime/store";
|
||||
import { readOwnedCanvas } from "@/lib/ai/canvas-agent-runtime/canvas";
|
||||
import { buildCanvasRuntimeTools } from "@/lib/ai/canvas-agent-runtime/tools";
|
||||
import { canvasRuntimeSystemPrompt } from "@/lib/ai/canvas-agent-runtime/prompt";
|
||||
import { verifyCanvasAgentResults } from "@/lib/ai/canvas-agent-runtime/review";
|
||||
import { inferCanvasTaskRequirements } from "@/lib/ai/canvas-agent-runtime/requirements";
|
||||
import {
|
||||
resolveRuntimeConversationSources,
|
||||
persistRuntimeConversationResults,
|
||||
} from "@/lib/ai/canvas-agent-runtime/conversation";
|
||||
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
|
||||
import { canApplyCanvasAgentMutations } from "@/lib/canvas/agent-feature-flags";
|
||||
|
||||
import {
|
||||
videoAgentSettingsSchema,
|
||||
inferVideoTaskRequirements,
|
||||
videoNodeUrl,
|
||||
videoRuntimeSystemPrompt,
|
||||
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
||||
import { readOwnedVideoCanvas } from "@/lib/ai/canvas-agent-runtime/video-store";
|
||||
import {
|
||||
buildVideoRuntimeTools,
|
||||
createVideoRuntimePorts,
|
||||
} from "@/lib/ai/canvas-agent-runtime/video-tools";
|
||||
import { probeVideoMetadata } from "@/lib/ai/canvas-agent-runtime/video-probe";
|
||||
import { verifyVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-review";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 1200;
|
||||
const requestSchema = postRequestBodySchema.extend({
|
||||
resumeRunId: z.string().uuid().optional(),
|
||||
selectedImageModel: z.string().min(1).max(180).optional(),
|
||||
workspaceVariant: z.enum(["canvas", "video"]).default("canvas"),
|
||||
videoAgentSettings: videoAgentSettingsSchema.optional(),
|
||||
});
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const body = requestSchema.parse(await request.json());
|
||||
if (!shouldUseCanvasAgentRuntime(body))
|
||||
throw new AgentRunError("此请求不属于画布 Agent 任务", 400);
|
||||
const projectId = body.contextPayload!.projectId!;
|
||||
const [ownedProject] = await db.select({ userId: canvasProject.userId, workspaceType: canvasProject.workspaceType })
|
||||
.from(canvasProject).where(eq(canvasProject.id, projectId)).limit(1);
|
||||
if (!ownedProject || ownedProject.userId !== session.user.id) throw new AgentRunError("画布不存在", 404);
|
||||
if (!isZcodeWorkspaceAuthorized(ownedProject.workspaceType)) throw new AgentRunError("视频画布暂未授权给 ZCode Agent,请使用手工功能。", 403);
|
||||
const isVideo = ownedProject.workspaceType === "video";
|
||||
const preset = body.agentPresetId ? await getAccessibleAgentPresetById({ id: body.agentPresetId, userId: session.user.id }) : null;
|
||||
if (preset && String(preset.type) === "video") throw new AgentRunError("视频技能暂未授权给 ZCode Agent", 403);
|
||||
if (body.agentPresetId && (!preset || preset.status !== "active")) throw new AgentRunError("此 Agent 预设不可用", 403);
|
||||
const objective = body.message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!objective) throw new AgentRunError("请描述需要完成的创作任务", 400);
|
||||
const settings = await getSiteSettings();
|
||||
if (
|
||||
!settings.agentEnabled ||
|
||||
!canApplyCanvasAgentMutations(await getCanvasAgentFeatureFlagsFromSystemSettings())
|
||||
) {
|
||||
throw new AgentRunError("管理员尚未开启画布 Agent 操作", 403);
|
||||
}
|
||||
const [usageSettings] = await db.select().from(siteSettings).limit(1);
|
||||
if (usageSettings?.usageTimeEnabled) {
|
||||
const time = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).format(new Date());
|
||||
if (
|
||||
time < (usageSettings.usageTimeStart || "08:00") ||
|
||||
time > (usageSettings.usageTimeEnd || "20:00")
|
||||
)
|
||||
throw new AgentRunError(usageSettings.usageTimeMessage || "当前不在系统使用时间段内", 403);
|
||||
}
|
||||
const count = await getMessageCountByUserId({ id: session.user.id, differenceInHours: 24 });
|
||||
if (count > entitlementsByUserType[session.user.type].maxMessagesPerDay)
|
||||
throw new AgentRunError("今日对话次数已达到限额", 429);
|
||||
const imageCanvas = isVideo ? null : await readOwnedCanvas(session.user.id, projectId);
|
||||
if (imageCanvas?.project.workspaceType === "video")
|
||||
throw new AgentRunError("请使用视频画布 Agent 入口", 400);
|
||||
const videoCanvas = isVideo ? await readOwnedVideoCanvas(session.user.id, projectId) : null;
|
||||
const nodes = imageCanvas?.nodes || [];
|
||||
const availableIds = isVideo
|
||||
? videoCanvas!.nodes.map((node) => node.id)
|
||||
: nodes.map((node) => node.id);
|
||||
const selectedNodeIds = body.contextPayload?.selectedNodeIds || [];
|
||||
if (selectedNodeIds.some((id) => !availableIds.includes(id))) {
|
||||
throw new AgentRunError("选中图片尚未保存到画布,请等待保存完成后重试。");
|
||||
}
|
||||
const existingChat = await getChatById({ id: body.id });
|
||||
if (existingChat && existingChat.userId !== session.user.id)
|
||||
throw new AgentRunError("对话不存在", 404);
|
||||
if (!existingChat)
|
||||
await saveChat({
|
||||
id: body.id,
|
||||
userId: session.user.id,
|
||||
title: objective.slice(0, 80),
|
||||
visibility: "private",
|
||||
});
|
||||
const conversation = isVideo
|
||||
? {
|
||||
sourceNodeIds: selectedNodeIds,
|
||||
targetReason: "video-selected-nodes",
|
||||
previousContext: null,
|
||||
}
|
||||
: await resolveRuntimeConversationSources({
|
||||
userId: session.user.id,
|
||||
chatId: body.id,
|
||||
projectId,
|
||||
text: objective,
|
||||
parts: body.message.parts,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
});
|
||||
const candidates = agentModelCandidates(await getAgentAiModelWithProvider(), await getEnabledAiModelsWithProvider());
|
||||
const configured = candidates[0];
|
||||
if (!configured) throw new AgentRunError("请先在后台配置可用的多模态 Agent 对话模型", 503);
|
||||
let model = await getLanguageModelById(configured.model.id);
|
||||
for (const candidate of candidates.slice(1)) {
|
||||
if (model) break;
|
||||
model = await getLanguageModelById(candidate.model.id);
|
||||
}
|
||||
if (!model) throw new AgentRunError("Agent 模型暂不可用", 503);
|
||||
const visionModel = model;
|
||||
const executeVisionModel = createAgentVisionExecutor(candidates, agentModelCircuitStore, getLanguageModelById);
|
||||
// Validate the user's image choice independently of the Agent planning model.
|
||||
if (body.selectedImageModel) {
|
||||
const images = await getStyleProImageModelEntries();
|
||||
if (!images.some(({ model }) => model.id === body.selectedImageModel && model.healthStatus !== "auto_disabled"))
|
||||
throw new AgentRunError("你选择的生图模型当前不可用,请重新选择;不会自动换模型生成。", 400);
|
||||
}
|
||||
const assistantMessageId = crypto.randomUUID();
|
||||
const previousVideoMessages =
|
||||
existingChat
|
||||
? (await getMessagesByChatId({ id: body.id })).slice(-6).map((message) => ({
|
||||
role: message.role as "user" | "assistant",
|
||||
content: (message.parts as Array<{ type: string; text?: string }>)
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text || "")
|
||||
.join("\n")
|
||||
.slice(0, 4000),
|
||||
}))
|
||||
: [];
|
||||
const initialSnapshot = newAgentRunSnapshot(objective, [
|
||||
...previousVideoMessages,
|
||||
{ role: "user", content: objective },
|
||||
]);
|
||||
initialSnapshot.workspace = isVideo ? "video" : "canvas";
|
||||
if (isVideo) {
|
||||
initialSnapshot.videoRequirements = inferVideoTaskRequirements(objective);
|
||||
initialSnapshot.videoSettings = videoAgentSettingsSchema.parse({
|
||||
...body.videoAgentSettings,
|
||||
...(initialSnapshot.videoRequirements.duration
|
||||
? { duration: initialSnapshot.videoRequirements.duration }
|
||||
: {}),
|
||||
...(initialSnapshot.videoRequirements.ratio
|
||||
? { ratio: initialSnapshot.videoRequirements.ratio }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
initialSnapshot.sourceNodeIds = conversation.sourceNodeIds;
|
||||
initialSnapshot.requirements = {
|
||||
...inferCanvasTaskRequirements(objective, initialSnapshot.sourceNodeIds.length),
|
||||
sourceCount: initialSnapshot.sourceNodeIds.length,
|
||||
};
|
||||
const run = await acquireAgentRun({
|
||||
id: body.message.id,
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
chatId: body.id,
|
||||
assistantMessageId,
|
||||
resumeRunId: body.resumeRunId,
|
||||
snapshot: initialSnapshot,
|
||||
});
|
||||
const snapshot = run.snapshot;
|
||||
snapshot.imagePreferences = {
|
||||
...snapshot.imagePreferences,
|
||||
// Each submitted turn captures the current controls, including switching back to Auto.
|
||||
modelId: body.selectedImageModel,
|
||||
parameters: body.imageParams || snapshot.imagePreferences?.parameters,
|
||||
};
|
||||
const taskSourceNodeIds = snapshot.sourceNodeIds ?? selectedNodeIds;
|
||||
const context = {
|
||||
recentConversation: previousVideoMessages,
|
||||
preset: preset ? { name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, defaultParams: preset.defaultParams, slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: slot.defaultParams })) } : null,
|
||||
videoSettings: snapshot.videoSettings,
|
||||
selectedNodeIds: taskSourceNodeIds,
|
||||
nodes: nodes
|
||||
.filter((node) => taskSourceNodeIds.includes(node.id))
|
||||
.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: node.position,
|
||||
size: node.size,
|
||||
title: node.data.title,
|
||||
})),
|
||||
skillInstructions: body.hiddenPrompt || undefined,
|
||||
imagePreferences: snapshot.imagePreferences,
|
||||
targetReason: conversation.targetReason,
|
||||
previousConversationContext: conversation.previousContext,
|
||||
};
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: body.message.id,
|
||||
chatId: body.id,
|
||||
role: "user",
|
||||
parts: body.message.parts,
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
let archiveVideoResults: (() => Promise<void>) | undefined;
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const emit = (type: string, data: unknown) => writer.write({ type, data, ...(type === "data-canvasAgentRun" ? { transient: true } : {}) } as never);
|
||||
let cancelled = false;
|
||||
let leaseLost = false;
|
||||
const activeModelController = new AbortController();
|
||||
const withModel: typeof executeVisionModel = (execute, signal) => executeVisionModel(execute,
|
||||
signal ? AbortSignal.any([signal, activeModelController.signal]) : activeModelController.signal);
|
||||
const heartbeat = setInterval(() => {
|
||||
void heartbeatAgentRun(run)
|
||||
.then((current) => {
|
||||
if (!current) leaseLost = true;
|
||||
if (current?.cancelRequested) cancelled = true;
|
||||
if (cancelled || leaseLost) activeModelController?.abort();
|
||||
})
|
||||
.catch(() => {
|
||||
leaseLost = true;
|
||||
activeModelController?.abort();
|
||||
});
|
||||
emit("data-keepalive", { label: "canvas-agent-runtime", ts: Date.now() });
|
||||
}, 10_000);
|
||||
const textId = crypto.randomUUID();
|
||||
writer.write({ type: "start", messageId: assistantMessageId });
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
let streamedText = "";
|
||||
const emitAssistantText = (delta: string) => {
|
||||
streamedText += delta;
|
||||
writer.write({ type: "text-delta", id: textId, delta });
|
||||
};
|
||||
try {
|
||||
const videoPorts = isVideo
|
||||
? await createVideoRuntimePorts({
|
||||
userId: session.user.id,
|
||||
chatId: body.id,
|
||||
projectId,
|
||||
runId: run.id,
|
||||
assistantMessageId,
|
||||
request,
|
||||
snapshot,
|
||||
emit,
|
||||
})
|
||||
: null;
|
||||
archiveVideoResults = videoPorts?.archiveResults;
|
||||
const tools = isVideo
|
||||
? buildVideoRuntimeTools({
|
||||
runId: run.id,
|
||||
projectId,
|
||||
selectedNodeIds,
|
||||
snapshot,
|
||||
ports: videoPorts!,
|
||||
})
|
||||
: await buildCanvasRuntimeTools({
|
||||
userId: session.user.id,
|
||||
chatId: body.id,
|
||||
projectId,
|
||||
runId: run.id,
|
||||
assistantMessageId,
|
||||
selectedNodeIds: taskSourceNodeIds,
|
||||
request,
|
||||
model: visionModel || model,
|
||||
supportsVision: Boolean(visionModel),
|
||||
withModel,
|
||||
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
|
||||
imagePreferences: snapshot.imagePreferences,
|
||||
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
|
||||
operationSelectedNodeIds: selectedNodeIds,
|
||||
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
|
||||
"\n",
|
||||
),
|
||||
emit,
|
||||
});
|
||||
await registerProjectAgentTools({ session, projectId, request, objective: snapshot.objective,
|
||||
definitions: tools.definitions, executable: tools.executable, emit,
|
||||
imagePreferences: snapshot.imagePreferences });
|
||||
const gateway = await createZcodeModelGateway(configured, undefined, candidates);
|
||||
const status = await runZcodeCanvasTask({
|
||||
runId: run.id, snapshot, definitions: tools.definitions,
|
||||
instructions: (isVideo ? videoRuntimeSystemPrompt : canvasRuntimeSystemPrompt)(snapshot.objective, snapshot.summary, snapshot.inputs.map(input => input.text), context),
|
||||
context, gateway, signal: activeModelController.signal,
|
||||
onAssistantText: emitAssistantText,
|
||||
tools: tools.executable,
|
||||
checkpoint: async (state, stateStatus) => {
|
||||
if (leaseLost) throw new AgentRunError("任务执行已中断");
|
||||
const saved = await checkpointAgentRun(run, state, stateStatus);
|
||||
cancelled ||= saved.cancelRequested;
|
||||
emit("data-canvasAgentRun", agentRunView(saved));
|
||||
},
|
||||
consumeInputs: async (state) => {
|
||||
if (leaseLost) throw new AgentRunError("任务执行已中断");
|
||||
const control = await consumeAgentRunInputs(run, state);
|
||||
cancelled ||= control.cancelled;
|
||||
return { cancelled };
|
||||
},
|
||||
isCancelled: async () => {
|
||||
const current = await heartbeatAgentRun(run);
|
||||
if (!current) throw new AgentRunError("任务执行已中断");
|
||||
cancelled ||= current.cancelRequested;
|
||||
return cancelled;
|
||||
},
|
||||
verify: async (state) =>
|
||||
isVideo
|
||||
? verifyVideoAgentResults({
|
||||
snapshot: state,
|
||||
nodes: (await readOwnedVideoCanvas(session.user.id, projectId)).nodes,
|
||||
inspect: async (node, requirements) => {
|
||||
const metadata = await probeVideoMetadata(videoNodeUrl(node));
|
||||
const report = await videoPorts!.analyze({
|
||||
mode: "video-analysis",
|
||||
prompt: `请基于真实抽帧画面检查主体、场景、镜头连贯性,说明不确定内容。用户要求:${requirements}`,
|
||||
imageUrls: [],
|
||||
videoUrls: [videoNodeUrl(node)],
|
||||
});
|
||||
const verdict = await generateText({
|
||||
model,
|
||||
system:
|
||||
'你是视频验收员。依据实际抽帧分析判断是否满足用户要求;不能从文件名或提示词判断。若报告无法读取真实帧,必须不通过。抽帧只能检查画面,不能证明音频、精确时长或完整运动,涉及这些要求无法确认时说明需要人工检查。仅返回 JSON {"passed":boolean,"issues":string[]}。',
|
||||
prompt: JSON.stringify({ requirements, report }),
|
||||
maxOutputTokens: 1200,
|
||||
maxRetries: 0,
|
||||
abortSignal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
state.inputTokens += verdict.usage.inputTokens || 0;
|
||||
state.outputTokens += verdict.usage.outputTokens || 0;
|
||||
const match = verdict.text.match(/\{[\s\S]*\}/);
|
||||
if (!match) throw new Error("视频检查未返回有效结果");
|
||||
return {
|
||||
...z
|
||||
.object({ passed: z.boolean(), issues: z.array(z.string()).max(12) })
|
||||
.parse(JSON.parse(match[0])),
|
||||
...metadata,
|
||||
};
|
||||
},
|
||||
})
|
||||
: verifyCanvasAgentResults({
|
||||
snapshot: state,
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
model: visionModel || model,
|
||||
supportsVision: Boolean(visionModel),
|
||||
withModel,
|
||||
signal: activeModelController.signal,
|
||||
}),
|
||||
});
|
||||
const finalText = canvasAgentReplyText(snapshot);
|
||||
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status }));
|
||||
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
|
||||
if (!isVideo)
|
||||
await persistRuntimeConversationResults({
|
||||
userId: session.user.id,
|
||||
chatId: body.id,
|
||||
projectId,
|
||||
assistantMessageId,
|
||||
snapshot,
|
||||
}).catch((error) => {
|
||||
console.warn("[Canvas Agent] Conversation memory update failed", {
|
||||
runId: run.id,
|
||||
error: publicAgentError(error),
|
||||
});
|
||||
});
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: assistantMessageId,
|
||||
chatId: body.id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: finalText }, { type: "data-canvasAgentProgress", data: progress }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
emitAssistantText(finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : `\n\n${snapshot.message}`);
|
||||
} catch (error) {
|
||||
const statusText = publicAgentError(error);
|
||||
const text = canvasAgentReplyText({ ...snapshot, message: statusText });
|
||||
await checkpointAgentRun(run, { ...snapshot, message: statusText }, "needs_review").catch(
|
||||
() => undefined,
|
||||
);
|
||||
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: statusText }, status: "needs_review" }));
|
||||
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
|
||||
await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant",
|
||||
parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }],
|
||||
attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined);
|
||||
emitAssistantText(text.startsWith(streamedText) ? text.slice(streamedText.length) : `\n\n${statusText}`);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
writer.write({ type: "finish" });
|
||||
}
|
||||
},
|
||||
});
|
||||
// Browser disconnection does not cancel paid image work. Keep consuming on the server.
|
||||
const [client, background] = stream.tee();
|
||||
after(async () => {
|
||||
const reader = background.getReader();
|
||||
try {
|
||||
while (!(await reader.read()).done) {
|
||||
/* Drain the server branch. */
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await archiveVideoResults?.();
|
||||
}
|
||||
});
|
||||
return createUIMessageStreamResponse({
|
||||
stream: client,
|
||||
headers: { "Cache-Control": "no-store", "X-Accel-Buffering": "no" },
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: publicAgentError(error), message: publicAgentError(error) },
|
||||
{
|
||||
status:
|
||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/api/canvas/agent-runs/route.ts
Normal file
65
app/api/canvas/agent-runs/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
AgentRunError,
|
||||
agentRunView,
|
||||
assertAgentRunOwnership,
|
||||
controlAgentRun,
|
||||
getAgentRun,
|
||||
} from "@/lib/ai/canvas-agent-runtime/store";
|
||||
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const scope = z.object({
|
||||
chatId: z.string().uuid(),
|
||||
projectId: z.string().uuid(),
|
||||
runId: z.string().uuid().optional(),
|
||||
});
|
||||
const control = scope
|
||||
.extend({
|
||||
runId: z.string().uuid(),
|
||||
action: z.enum(["steer", "cancel"]),
|
||||
inputId: z.string().uuid().optional(),
|
||||
text: z.string().trim().min(1).max(2000).optional(),
|
||||
})
|
||||
.refine(
|
||||
(input) => input.action !== "steer" || Boolean(input.inputId && input.text),
|
||||
"补充要求缺少内容或请求号",
|
||||
);
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const input = scope.parse(Object.fromEntries(new URL(request.url).searchParams));
|
||||
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
|
||||
const run = await getAgentRun(session.user.id, input.chatId, input.projectId, input.runId);
|
||||
return NextResponse.json(
|
||||
{ run: run ? agentRunView(run) : null },
|
||||
{ headers: { "Cache-Control": "private, no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const input = control.parse(await request.json());
|
||||
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
|
||||
return NextResponse.json({ run: await controlAgentRun({ ...input, userId: session.user.id }) });
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
}
|
||||
function failure(error: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: publicAgentError(error) },
|
||||
{
|
||||
status:
|
||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
||||
},
|
||||
);
|
||||
}
|
||||
112
app/api/canvas/audio-separate/route.ts
Normal file
112
app/api/canvas/audio-separate/route.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { ffmpeg, mediaSafety, probeMedia } from "@/lib/video-canvas/smart-edit/local";
|
||||
import {
|
||||
remoteAudio,
|
||||
remoteAudioConfig,
|
||||
} from "@/lib/video-canvas/smart-edit/remote-audio";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 600;
|
||||
|
||||
const running = new Set<string>();
|
||||
const allowedStems = ["vocals", "background"] as const;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
if (!userId)
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
url?: unknown;
|
||||
stem?: unknown;
|
||||
} | null;
|
||||
const url = typeof body?.url === "string" ? body.url.trim() : "";
|
||||
const stem = typeof body?.stem === "string" ? body.stem : "";
|
||||
if (!/^https?:\/\//i.test(url) || !allowedStems.includes(stem as (typeof allowedStems)[number]))
|
||||
return NextResponse.json({ success: false, error: "参数不完整" }, { status: 400 });
|
||||
if (!remoteAudioConfig())
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "音频分离服务未配置,请联系管理员" },
|
||||
{ status: 503 }
|
||||
);
|
||||
const lockKey = `${userId}:${url}:${stem}`;
|
||||
if (running.has(lockKey) || running.size >= 3)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "已有分离任务在进行,请稍候" },
|
||||
{ status: 429 }
|
||||
);
|
||||
running.add(lockKey);
|
||||
let dir = "";
|
||||
try {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "canvas-separate-"));
|
||||
const input = path.join(dir, "input");
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(180000),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("素材下载失败,请确认素材仍可访问");
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (!bytes.length || bytes.length > 150 * 1024 * 1024)
|
||||
throw new Error("素材必须非空且不超过 125 MiB");
|
||||
await writeFile(input, bytes);
|
||||
const info = await probeMedia(input, 600);
|
||||
if (!info.hasAudio) throw new Error("当前视频没有音轨");
|
||||
const inputAudio = path.join(dir, "separation-input.wav");
|
||||
const durationSeconds = Math.round(info.duration / 1e6);
|
||||
await ffmpeg(
|
||||
[
|
||||
...mediaSafety,
|
||||
"-i",
|
||||
input,
|
||||
"-vn",
|
||||
"-t",
|
||||
String(durationSeconds),
|
||||
"-ac",
|
||||
"2",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-c:a",
|
||||
"pcm_f32le",
|
||||
inputAudio,
|
||||
],
|
||||
undefined,
|
||||
240000
|
||||
);
|
||||
const requestKey = createHash("sha256")
|
||||
.update(`${url}:${stem}:${(await stat(inputAudio)).size}`)
|
||||
.digest("hex");
|
||||
await remoteAudio("separate", inputAudio, userId, requestKey, dir);
|
||||
const stemFile = path.join(dir, `${stem}.wav`);
|
||||
const wav = await readFile(stemFile);
|
||||
if (wav.length < 1024) throw new Error("分离结果异常,请稍后重试");
|
||||
const uploaded = await uploadFile(
|
||||
`canvas-separate-${randomUUID()}.wav`,
|
||||
await new Blob([wav]).arrayBuffer()
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
url: uploaded.url,
|
||||
duration: Math.round(info.duration / 1e6),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "人声分离失败,原音轨已保留",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
running.delete(lockKey);
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
24
app/api/canvas/audio/models/route.ts
Normal file
24
app/api/canvas/audio/models/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { listCanvasAudioModels } from "@/lib/video-canvas/audio-generation";
|
||||
import { audioContract } from "@/lib/video-canvas/audio-contract";
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const models = await listCanvasAudioModels();
|
||||
return NextResponse.json(
|
||||
{
|
||||
models: models.map(({ model }) => ({
|
||||
id: model.id,
|
||||
modelId: model.modelId,
|
||||
name: model.displayName,
|
||||
...audioContract(model.modelId),
|
||||
})),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "音频模型读取失败,请稍后重试" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
266
app/api/canvas/clawd-chat/artifact/route.ts
Normal file
266
app/api/canvas/clawd-chat/artifact/route.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getMessageById, getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
normalizeOpenClawChatConfig,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 120;
|
||||
|
||||
const querySchema = z.object({
|
||||
chatId: z.string().uuid(),
|
||||
messageId: z.string().uuid(),
|
||||
index: z.coerce.number().int().min(0).max(127),
|
||||
download: z.enum(["0", "1"]).optional().default("0"),
|
||||
});
|
||||
|
||||
const MAX_REDIRECTS = 4;
|
||||
const RESPONSE_HEADERS = [
|
||||
"accept-ranges",
|
||||
"content-length",
|
||||
"content-range",
|
||||
"etag",
|
||||
"last-modified",
|
||||
] as const;
|
||||
|
||||
function isPrivateIpv4(address: string) {
|
||||
const octets = address.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value))) {
|
||||
return true;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpAddress(address: string) {
|
||||
if (isIP(address) === 4) return isPrivateIpv4(address);
|
||||
if (isIP(address) !== 6) return true;
|
||||
const normalized = address.toLowerCase();
|
||||
if (normalized.startsWith("::ffff:")) {
|
||||
return isPrivateIpv4(normalized.slice("::ffff:".length));
|
||||
}
|
||||
return (
|
||||
normalized === "::" ||
|
||||
normalized === "::1" ||
|
||||
normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") ||
|
||||
normalized.startsWith("fe8") ||
|
||||
normalized.startsWith("fe9") ||
|
||||
normalized.startsWith("fea") ||
|
||||
normalized.startsWith("feb") ||
|
||||
normalized.startsWith("ff")
|
||||
);
|
||||
}
|
||||
|
||||
async function validateTargetUrl(target: URL, trustedOrigin: string) {
|
||||
if (target.username || target.password) return false;
|
||||
if (target.origin === trustedOrigin) {
|
||||
return target.protocol === "http:" || target.protocol === "https:";
|
||||
}
|
||||
if (target.protocol !== "https:") return false;
|
||||
const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".local")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isIP(hostname)) return !isPrivateIpAddress(hostname);
|
||||
|
||||
const addresses = await lookup(hostname, { all: true, verbatim: true }).catch(
|
||||
() => []
|
||||
);
|
||||
return (
|
||||
addresses.length > 0 &&
|
||||
addresses.every((entry) => !isPrivateIpAddress(entry.address))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRangeHeader(value: string | null) {
|
||||
const range = String(value || "").trim();
|
||||
if (!range) return null;
|
||||
const match = range.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match || (!match[1] && !match[2])) return "";
|
||||
if (match[1] && match[2] && Number(match[1]) > Number(match[2])) return "";
|
||||
return range;
|
||||
}
|
||||
|
||||
function inferContentType(filename: string, declaredType: string) {
|
||||
if (declaredType) return declaredType;
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".glb")) return "model/gltf-binary";
|
||||
if (lower.endsWith(".gltf")) return "model/gltf+json";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function safeDownloadName(value: string) {
|
||||
const normalized = value.replace(/[\r\n"\\/]/g, "_").trim() || "artifact";
|
||||
return normalized.slice(0, 180);
|
||||
}
|
||||
|
||||
async function fetchArtifact(params: {
|
||||
request: Request;
|
||||
initialUrl: URL;
|
||||
method: "GET" | "HEAD";
|
||||
range: string | null;
|
||||
token: string;
|
||||
trustedOrigin: string;
|
||||
}) {
|
||||
let target = params.initialUrl;
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
if (!(await validateTargetUrl(target, params.trustedOrigin))) {
|
||||
throw new Error("不允许访问该文件地址");
|
||||
}
|
||||
const headers = new Headers({ Accept: "*/*" });
|
||||
if (params.range) headers.set("Range", params.range);
|
||||
if (target.origin === params.trustedOrigin && params.token) {
|
||||
headers.set("Authorization", `Bearer ${params.token}`);
|
||||
}
|
||||
const response = await fetch(target, {
|
||||
method: params.method,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
redirect: "manual",
|
||||
signal: params.request.signal,
|
||||
});
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) {
|
||||
return { response, target };
|
||||
}
|
||||
if (redirects === MAX_REDIRECTS) throw new Error("文件重定向次数过多");
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error("文件重定向地址无效");
|
||||
target = new URL(location, target);
|
||||
}
|
||||
throw new Error("文件地址无效");
|
||||
}
|
||||
|
||||
async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
||||
const session = await auth().catch(() => null);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = querySchema.safeParse({
|
||||
chatId: url.searchParams.get("chatId"),
|
||||
messageId: url.searchParams.get("messageId"),
|
||||
index: url.searchParams.get("index"),
|
||||
download: url.searchParams.get("download") || undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: parsed.data.chatId }).catch(() => null);
|
||||
if (!chat || chat.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const rows = await getMessageById({ id: parsed.data.messageId }).catch(
|
||||
() => []
|
||||
);
|
||||
const message = rows.find((row) => row.chatId === parsed.data.chatId);
|
||||
const metadata = normalizeClawdTaskMetadata(message?.videoMetadata);
|
||||
const artifact = metadata?.artifacts?.[parsed.data.index];
|
||||
if (!message || !artifact) {
|
||||
return NextResponse.json({ error: "文件不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
|
||||
() => null
|
||||
);
|
||||
const config = normalizeOpenClawChatConfig(setting?.value);
|
||||
let target: URL;
|
||||
let trustedOrigin: string;
|
||||
try {
|
||||
target = new URL(artifact.downloadUrl);
|
||||
trustedOrigin = new URL(config.baseUrl).origin;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "文件地址无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
const range = normalizeRangeHeader(request.headers.get("range"));
|
||||
if (range === "") {
|
||||
return NextResponse.json({ error: "Range 请求格式无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, target: finalTarget } = await fetchArtifact({
|
||||
request,
|
||||
initialUrl: target,
|
||||
method,
|
||||
range,
|
||||
token: config.token,
|
||||
trustedOrigin,
|
||||
});
|
||||
if (!response.ok && response.status !== 206) {
|
||||
return NextResponse.json(
|
||||
{ error: `文件读取失败(HTTP ${response.status})` },
|
||||
{ status: response.status >= 400 && response.status < 600 ? response.status : 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
for (const name of RESPONSE_HEADERS) {
|
||||
const value = response.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
headers.set(
|
||||
"content-type",
|
||||
response.headers.get("content-type") ||
|
||||
inferContentType(artifact.filename || finalTarget.pathname, artifact.mimeType)
|
||||
);
|
||||
headers.set("cache-control", "private, max-age=3600, must-revalidate");
|
||||
headers.set("x-content-type-options", "nosniff");
|
||||
if (parsed.data.download === "1") {
|
||||
const filename = safeDownloadName(artifact.filename || artifact.displayName);
|
||||
headers.set(
|
||||
"content-disposition",
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(method === "HEAD" ? null : response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[Clawd Artifact] Proxy failed:", {
|
||||
chatId: parsed.data.chatId,
|
||||
messageId: parsed.data.messageId,
|
||||
artifactIndex: parsed.data.index,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "文件读取失败" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return handleRequest(request, "GET");
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request) {
|
||||
return handleRequest(request, "HEAD");
|
||||
}
|
||||
@ -1,11 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById } from "@/lib/db/queries";
|
||||
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(),
|
||||
@ -15,7 +31,13 @@ const cancelSchema = z.object({
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
|
||||
@ -28,13 +50,43 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const active = cancelClawdRequest(
|
||||
buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
})
|
||||
);
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ canceled: true, active });
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
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;
|
||||
@ -9,13 +13,19 @@ export async function GET(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
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)))
|
||||
Math.min(500, Math.round(Number(url.searchParams.get("limit") || 100)))
|
||||
);
|
||||
|
||||
const chats = await getClawdChatsByUserId({
|
||||
|
||||
@ -1,34 +1,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
getChatById,
|
||||
getMessagesByChatId,
|
||||
getSystemSetting,
|
||||
getUserById,
|
||||
saveChat,
|
||||
saveMessages,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
normalizeOpenClawChatConfig,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import {
|
||||
buildClawdCancelKey,
|
||||
cancelClawdRequest,
|
||||
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 { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
|
||||
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 CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试";
|
||||
const DEFAULT_PROCESSING_HINT =
|
||||
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
||||
|
||||
export const maxDuration = 1800;
|
||||
|
||||
const requestSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
@ -36,12 +62,63 @@ const requestSchema = z.object({
|
||||
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 = z.infer<typeof requestSchema> & {
|
||||
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 对话";
|
||||
@ -57,17 +134,15 @@ function getOpenClawUserName(user: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}) {
|
||||
const name = String(user.name || "").trim();
|
||||
if (name) return name;
|
||||
|
||||
const email = String(user.email || "").trim();
|
||||
if (email) return email;
|
||||
|
||||
return user.id;
|
||||
return String(user.name || user.email || user.id).trim();
|
||||
}
|
||||
|
||||
function getOpenClawDepartmentName(department?: string | null) {
|
||||
return String(department || "").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 {
|
||||
@ -86,25 +161,39 @@ async function parseClawdChatRequest(
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||
return parsed.success ? { ...parsed.data, tableFiles: [] } : 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;
|
||||
|
||||
const metadataValue = formData.get("metadata");
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
||||
try {
|
||||
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") {
|
||||
metadata = parsedMetadata as Record<string, unknown>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
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({
|
||||
@ -113,68 +202,279 @@ async function parseClawdChatRequest(
|
||||
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;
|
||||
}
|
||||
|
||||
const tableFiles = formData
|
||||
.getAll("tableFiles")
|
||||
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
|
||||
|
||||
return { ...parsed.data, tableFiles };
|
||||
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
|
||||
);
|
||||
const setting = await getSystemSetting({
|
||||
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
return normalizeOpenClawChatConfig(setting?.value);
|
||||
}
|
||||
|
||||
function getOpenClawErrorCode(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
const raw = payload as Record<string, unknown>;
|
||||
const nestedError =
|
||||
raw.error && typeof raw.error === "object"
|
||||
? (raw.error as Record<string, unknown>)
|
||||
: null;
|
||||
const code = nestedError?.code || raw.code;
|
||||
return typeof code === "string" ? code.trim() : "";
|
||||
function getAnswer(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return "";
|
||||
}
|
||||
return String((payload as Record<string, unknown>).answer || "").trim();
|
||||
}
|
||||
|
||||
function getMetadataString(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
) {
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "string" ? value.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;
|
||||
}
|
||||
|
||||
function createCanceledResponse() {
|
||||
return NextResponse.json({ canceled: true }, { status: 499 });
|
||||
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: !params.sensitiveInput,
|
||||
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),
|
||||
artifacts: extractOpenClawArtifacts(payload, {
|
||||
baseUrl: params.config.baseUrl,
|
||||
}),
|
||||
});
|
||||
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,
|
||||
artifacts: outcome.artifacts,
|
||||
});
|
||||
} 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: "Unauthorized" }, { status: 401 });
|
||||
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) {
|
||||
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) {
|
||||
return NextResponse.json({ error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 });
|
||||
}
|
||||
if (!config.baseUrl || !config.token) {
|
||||
if (!config.enabled || !config.baseUrl || !config.token) {
|
||||
return NextResponse.json(
|
||||
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
|
||||
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
@ -185,13 +485,22 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const userMessageId = parsed.userMessageId || generateUUID();
|
||||
const cancelKey = buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
const existingTask = await getPersistedClawdTask({
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
});
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}).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<
|
||||
@ -221,10 +530,23 @@ export async function POST(request: Request) {
|
||||
message: parsed.message,
|
||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||
});
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
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({
|
||||
@ -236,220 +558,107 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const assistantMessageId = generateUUID();
|
||||
const conversationId = createConversationId(session.user.id, parsed.id);
|
||||
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
|
||||
const openClawUserName = getOpenClawUserName({
|
||||
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"),
|
||||
}));
|
||||
|
||||
const persistedTask = await persistPendingClawdTask({
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
assistantMessageId,
|
||||
displayMessage,
|
||||
attachments: messageAttachments,
|
||||
spreadsheetAttachments,
|
||||
skillTagId,
|
||||
skillTagName,
|
||||
processingHint,
|
||||
});
|
||||
if (
|
||||
persistedTask &&
|
||||
persistedTask.assistantMessageId !== assistantMessageId
|
||||
) {
|
||||
return NextResponse.json(
|
||||
buildTaskResponse({
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
assistantMessageId: persistedTask.assistantMessageId,
|
||||
status: persistedTask.status,
|
||||
processingHint:
|
||||
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
|
||||
}),
|
||||
{ status: 202, headers: { "Retry-After": "2" } }
|
||||
);
|
||||
}
|
||||
|
||||
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 openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department);
|
||||
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
||||
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
||||
const endpoint = `${config.baseUrl}/api/chat`;
|
||||
const cancelKey = buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
});
|
||||
|
||||
let upstreamPayload: unknown = null;
|
||||
let upstreamStatus = 502;
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
const handleRequestAbort = () => {
|
||||
cancelClawdRequest(cancelKey);
|
||||
};
|
||||
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation_id: conversationId,
|
||||
user_id: session.user.id,
|
||||
user_name: openClawUserName,
|
||||
username: openClawUserName,
|
||||
department: openClawDepartmentName,
|
||||
department_name: openClawDepartmentName,
|
||||
user_department: openClawDepartmentName,
|
||||
skill_tag_id: skillTagId || undefined,
|
||||
skill_tag_name: skillTagName || undefined,
|
||||
message: upstreamMessage,
|
||||
metadata: {
|
||||
source: "ai_chatbot_canvas",
|
||||
page: "/canvas",
|
||||
tenant: "default",
|
||||
chat_id: parsed.id,
|
||||
user_id: session.user.id,
|
||||
user_name: openClawUserName,
|
||||
username: openClawUserName,
|
||||
department: openClawDepartmentName,
|
||||
department_name: openClawDepartmentName,
|
||||
user_department: openClawDepartmentName,
|
||||
skill_tag_id: skillTagId || undefined,
|
||||
skill_tag_name: skillTagName || undefined,
|
||||
spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
|
||||
spreadsheet_markdown_truncated:
|
||||
spreadsheetMarkdownResult?.truncated || undefined,
|
||||
...parsed.metadata,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
request.signal.removeEventListener("abort", handleRequestAbort);
|
||||
unregisterActiveClawdRequest(cancelKey);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
upstreamStatus = response.status;
|
||||
upstreamPayload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorCode = getOpenClawErrorCode(upstreamPayload);
|
||||
console.error("[ClawdChat API] OpenClaw upstream error:", {
|
||||
status: response.status,
|
||||
code: errorCode || undefined,
|
||||
payload: upstreamPayload,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: CLAW_UPGRADE_RETRY_MESSAGE,
|
||||
code: errorCode || undefined,
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
unregisterActiveClawdRequest(cancelKey);
|
||||
const errorName =
|
||||
error && typeof error === "object" && "name" in error
|
||||
? String((error as { name?: unknown }).name || "")
|
||||
: "";
|
||||
const isAbortError = errorName === "AbortError";
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
console.info("[ClawdChat API] OpenClaw request canceled:", {
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
});
|
||||
return createCanceledResponse();
|
||||
}
|
||||
console.error("[ClawdChat API] OpenClaw request failed:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: CLAW_UPGRADE_RETRY_MESSAGE,
|
||||
},
|
||||
{ status: isAbortError ? 504 : upstreamStatus }
|
||||
);
|
||||
}
|
||||
|
||||
const answer =
|
||||
upstreamPayload && typeof upstreamPayload === "object"
|
||||
? String((upstreamPayload as Record<string, unknown>).answer || "").trim()
|
||||
: "";
|
||||
|
||||
if (!answer) {
|
||||
console.error("[ClawdChat API] OpenClaw empty answer:", upstreamPayload);
|
||||
return NextResponse.json(
|
||||
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
|
||||
() => []
|
||||
);
|
||||
const latestMessageTimestamp = existingMessages.reduce((latest, item) => {
|
||||
const timestamp =
|
||||
item.createdAt instanceof Date
|
||||
? item.createdAt.getTime()
|
||||
: Date.parse(String(item.createdAt || ""));
|
||||
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
|
||||
}, 0);
|
||||
const userCreatedAt = new Date(
|
||||
Math.max(Date.now(), latestMessageTimestamp + 1)
|
||||
);
|
||||
const assistantCreatedAt = new Date(userCreatedAt.getTime() + 1);
|
||||
|
||||
const userMessage = {
|
||||
id: userMessageId,
|
||||
const responseBody = buildTaskResponse({
|
||||
chatId: parsed.id,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: displayMessage }],
|
||||
attachments: [],
|
||||
createdAt: userCreatedAt,
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: {
|
||||
composerMode: "clawd",
|
||||
source: "openclaw",
|
||||
skillTagId: skillTagId || null,
|
||||
skillTagName: skillTagName || null,
|
||||
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",
|
||||
},
|
||||
};
|
||||
const assistantMessage = {
|
||||
id: assistantMessageId,
|
||||
chatId: parsed.id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: answer }],
|
||||
attachments: [],
|
||||
createdAt: assistantCreatedAt,
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: {
|
||||
composerMode: "clawd",
|
||||
source: "openclaw",
|
||||
requestId:
|
||||
upstreamPayload && typeof upstreamPayload === "object"
|
||||
? (upstreamPayload as Record<string, unknown>).request_id || null
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
await saveMessages({ messages: [userMessage, assistantMessage] });
|
||||
clearClawdRequestCancellation(cancelKey);
|
||||
|
||||
return NextResponse.json({
|
||||
conversationId,
|
||||
requestId:
|
||||
upstreamPayload && typeof upstreamPayload === "object"
|
||||
? (upstreamPayload as Record<string, unknown>).request_id || null
|
||||
: null,
|
||||
messages: [
|
||||
{
|
||||
id: userMessage.id,
|
||||
role: "user",
|
||||
parts: userMessage.parts,
|
||||
createdAt: userMessage.createdAt.toISOString(),
|
||||
metadata: {
|
||||
createdAt: userMessage.createdAt.toISOString(),
|
||||
composerMode: "clawd",
|
||||
},
|
||||
videoMetadata: userMessage.videoMetadata,
|
||||
},
|
||||
{
|
||||
id: assistantMessage.id,
|
||||
role: "assistant",
|
||||
parts: assistantMessage.parts,
|
||||
createdAt: assistantMessage.createdAt.toISOString(),
|
||||
metadata: {
|
||||
createdAt: assistantMessage.createdAt.toISOString(),
|
||||
composerMode: "clawd",
|
||||
},
|
||||
videoMetadata: assistantMessage.videoMetadata,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
191
app/api/canvas/clawd-chat/status/route.ts
Normal file
191
app/api/canvas/clawd-chat/status/route.ts
Normal file
@ -0,0 +1,191 @@
|
||||
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,
|
||||
artifacts: outcome.artifacts,
|
||||
});
|
||||
} 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",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -1956,7 +1956,7 @@ export async function POST(request: Request) {
|
||||
(item) =>
|
||||
!item.model.isSubModel &&
|
||||
item.model.isEnabled &&
|
||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
||||
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||
);
|
||||
const configuredDetailAgentConfig =
|
||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||
|
||||
@ -204,7 +204,7 @@ export async function POST(request: Request) {
|
||||
(item) =>
|
||||
!item.model.isSubModel &&
|
||||
item.model.isEnabled &&
|
||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
||||
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||
);
|
||||
const configuredDetailAgentConfig =
|
||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||
|
||||
45
app/api/canvas/director/upload-model/route.ts
Normal file
45
app/api/canvas/director/upload-model/route.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
DIRECTOR_MAX_GLB_BYTES,
|
||||
validateDirectorGlb,
|
||||
} from "@/lib/video-canvas/director-glb";
|
||||
export const runtime = "nodejs";
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (
|
||||
!(file instanceof File) ||
|
||||
!/\.glb$/i.test(file.name) ||
|
||||
file.size > DIRECTOR_MAX_GLB_BYTES
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: "请上传 25 MiB 以内的 GLB 模型" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
validateDirectorGlb(buffer);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "无效 GLB" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const result = await uploadFile(
|
||||
`director-${crypto.randomUUID()}.glb`,
|
||||
buffer,
|
||||
);
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "模型上传失败,请重试" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
||||
import { after, NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import {
|
||||
buildAttachmentContentDisposition,
|
||||
fetchCanvasDownloadAsset,
|
||||
@ -9,6 +13,31 @@ import {
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
||||
// A proxy candidate for the same original must not repeat the same slow
|
||||
// upstream request through our own server (and create another HTTP timeout).
|
||||
const sources = [...new Set(input.urls.map(url => {
|
||||
const source = extractImageProxySourceUrl(url) || url;
|
||||
return inferOriginalImageUrl(source) || source;
|
||||
}))];
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
signal.throwIfAborted();
|
||||
for (const url of sources) {
|
||||
signal.throwIfAborted();
|
||||
const result = await fetchCanvasDownloadAsset({
|
||||
file: { url, name: "original" }, index: 0,
|
||||
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
||||
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
|
||||
});
|
||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
||||
}
|
||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
||||
// No AI/model request is involved and a completed source fetch is shared.
|
||||
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||
}
|
||||
throw new Error("Original image sources unavailable");
|
||||
}
|
||||
|
||||
function getRequestOrigin(request: NextRequest): string {
|
||||
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
|
||||
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
|
||||
@ -55,12 +84,41 @@ export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||
const requestedUrls = [...urls];
|
||||
if (urls.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||
if (body.preferDirect === true) {
|
||||
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
||||
if (task?.targetUrl) {
|
||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
||||
after(() => prepareImageArchiveDownload(task.id));
|
||||
}
|
||||
}
|
||||
const cookie = request.headers.get("cookie") || "";
|
||||
if (body.preparation === "async") {
|
||||
const result = await getDownloadPreparationQueue(prepareDownload, after).request({
|
||||
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
||||
});
|
||||
if (result.status === "preparing") {
|
||||
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
||||
message: "正在准备原图,完成后将自动下载" }, {
|
||||
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
||||
}
|
||||
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
||||
"Content-Type": result.asset.contentType,
|
||||
"Content-Length": String(result.asset.buffer.length),
|
||||
"Content-Disposition": buildAttachmentContentDisposition(`${name}.png`),
|
||||
"Cache-Control": "no-store",
|
||||
} });
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
|
||||
const failures: string[] = [];
|
||||
|
||||
|
||||
@ -1,54 +1,22 @@
|
||||
import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
|
||||
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
|
||||
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { after, NextRequest, NextResponse } from "next/server";
|
||||
import JSZip from "jszip";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||
|
||||
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
type DownloadFileItem = {
|
||||
url: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ZipTaskStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
type ZipTask = {
|
||||
id: string;
|
||||
userId: string;
|
||||
folderName: string;
|
||||
files: DownloadFileItem[];
|
||||
totalCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
compressPercent: number;
|
||||
status: ZipTaskStatus;
|
||||
stage: string;
|
||||
error?: string;
|
||||
zipBuffer?: Buffer;
|
||||
contentDisposition?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const MAX_CONCURRENCY = 8;
|
||||
type DownloadFileItem = { url: string; name?: string };
|
||||
const MAX_DOWNLOAD_FILES = 200;
|
||||
const TASK_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
const globalTaskStore = globalThis as typeof globalThis & {
|
||||
__canvasDownloadZipTasks?: Map<string, ZipTask>;
|
||||
};
|
||||
|
||||
function getTaskStore() {
|
||||
if (!globalTaskStore.__canvasDownloadZipTasks) {
|
||||
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
|
||||
}
|
||||
return globalTaskStore.__canvasDownloadZipTasks;
|
||||
}
|
||||
|
||||
const TASK_TTL_MS = 60 * 60_000;
|
||||
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
|
||||
const active = running.__canvasZipRunning ??= new Set<string>();
|
||||
function sanitizeFolderName(value: string): string {
|
||||
const cleaned = String(value || "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
@ -56,7 +24,7 @@ function sanitizeFolderName(value: string): string {
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.trim();
|
||||
return cleaned || `canvas-images-${Date.now()}`;
|
||||
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
|
||||
}
|
||||
|
||||
function buildContentDisposition(folderName: string): string {
|
||||
@ -110,258 +78,161 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
|
||||
.slice(0, MAX_DOWNLOAD_FILES);
|
||||
}
|
||||
|
||||
function cleanupExpiredTasks() {
|
||||
const now = Date.now();
|
||||
const store = getTaskStore();
|
||||
for (const [taskId, task] of store.entries()) {
|
||||
if (task.expiresAt <= now) {
|
||||
store.delete(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskProgress(task: ZipTask) {
|
||||
if (task.status === "completed") {
|
||||
return 100;
|
||||
}
|
||||
if (task.status === "failed") {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
|
||||
);
|
||||
}
|
||||
if (task.processedCount >= task.totalCount && task.totalCount > 0) {
|
||||
return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14));
|
||||
}
|
||||
return Math.max(
|
||||
4,
|
||||
Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
|
||||
);
|
||||
}
|
||||
|
||||
function serializeTask(task: ZipTask) {
|
||||
const downloadUrl =
|
||||
task.status === "completed"
|
||||
? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1`
|
||||
: "";
|
||||
return {
|
||||
id: task.id,
|
||||
folderName: task.folderName,
|
||||
status: task.status,
|
||||
stage: task.stage,
|
||||
totalCount: task.totalCount,
|
||||
processedCount: task.processedCount,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
progress: getTaskProgress(task),
|
||||
error: task.error || "",
|
||||
downloadUrl,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
expiresAt: task.expiresAt,
|
||||
};
|
||||
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
|
||||
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
|
||||
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
|
||||
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
|
||||
error: task.error || "", expiresAt: task.expiresAt,
|
||||
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
|
||||
}
|
||||
|
||||
async function processZipTask(params: {
|
||||
task: ZipTask;
|
||||
requestOrigin: string;
|
||||
cookie: string;
|
||||
}) {
|
||||
const { task, requestOrigin, cookie } = params;
|
||||
const store = getTaskStore();
|
||||
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
|
||||
if (active.has(id) || active.size >= 2) return;
|
||||
active.add(id);
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
let task: ZipTask | null = null;
|
||||
try {
|
||||
task.status = "running";
|
||||
task.stage = "正在读取源图";
|
||||
task.updatedAt = Date.now();
|
||||
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(task.folderName) || zip;
|
||||
const results: Array<
|
||||
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
|
||||
| null
|
||||
> = new Array(task.files.length).fill(null);
|
||||
release = await claimZipTask(id);
|
||||
if (!release) return;
|
||||
task = await loadZipTask(id, userId);
|
||||
if (!task || task.status === "completed" || task.status === "failed") return;
|
||||
await assertZipDiskSpace();
|
||||
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
|
||||
await saveZipTask(task);
|
||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
||||
// A restart resumes from these files using the current owner's session.
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
if (index >= task.files.length) break;
|
||||
|
||||
const file = task.files[index]!;
|
||||
results[index] = await fetchCanvasDownloadAsset({
|
||||
file,
|
||||
index,
|
||||
requestOrigin,
|
||||
cookie,
|
||||
});
|
||||
|
||||
const result = results[index];
|
||||
task.processedCount += 1;
|
||||
if (result?.ok) {
|
||||
task.successCount += 1;
|
||||
} else {
|
||||
task.failedCount += 1;
|
||||
}
|
||||
task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`;
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
|
||||
for (const [index, item] of results.entries()) {
|
||||
if (!item || !item.ok) {
|
||||
failures.push({
|
||||
index: index + 1,
|
||||
url: item?.ok === false ? item.url : task.files[index]?.url || "",
|
||||
name: item?.ok === false ? item.name : task.files[index]?.name,
|
||||
error: item?.ok === false ? item.error : "未知错误",
|
||||
});
|
||||
continue;
|
||||
let writes = Promise.resolve();
|
||||
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
|
||||
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
|
||||
while (cursor < task!.files.length) {
|
||||
const index = cursor++; const file = task!.files[index];
|
||||
const path = join(zipTaskPath(id), `${index}.image`);
|
||||
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
|
||||
delete file.prepared; delete file.filename;
|
||||
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
|
||||
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
|
||||
if (result.ok) {
|
||||
await assertZipDiskSpace(result.bytes.length);
|
||||
await writeFile(path, result.bytes, { mode: 0o600 });
|
||||
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
|
||||
} else file.error = result.error;
|
||||
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
|
||||
task!.successCount = task!.files.filter(f => f.prepared).length;
|
||||
task!.failedCount = task!.files.filter(f => f.error).length;
|
||||
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
|
||||
task!.updatedAt = Date.now(); await persist();
|
||||
}
|
||||
folder.file(item.filename, item.bytes);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
folder.file(
|
||||
"_download-failures.txt",
|
||||
[
|
||||
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
|
||||
"",
|
||||
...failures.map((item) => {
|
||||
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
|
||||
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
|
||||
}),
|
||||
].join("\n\n")
|
||||
);
|
||||
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
|
||||
}
|
||||
|
||||
task.stage = "正在生成 ZIP 文件";
|
||||
task.updatedAt = Date.now();
|
||||
task.zipBuffer = await zip.generateAsync(
|
||||
{
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
},
|
||||
(metadata) => {
|
||||
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
);
|
||||
}));
|
||||
const rejected = results.find(result => result.status === "rejected");
|
||||
if (rejected?.status === "rejected") throw rejected.reason;
|
||||
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
|
||||
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
|
||||
for (const file of task.files) if (file.prepared && file.filename)
|
||||
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
|
||||
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
|
||||
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
|
||||
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
|
||||
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
||||
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||
task.status = "completed";
|
||||
task.stage = "打包完成";
|
||||
task.compressPercent = 100;
|
||||
task.updatedAt = Date.now();
|
||||
task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadZipTask] Failed:", error);
|
||||
task.status = "failed";
|
||||
task.stage = "打包失败";
|
||||
task.error = error instanceof Error ? error.message : "打包下载失败";
|
||||
task.updatedAt = Date.now();
|
||||
task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
} finally {
|
||||
store.set(task.id, task);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
cleanupExpiredTasks();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const files = toDownloadItems(body.files, requestOrigin);
|
||||
const folderName = sanitizeFolderName(String(body.folderName || ""));
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task: ZipTask = {
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
folderName,
|
||||
files,
|
||||
totalCount: files.length,
|
||||
processedCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
compressPercent: 0,
|
||||
status: "pending",
|
||||
stage: "等待服务端开始打包",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + TASK_TTL_MS,
|
||||
};
|
||||
getTaskStore().set(task.id, task);
|
||||
|
||||
void processZipTask({
|
||||
task,
|
||||
requestOrigin,
|
||||
cookie: request.headers.get("cookie") || "",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: serializeTask(task),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadZipTask] Create failed:", error);
|
||||
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
cleanupExpiredTasks();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim();
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = getTaskStore().get(taskId);
|
||||
if (!task || task.userId !== session.user.id) {
|
||||
return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
||||
if (task.status !== "completed" || !task.zipBuffer) {
|
||||
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
|
||||
task.stage = "正在准备下载链接"; await saveZipTask(task);
|
||||
try {
|
||||
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
|
||||
if (cdn) {
|
||||
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
||||
if (cdn.url) task.cdnUrl = cdn.url;
|
||||
}
|
||||
return new NextResponse(task.zipBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
|
||||
"Content-Length": String(task.zipBuffer.length),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Download-Success-Count": String(task.successCount),
|
||||
"X-Download-Failed-Count": String(task.failedCount),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: serializeTask(task),
|
||||
});
|
||||
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
|
||||
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
|
||||
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
await saveZipTask(task);
|
||||
// The complete ZIP is enough for retries; release staged duplicate bytes.
|
||||
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
|
||||
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
|
||||
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadZipTask] Query failed:", error);
|
||||
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
|
||||
if (task) {
|
||||
task.status = "failed"; task.stage = "打包未完成";
|
||||
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
|
||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
|
||||
}
|
||||
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
|
||||
} finally { await release?.().catch(() => undefined); active.delete(id); }
|
||||
}
|
||||
function schedule(task: ZipTask, request: NextRequest) {
|
||||
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
|
||||
after(run);
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
|
||||
let task = await loadZipTask(id, session.user.id);
|
||||
if (!task) {
|
||||
await assertZipTaskCapacity(session.user.id);
|
||||
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
|
||||
const files = toDownloadItems(body.files, getRequestOrigin(request));
|
||||
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
|
||||
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
|
||||
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
|
||||
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
|
||||
await mkdir(zipTaskPath(id), { mode: 0o700 });
|
||||
});
|
||||
const now = Date.now();
|
||||
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
|
||||
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
|
||||
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
|
||||
await saveZipTask(task);
|
||||
} else if (task.status === "failed" && body.retry === true) {
|
||||
task.status = "pending"; task.error = undefined;
|
||||
await assertZipTaskCapacity(session.user.id);
|
||||
for (const file of task.files) delete file.error;
|
||||
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
|
||||
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
|
||||
await saveZipTask(task);
|
||||
}
|
||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
||||
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
|
||||
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
|
||||
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const id = request.nextUrl.searchParams.get("taskId") || "";
|
||||
const task = await loadZipTask(id, session.user.id);
|
||||
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
|
||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
||||
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
|
||||
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
|
||||
return NextResponse.redirect(task.cdnUrl, 307);
|
||||
const info = await stat(task.zipFile.path).catch(() => null);
|
||||
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
|
||||
const etag = `"${task.id}-${info.size}"`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
|
||||
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
|
||||
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
|
||||
const ifRange = request.headers.get("if-range");
|
||||
let range;
|
||||
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
|
||||
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
|
||||
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
|
||||
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
|
||||
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
|
||||
}
|
||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
||||
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import JSZip from "jszip";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
@ -159,13 +160,7 @@ export async function POST(request: NextRequest) {
|
||||
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
return new NextResponse(zipBuffer, {
|
||||
return new NextResponse(streamDownloadZip(zip), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
|
||||
@ -1,62 +1,10 @@
|
||||
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
getCanvasImageTaskForUser,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import {
|
||||
generateCanvasImage,
|
||||
type CanvasGenerateImageRequest,
|
||||
} from "@/lib/canvas/generate-image";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
getImageToolCreditCost,
|
||||
type ImageToolCreditFeature,
|
||||
} from "@/lib/points/config";
|
||||
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
function normalizeBillingFeature(
|
||||
value: unknown
|
||||
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
|
||||
if (value === "multi-view" || value === "repaint") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
|
||||
return {
|
||||
prompt: String(body.prompt || "").trim(),
|
||||
negativePrompt:
|
||||
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
|
||||
model: typeof body.model === "string" ? body.model : undefined,
|
||||
aspectRatio:
|
||||
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
|
||||
? body.aspectRatio.trim()
|
||||
: "auto",
|
||||
imageSize:
|
||||
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||
? body.imageSize.trim()
|
||||
: undefined,
|
||||
imageCount:
|
||||
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
|
||||
? body.imageCount
|
||||
: 1,
|
||||
promptExtend:
|
||||
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
|
||||
referenceImage:
|
||||
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
|
||||
referenceImages: Array.isArray(body.referenceImages)
|
||||
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -67,10 +15,7 @@ export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = String(searchParams.get("taskId") || "").trim();
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 taskId 参数" },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
||||
@ -99,105 +44,15 @@ export async function GET(request: Request) {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "查询任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: GenerateImageRequest = await request.json();
|
||||
const payload: CanvasGenerateImageRequest = {
|
||||
...toGenerateImagePayload(body),
|
||||
ownerKey: session.user.id,
|
||||
};
|
||||
if (!payload.prompt) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入提示词" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const billingFeature = normalizeBillingFeature(body.billingFeature);
|
||||
const pointsCharge = billingFeature
|
||||
? await (async () => {
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
return prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
|
||||
reason:
|
||||
billingFeature === "multi-view"
|
||||
? "image_multi_view"
|
||||
: "image_repaint",
|
||||
modelId: "canvas-generate-image",
|
||||
note:
|
||||
billingFeature === "multi-view"
|
||||
? "图片多视角生成消耗积分"
|
||||
: "图片重绘消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
if (pointsCharge && !pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const asyncMode = body.async === true;
|
||||
if (asyncMode) {
|
||||
const task = await createCanvasImageTask({
|
||||
userId: session.user.id,
|
||||
request: payload as unknown as Record<string, unknown>,
|
||||
});
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
const currentTask = await getCanvasImageTaskForUser({
|
||||
taskId: task.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note:
|
||||
billingFeature === "multi-view"
|
||||
? `图片多视角生成消耗积分(${task.id})`
|
||||
: `图片重绘消耗积分(${task.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image task points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId: task.id,
|
||||
status: currentTask?.status || task.status,
|
||||
queueStatus: currentTask?.queueStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await generateCanvasImage(payload);
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Canvas Generate Image] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "生成失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const body = await request.json().catch(() => ({}));
|
||||
return executeCanvasImageResponse(body, session.user.id);
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
|
||||
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
||||
import { probeRemoteUrl } from "@/lib/net/url-access";
|
||||
import { validateVideoReferences } from "@/lib/video-canvas/reference-input";
|
||||
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
||||
|
||||
export const maxDuration = 300;
|
||||
@ -10,6 +11,8 @@ interface GenerateVideoRequest {
|
||||
prompt?: string;
|
||||
mode?: VideoGenerationMode;
|
||||
images?: string[];
|
||||
audioUrls?: string[];
|
||||
generateAudio?: boolean;
|
||||
resolution?: VideoResolution;
|
||||
ratio?: VideoRatio;
|
||||
duration?: number;
|
||||
@ -46,23 +49,18 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const inputImages = Array.isArray(body.images)
|
||||
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
|
||||
: [];
|
||||
const { kept: reachableImages, dropped: unreachableImages } =
|
||||
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
|
||||
|
||||
if (inputImages.length > 0 && reachableImages.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "参考图片链接不可访问,请重新上传后再试" },
|
||||
{ status: 400 }
|
||||
);
|
||||
const referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 }));
|
||||
if (referenceCheck.error) {
|
||||
return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status });
|
||||
}
|
||||
const reachableImages = referenceCheck.images;
|
||||
|
||||
const mode = body.mode || inferMode(reachableImages);
|
||||
const result = await executeDirectVideoGeneration({
|
||||
prompt,
|
||||
mode,
|
||||
audioUrls: Array.isArray(body.audioUrls) ? body.audioUrls.filter(url => typeof url === "string" && /^https?:\/\//i.test(url)).slice(0, 3) : undefined,
|
||||
generateAudio: body.generateAudio,
|
||||
images: reachableImages,
|
||||
resolution: body.resolution,
|
||||
ratio: body.ratio,
|
||||
@ -90,13 +88,13 @@ export async function POST(request: Request) {
|
||||
success: true,
|
||||
data: {
|
||||
url: result.videoUrl,
|
||||
metadata: result.metadata || null,
|
||||
metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" },
|
||||
modelDisplayName: result.modelDisplayName,
|
||||
modelId: result.modelId,
|
||||
modelRef: result.modelRef,
|
||||
mode,
|
||||
images: reachableImages,
|
||||
droppedImages: unreachableImages,
|
||||
droppedImages: [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
130
app/api/canvas/generation-tasks/route.ts
Normal file
130
app/api/canvas/generation-tasks/route.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { videoUpscaleSchema } from "@/lib/video-canvas/video-upscale-contract";
|
||||
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
|
||||
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
|
||||
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
|
||||
import { imageOperationSchema } from "@/lib/video-canvas/image-operation";
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { z } from "zod";
|
||||
import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract";
|
||||
import {
|
||||
cancelCanvasGenerationTask,
|
||||
createCanvasGenerationTask,
|
||||
listCanvasGenerationTasks,
|
||||
ownsTaskProject,
|
||||
} from "@/lib/video-canvas/task-store";
|
||||
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
||||
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 900;
|
||||
|
||||
async function owner(projectId: unknown) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw Object.assign(Error("未登录"), { status: 401 });
|
||||
if (!z.string().uuid().safeParse(projectId).success)
|
||||
throw Object.assign(Error("画布 ID 无效"), { status: 400 });
|
||||
if (!(await ownsTaskProject(session.user.id, projectId as string)))
|
||||
throw Object.assign(Error("画布不存在"), { status: 404 });
|
||||
return session.user.id;
|
||||
}
|
||||
const fail = (error: unknown) =>
|
||||
NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "任务操作失败", code: (error as any)?.code },
|
||||
{ status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) },
|
||||
);
|
||||
const kick = (userId: string, projectId: string) =>
|
||||
after(() =>
|
||||
runCanvasGenerationQueue(userId, projectId).catch((error) =>
|
||||
console.error("[Canvas tasks]", error),
|
||||
),
|
||||
);
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).byteLength > 262144)
|
||||
throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 });
|
||||
const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text));
|
||||
if (!parsed.success) throw Object.assign(Error("任务参数不合法"), { status: 400 });
|
||||
if(parsed.data.workflow)throw Object.assign(Error("编组任务必须使用整组提交接口"),{status:400});
|
||||
if (
|
||||
parsed.data.kind === "image-operation" &&
|
||||
!imageOperationSchema.safeParse(parsed.data.params).success
|
||||
)
|
||||
throw Object.assign(Error("图片处理参数不合法"), { status: 400 });
|
||||
if (
|
||||
parsed.data.attachment &&
|
||||
((parsed.data.attachment.type === "story-asset" && parsed.data.kind !== "image") ||
|
||||
(parsed.data.attachment.type === "story-prompts" && parsed.data.kind !== "analysis"))
|
||||
)
|
||||
throw Object.assign(Error("脚本结果类型不匹配"), { status: 400 });
|
||||
if (parsed.data.kind === "audio" && !audioRequestSchema.safeParse(parsed.data.params).success)
|
||||
throw Object.assign(Error("音频参数不合法"), { status: 400 });
|
||||
if (
|
||||
parsed.data.kind === "smart-edit" &&
|
||||
!smartEditRequestSchema.safeParse(parsed.data.params).success
|
||||
)
|
||||
throw Object.assign(Error("剪辑参数无效"), { status: 400 });
|
||||
if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400});
|
||||
const userId = await owner(parsed.data.projectId);
|
||||
const task = await createCanvasGenerationTask(userId, parsed.data,
|
||||
() => preflightCanvasProductionRequests([parsed.data]));
|
||||
kick(userId, parsed.data.projectId);
|
||||
return NextResponse.json({ success: true, task }, { status: 202 });
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const projectId = new URL(request.url).searchParams.get("projectId") || "";
|
||||
const userId = await owner(projectId);
|
||||
const tasks = await listCanvasGenerationTasks(userId, projectId);
|
||||
if (tasks.some((task) => task.status === "pending" || canResumeCanvasTask(task)))
|
||||
kick(userId, projectId);
|
||||
return NextResponse.json(
|
||||
{ success: true, tasks },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { projectId, taskId } = await request.json();
|
||||
const userId = await owner(projectId);
|
||||
if (!z.string().uuid().safeParse(taskId).success)
|
||||
throw Object.assign(Error("任务 ID 无效"), { status: 400 });
|
||||
const result = await cancelCanvasGenerationTask(userId, projectId, taskId);
|
||||
if (!result.task)
|
||||
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
||||
if (!result.ok)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
result.task.status === "running"
|
||||
? "任务已提交供应商,当前线路不支持中止。结果会继续保存,请勿重复生成。"
|
||||
: "任务已结束或状态待核实,请先刷新任务状态。",
|
||||
task: result.task,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
return NextResponse.json({ success: true, task: result.task });
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const { projectId, taskId, nodeId } = await request.json();
|
||||
const userId = await owner(projectId);
|
||||
if (!z.string().uuid().safeParse(taskId).success || typeof nodeId !== "string")
|
||||
return NextResponse.json({ success: false }, { status: 400 });
|
||||
const { hideCanvasGenerationNode } = await import("@/lib/video-canvas/task-store");
|
||||
const success = await hideCanvasGenerationNode(userId, projectId, taskId, nodeId);
|
||||
return NextResponse.json({ success }, { status: success ? 200 : 404 });
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
41
app/api/canvas/group-runs/route.ts
Normal file
41
app/api/canvas/group-runs/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract";
|
||||
import {
|
||||
createCanvasGroupTasks,
|
||||
ownsTaskProject,
|
||||
} from "@/lib/video-canvas/task-store";
|
||||
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
||||
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
|
||||
export const runtime = "nodejs",
|
||||
maxDuration = 900;
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const text = await request.text();
|
||||
if (Buffer.byteLength(text) > 1048576)
|
||||
return NextResponse.json({ error: "编组数据过大" }, { status: 413 });
|
||||
const parsed = canvasTaskRequestSchema
|
||||
.array()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.safeParse(JSON.parse(text));
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: "编组参数无效" }, { status: 400 });
|
||||
const projectId = parsed.data[0].projectId,
|
||||
userId = session.user.id;
|
||||
if (!(await ownsTaskProject(userId, projectId)))
|
||||
return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
||||
const tasks = await createCanvasGroupTasks(userId, parsed.data,
|
||||
() => preflightCanvasProductionRequests(parsed.data));
|
||||
after(() => runCanvasGenerationQueue(userId, projectId));
|
||||
return NextResponse.json({ success: true, tasks }, { status: 202 });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "编组执行失败", code: (e as any)?.code },
|
||||
{ status: Number((e as any)?.status) || 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/api/canvas/group-templates/route.ts
Normal file
40
app/api/canvas/group-templates/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import { eq, and, sql, desc } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const rows = await db
|
||||
.select({
|
||||
projectId: canvasProject.id,
|
||||
title: canvasProject.title,
|
||||
templates: sql<any[]>`${canvasProject.canvasData}->'groupTemplates'`,
|
||||
})
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
eq(canvasProject.workspaceType, "video"),
|
||||
sql`${canvasProject.canvasData} ? 'groupTemplates'`
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasProject.updatedAt))
|
||||
.limit(100);
|
||||
return NextResponse.json(
|
||||
{
|
||||
templates: rows.flatMap((row) =>
|
||||
Array.isArray(row.templates)
|
||||
? row.templates.map((template) => ({
|
||||
...template,
|
||||
projectId: row.projectId,
|
||||
projectTitle: row.title,
|
||||
}))
|
||||
: []
|
||||
),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
143
app/api/canvas/layout-replace/batches/[batchId]/route.ts
Normal file
143
app/api/canvas/layout-replace/batches/[batchId]/route.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
getCanvasLayoutReplaceBatchActiveTaskId,
|
||||
getCanvasLayoutReplaceBatchForUser,
|
||||
markCanvasLayoutReplaceBatchItemsAdded,
|
||||
retryCanvasLayoutReplaceBatchItem,
|
||||
} from "@/lib/canvas/layout-replace-batch";
|
||||
import {
|
||||
enqueueCanvasImageTask,
|
||||
ensureCanvasImageTaskActive,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
|
||||
function normalizeUuid(value: unknown) {
|
||||
const id = String(value || "").trim();
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||||
? id
|
||||
: "";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ batchId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const batchId = normalizeUuid((await params).batchId);
|
||||
if (!batchId) {
|
||||
return NextResponse.json({ success: false, error: "批次ID无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
let batch = await getCanvasLayoutReplaceBatchForUser({
|
||||
batchId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (!batch) {
|
||||
return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 });
|
||||
}
|
||||
const activeTaskId = getCanvasLayoutReplaceBatchActiveTaskId(batch);
|
||||
if (activeTaskId) {
|
||||
await ensureCanvasImageTaskActive({
|
||||
taskId: activeTaskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
batch = await getCanvasLayoutReplaceBatchForUser({
|
||||
batchId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, batch });
|
||||
} catch (error) {
|
||||
console.error("[Canvas Layout Replace Batch][Detail] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "查询批次失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ batchId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const batchId = normalizeUuid((await params).batchId);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
if (body?.action === "retry") {
|
||||
const itemId = normalizeUuid(body?.itemId);
|
||||
if (!batchId || !itemId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少需要重试的结果" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const taskId = await retryCanvasLayoutReplaceBatchItem({
|
||||
batchId,
|
||||
itemId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "该结果当前无法重试" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
await enqueueCanvasImageTask(taskId);
|
||||
return NextResponse.json({ success: true, taskId });
|
||||
}
|
||||
|
||||
const items = Array.isArray(body?.items)
|
||||
? body.items
|
||||
.map((item: unknown) => {
|
||||
const record =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
itemId: normalizeUuid(record.itemId),
|
||||
canvasNodeId: String(record.canvasNodeId || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item: { itemId: string; canvasNodeId: string }) =>
|
||||
Boolean(item.itemId && item.canvasNodeId)
|
||||
)
|
||||
: [];
|
||||
if (!batchId || items.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少已添加的结果信息" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const updated = await markCanvasLayoutReplaceBatchItemsAdded({
|
||||
batchId,
|
||||
userId: session.user.id,
|
||||
items,
|
||||
});
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[Canvas Layout Replace Batch][PATCH] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "更新批次失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
201
app/api/canvas/layout-replace/batches/route.ts
Normal file
201
app/api/canvas/layout-replace/batches/route.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
CANVAS_LAYOUT_REPLACE_BATCH_LIMIT,
|
||||
createCanvasLayoutReplaceBatch,
|
||||
getCanvasLayoutReplaceBatchActiveTaskId,
|
||||
getLatestCanvasLayoutReplaceBatchForUser,
|
||||
type CanvasLayoutReplaceBatchSource,
|
||||
} from "@/lib/canvas/layout-replace-batch";
|
||||
import {
|
||||
enqueueCanvasImageTask,
|
||||
ensureCanvasImageTaskActive,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
function normalizeHttpUrl(value: unknown) {
|
||||
const url = String(value || "").trim();
|
||||
return /^https?:\/\//i.test(url) ? url : "";
|
||||
}
|
||||
|
||||
function normalizeUuid(value: unknown) {
|
||||
const id = String(value || "").trim();
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||||
? id
|
||||
: "";
|
||||
}
|
||||
|
||||
function normalizeSources(value: unknown): CanvasLayoutReplaceBatchSource[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const sources: CanvasLayoutReplaceBatchSource[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
const sourceImageUrl = normalizeHttpUrl(record.sourceImageUrl);
|
||||
if (!sourceImageUrl || seen.has(sourceImageUrl)) continue;
|
||||
seen.add(sourceImageUrl);
|
||||
sources.push({
|
||||
sourceImageUrl,
|
||||
sourceKind: record.sourceKind === "upload" ? "upload" : "canvas",
|
||||
sourceNodeId: String(record.sourceNodeId || "").trim() || undefined,
|
||||
sourceTitle: String(record.sourceTitle || "").trim().slice(0, 256) || undefined,
|
||||
});
|
||||
if (sources.length >= CANVAS_LAYOUT_REPLACE_BATCH_LIMIT) break;
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function getOwnedProject(projectId: string, userId: string) {
|
||||
const [project] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
|
||||
.limit(1);
|
||||
return project || null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = normalizeUuid(searchParams.get("projectId"));
|
||||
const sourceNodeId = String(searchParams.get("sourceNodeId") || "").trim();
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少有效的画布项目" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!(await getOwnedProject(projectId, session.user.id))) {
|
||||
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
let batch = await getLatestCanvasLayoutReplaceBatchForUser({
|
||||
projectId,
|
||||
userId: session.user.id,
|
||||
sourceNodeId: sourceNodeId || undefined,
|
||||
});
|
||||
const activeTaskId = batch
|
||||
? getCanvasLayoutReplaceBatchActiveTaskId(batch)
|
||||
: null;
|
||||
if (activeTaskId) {
|
||||
await ensureCanvasImageTaskActive({
|
||||
taskId: activeTaskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
batch = await getLatestCanvasLayoutReplaceBatchForUser({
|
||||
projectId,
|
||||
userId: session.user.id,
|
||||
sourceNodeId: sourceNodeId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, batch: batch || null });
|
||||
} catch (error) {
|
||||
console.error("[Canvas Layout Replace Batch][GET] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "查询批次失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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().catch(() => ({}));
|
||||
const projectId = normalizeUuid(body?.projectId);
|
||||
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
|
||||
const sources = normalizeSources(body?.sources);
|
||||
if (!projectId || !(await getOwnedProject(projectId, session.user.id))) {
|
||||
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
|
||||
}
|
||||
if (sources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请至少添加一张待处理图片" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!referenceImageUrl) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请上传目标模版图" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const perImageCost = getImageToolCreditCost(pointsConfig, "repaint");
|
||||
const totalCost = perImageCost * sources.length;
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: totalCost,
|
||||
reason: "image_repaint",
|
||||
modelId: "canvas-layout-replace-batch",
|
||||
note: `批量排版替换 ${sources.length} 张`,
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const created = await createCanvasLayoutReplaceBatch({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
sources,
|
||||
referenceImageUrl,
|
||||
supplementalPrompt:
|
||||
typeof body?.supplementalPrompt === "string"
|
||||
? body.supplementalPrompt
|
||||
: undefined,
|
||||
imageSize: typeof body?.imageSize === "string" ? body.imageSize : "2K",
|
||||
model: typeof body?.model === "string" ? body.model : undefined,
|
||||
});
|
||||
await enqueueCanvasImageTask(created.firstTaskId);
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `批量排版替换 ${sources.length} 张(${created.batchId})`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Points] Failed to consume layout batch points:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
batchId: created.batchId,
|
||||
firstTaskId: created.firstTaskId,
|
||||
sourceCount: sources.length,
|
||||
pointsCost: totalCost,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Canvas Layout Replace Batch][POST] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "创建批次失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
124
app/api/canvas/layout-replace/route.ts
Normal file
124
app/api/canvas/layout-replace/route.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import type { CanvasLayoutReplaceTaskRequest } from "@/lib/canvas/layout-replace-task";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
function normalizeHttpUrl(value: unknown) {
|
||||
const url = String(value || "").trim();
|
||||
return /^https?:\/\//i.test(url) ? url : "";
|
||||
}
|
||||
|
||||
function normalizeUuid(value: unknown) {
|
||||
const uuid = String(value || "").trim();
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)
|
||||
? uuid
|
||||
: undefined;
|
||||
}
|
||||
|
||||
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().catch(() => ({}));
|
||||
const sourceImageUrl = normalizeHttpUrl(body?.sourceImageUrl);
|
||||
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
|
||||
if (!sourceImageUrl) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少当前产品图片" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!referenceImageUrl) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少排版参考图" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||
reason: "image_repaint",
|
||||
modelId: "canvas-layout-replace",
|
||||
note: "排版替换消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, {
|
||||
status: pointsCharge.status,
|
||||
});
|
||||
}
|
||||
|
||||
const taskRequest: CanvasLayoutReplaceTaskRequest = {
|
||||
taskKind: "canvas-layout-replace",
|
||||
sourceImageUrl,
|
||||
referenceImageUrl,
|
||||
supplementalPrompt:
|
||||
typeof body?.supplementalPrompt === "string"
|
||||
? body.supplementalPrompt
|
||||
: undefined,
|
||||
model: typeof body?.model === "string" ? body.model : undefined,
|
||||
imageSize:
|
||||
typeof body?.imageSize === "string" && body.imageSize.trim()
|
||||
? body.imageSize.trim()
|
||||
: "2K",
|
||||
sourceNodeId:
|
||||
typeof body?.sourceNodeId === "string" ? body.sourceNodeId : undefined,
|
||||
agentProjectId: normalizeUuid(body?.agentProjectId),
|
||||
referenceLabel:
|
||||
typeof body?.referenceLabel === "string" ? body.referenceLabel : undefined,
|
||||
debugMeta:
|
||||
body?.debugMeta &&
|
||||
typeof body.debugMeta === "object" &&
|
||||
!Array.isArray(body.debugMeta)
|
||||
? body.debugMeta
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const task = await createCanvasImageTask({
|
||||
userId: session.user.id,
|
||||
request: taskRequest as unknown as Record<string, unknown>,
|
||||
});
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `排版替换消耗积分(${task.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas layout replace points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Canvas Layout Replace] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "排版替换提交失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,13 @@ import {
|
||||
} 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 { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
@ -618,7 +624,23 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||
@ -787,7 +809,7 @@ export async function POST(request: NextRequest) {
|
||||
operation,
|
||||
requestedModel: requestedModel || null,
|
||||
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
||||
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
resolvedModel: configuredScaleAdjustModelId,
|
||||
scaleAdjustProductLockMaskEditApplied: true,
|
||||
scaleAdjustPersonExclusionApplied:
|
||||
productLockAssets.personExclusionApplied,
|
||||
@ -814,7 +836,7 @@ export async function POST(request: NextRequest) {
|
||||
displayPrompt,
|
||||
imageCount: 1,
|
||||
imageSize: requestedImageSize,
|
||||
model: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
model: configuredScaleAdjustModelId,
|
||||
negativePrompt:
|
||||
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
|
||||
prompt,
|
||||
@ -847,7 +869,7 @@ export async function POST(request: NextRequest) {
|
||||
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
|
||||
target: region.targetRect,
|
||||
})),
|
||||
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
relightModel: configuredScaleAdjustModelId,
|
||||
sourceImageUrl,
|
||||
},
|
||||
}
|
||||
@ -876,7 +898,7 @@ export async function POST(request: NextRequest) {
|
||||
...(hasPositionMove
|
||||
? {
|
||||
scaleAdjustModelRelight: {
|
||||
model: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
model: configuredScaleAdjustModelId,
|
||||
overlayImageUrl: overlayUpload.url,
|
||||
quality: "medium" as const,
|
||||
regions: scaleAdjustIntentRegions.map((region) => ({
|
||||
@ -975,6 +997,7 @@ export async function POST(request: NextRequest) {
|
||||
displayPrompt,
|
||||
operation,
|
||||
requestedModel: requestedModel || null,
|
||||
resolvedModel: requestedModel || configuredLocalEditModelId || null,
|
||||
resolvedAspectRatio,
|
||||
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
|
||||
regionReferenceImageCount: referenceImages.length,
|
||||
@ -986,7 +1009,7 @@ export async function POST(request: NextRequest) {
|
||||
displayPrompt,
|
||||
imageCount: 1,
|
||||
imageSize: requestedImageSize,
|
||||
model: requestedModel || undefined,
|
||||
model: requestedModel || configuredLocalEditModelId || undefined,
|
||||
negativePrompt:
|
||||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
|
||||
prompt,
|
||||
|
||||
65
app/api/canvas/media/upload/route.ts
Normal file
65
app/api/canvas/media/upload/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
const probe = promisify(execFile);
|
||||
const running = new Set<string>();
|
||||
const formats: Record<string, string> = {
|
||||
"video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",
|
||||
"audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/mp4": "m4a", "audio/x-m4a": "m4a",
|
||||
"audio/wav": "wav", "audio/x-wav": "wav", "audio/ogg": "ogg", "audio/flac": "flac", "audio/webm": "webm",
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
if (running.has(session.user.id) || running.size >= 4) return NextResponse.json({ success: false, error: "正在上传,请稍后重试" }, { status: 429 });
|
||||
running.add(session.user.id);
|
||||
let dir = "";
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || !formats[file.type]) return NextResponse.json({ success: false, error: "请上传 MP4、MOV、WebM 视频,或 MP3、M4A、WAV、OGG、FLAC 音频" }, { status: 400 });
|
||||
if (!file.size || file.size > 125 * 1024 * 1024) return NextResponse.json({ success: false, error: "素材必须非空且不超过 125 MiB" }, { status: 413 });
|
||||
dir = await mkdtemp(join(tmpdir(), "canvas-upload-"));
|
||||
const input = join(dir, "input");
|
||||
const buffer = await file.arrayBuffer();
|
||||
await writeFile(input, Buffer.from(buffer));
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 }));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return NextResponse.json({ success: false, error: "服务器缺少 ffprobe 视频解析组件,请联系管理员安装 FFmpeg" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ success: false, error: "视频文件无法解析,请确认素材完整且格式受支持" }, { status: 400 });
|
||||
}
|
||||
const info = JSON.parse(stdout);
|
||||
const kind = file.type.startsWith("video/") ? "video" : "audio";
|
||||
const stream = info.streams?.find((item: { codec_type: string }) => item.codec_type === kind);
|
||||
const duration = Number(info.format?.duration);
|
||||
if (!stream || !Number.isFinite(duration) || duration <= 0 || duration > 600) return NextResponse.json({ success: false, error: "请上传内容完整、10 分钟以内的音视频" }, { status: 400 });
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer);
|
||||
} catch (error) {
|
||||
console.error("[Canvas media upload] Storage unavailable:", error);
|
||||
return NextResponse.json({ success: false, error: "媒体存储暂不可用,请稍后重试" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ success: true, data: { url: uploaded.url, duration, width: stream.width, height: stream.height } });
|
||||
} catch (error) {
|
||||
console.error("[Canvas media upload] Failed:", error);
|
||||
return NextResponse.json({ success: false, error: "素材上传失败,请确认文件完整或稍后重试" }, { status: 400 });
|
||||
} finally {
|
||||
running.delete(session.user.id);
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
61
app/api/canvas/prompt-tools/route.ts
Normal file
61
app/api/canvas/prompt-tools/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { generateText } from "ai";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
const logger = createLogger("canvas-prompt-tools");
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
||||
import { VIDEO_CANVAS_FUNCTION_CONFIG_KEY, normalizeVideoCanvasFunctionConfig } from "@/lib/video-canvas/config";
|
||||
import { promptToolErrorMessage, promptToolFunctionKey, promptToolSchema, selectPromptToolModel } from "@/lib/video-canvas/prompt-tools";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
const running = new Set<string>();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const parsed = promptToolSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ success: false, error: parsed.error.issues[0]?.message || "参数无效" }, { status: 400 });
|
||||
if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "提示词处理中,请稍候" }, { status: 429 });
|
||||
running.add(session.user.id);
|
||||
try {
|
||||
const [setting, available] = await Promise.all([
|
||||
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }),
|
||||
getEnabledAiModelsWithProvider(false),
|
||||
]);
|
||||
const config = normalizeVideoCanvasFunctionConfig(setting?.value);
|
||||
const definition = config.functions[promptToolFunctionKey(parsed.data.mode)];
|
||||
if (!config.enabled || !definition?.enabled) return NextResponse.json({ success: false, error: "此提示词功能已停用" }, { status: 403 });
|
||||
const requested = parsed.data.model && parsed.data.model !== "auto"
|
||||
? parsed.data.model : definition.model && definition.model !== "auto"
|
||||
? definition.model : config.defaultMultimodalModel;
|
||||
let selected;
|
||||
try {
|
||||
selected = selectPromptToolModel(available
|
||||
.filter(item => item.provider.isEnabled && item.model.isEnabled && !item.model.isSubModel)
|
||||
.map(item => ({ ...item.model, providerType: item.provider.type })), requested);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ success: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
const model = await getLanguageModelById(selected.id);
|
||||
if (!model) return NextResponse.json({ success: false, error: "提示词模型初始化失败" }, { status: 502 });
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: definition.prompt,
|
||||
prompt: parsed.data.prompt,
|
||||
temperature: parsed.data.mode === "translate" ? 0.1 : 0.4,
|
||||
maxOutputTokens: 4000,
|
||||
maxRetries: 0,
|
||||
abortSignal: AbortSignal.any([request.signal, AbortSignal.timeout(90_000)]),
|
||||
});
|
||||
if (!text.trim()) throw new Error("empty model output");
|
||||
return NextResponse.json({ success: true, data: { text: text.trim(), model: selected.id, modelName: selected.displayName || selected.modelId } });
|
||||
} catch (error) {
|
||||
const detail = error as { name?: string; statusCode?: number; cause?: { code?: string }; data?: { error?: { code?: string } } };
|
||||
logger.warn("Prompt tool request failed", { name: detail?.name, statusCode: detail?.statusCode, code: detail?.data?.error?.code || detail?.cause?.code });
|
||||
return NextResponse.json({ success: false, error: promptToolErrorMessage(error) }, { status: 502 });
|
||||
} finally {
|
||||
running.delete(session.user.id);
|
||||
}
|
||||
}
|
||||
@ -100,6 +100,8 @@ export async function POST(request: NextRequest) {
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
title: title || getDefaultWorkspaceTitle(workspaceType),
|
||||
description,
|
||||
workspaceType,
|
||||
|
||||
68
app/api/canvas/smart-edit/capabilities/route.ts
Normal file
68
app/api/canvas/smart-edit/capabilities/route.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { remoteAudioCapabilities } from "@/lib/video-canvas/smart-edit/remote-audio";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { isCanvasVisionModel } from "@/lib/video-canvas/model-matching";
|
||||
import { remoteRenderConfig } from "@/lib/video-canvas/smart-edit/remote-render";
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
let capabilities = {
|
||||
separation: false,
|
||||
scenes: false,
|
||||
beats: false,
|
||||
asr: false,
|
||||
ffmpeg: false,
|
||||
ffprobe: false,
|
||||
};
|
||||
try {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.env.SMART_EDIT_PYTHON || "python3",
|
||||
[path.resolve("services/smart-edit/analyze.py"), "--check"],
|
||||
{ timeout: 10000, maxBuffer: 65536 }
|
||||
);
|
||||
capabilities = JSON.parse(stdout);
|
||||
} catch {}
|
||||
const audio = await remoteAudioCapabilities();
|
||||
if (audio) capabilities = { ...capabilities, ...audio };
|
||||
let renderAvailable = capabilities.ffmpeg;
|
||||
const remote = remoteRenderConfig();
|
||||
if (remote) {
|
||||
try {
|
||||
const response = await fetch(remote.base + "/health", {
|
||||
headers: { authorization: "Bearer " + remote.token },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
});
|
||||
renderAvailable =
|
||||
response.ok && (await response.json()).status === "ok";
|
||||
} catch {
|
||||
renderAvailable = false;
|
||||
}
|
||||
}
|
||||
const models = (await getEnabledAiModelsWithProvider(false))
|
||||
.filter((item) =>
|
||||
isCanvasVisionModel({ ...item.model, providerType: item.provider.type })
|
||||
)
|
||||
.map(({ model }) => ({
|
||||
id: model.id,
|
||||
name: model.displayName,
|
||||
modelId: model.modelId,
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{ capabilities: { ...capabilities, render: renderAvailable }, models },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "剪辑服务配置读取失败" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
app/api/canvas/smart-edit/fonts/[id]/route.ts
Normal file
22
app/api/canvas/smart-edit/fonts/[id]/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { captionFont } from "@/lib/video-canvas/smart-edit/fonts";
|
||||
import { captionFontFile } from "@/lib/video-canvas/smart-edit/font-files";
|
||||
import { readFile } from "node:fs/promises";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await auth())?.user?.id) return Response.json({ error: "未登录" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
if (!captionFont(id)) return Response.json({ error: "字体不存在" }, { status: 404 });
|
||||
try {
|
||||
const data = await readFile(await captionFontFile(id));
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: {
|
||||
"Content-Type": "font/ttf",
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ error: "字体加载失败,请检查字体服务配置后重试" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ 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 { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
import { createRunway } from "@/lib/ai/providers/runway";
|
||||
@ -28,8 +29,9 @@ export const runtime = "nodejs";
|
||||
export const maxDuration = 1200;
|
||||
|
||||
const logger = createLogger("canvas-text-edit");
|
||||
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
|
||||
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 120_000;
|
||||
const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3;
|
||||
const TEXT_EDIT_GENERATION_TIMEOUT_MS = 180_000;
|
||||
|
||||
const textItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@ -57,6 +59,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -66,6 +69,13 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
|
||||
type TextEditModelCandidate = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
|
||||
|
||||
function isExplicitTextEditModel(candidate: TextEditModelCandidate) {
|
||||
const capabilities = candidate.model.capabilities as Record<string, unknown> | null;
|
||||
const kind = String(capabilities?.kind || "").toLowerCase();
|
||||
const modelId = String(candidate.model.modelId || "").toLowerCase();
|
||||
return kind === "image-edit" || /(?:^|[/_-])edit(?:$|[/_-])/.test(modelId);
|
||||
}
|
||||
|
||||
function getTextEditModelCandidates(
|
||||
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
|
||||
) {
|
||||
@ -80,16 +90,19 @@ function getTextEditModelCandidates(
|
||||
}) &&
|
||||
item.model.isEnabled &&
|
||||
item.provider.isEnabled &&
|
||||
(!item.model.excludeFromFailover || item.model.isDefaultTextEditModel) &&
|
||||
SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aScore =
|
||||
(a.model.isDefaultTextEditModel ? 100 : 0) +
|
||||
(isExplicitTextEditModel(a) ? 30 : 0) +
|
||||
(a.model.isDefault ? 10 : 0) +
|
||||
(a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
|
||||
(a.model.displayName?.includes("火山") ? 3 : 0);
|
||||
const bScore =
|
||||
(b.model.isDefaultTextEditModel ? 100 : 0) +
|
||||
(isExplicitTextEditModel(b) ? 30 : 0) +
|
||||
(b.model.isDefault ? 10 : 0) +
|
||||
(b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
|
||||
(b.model.displayName?.includes("火山") ? 3 : 0);
|
||||
@ -127,6 +140,8 @@ function createTextEditProvider(
|
||||
return createVisionary({ baseURL, apiKey });
|
||||
case "atlascloud":
|
||||
return createAtlasCloud({ baseURL, apiKey });
|
||||
case "vip98k":
|
||||
return createVip98k({ baseURL, apiKey });
|
||||
case "stepfun-image":
|
||||
return createStepfunImage({ baseURL, apiKey });
|
||||
case "midjourney":
|
||||
@ -216,6 +231,13 @@ async function resolveTextEditSourceImageUrl(imageUrl: string) {
|
||||
} catch (error) {
|
||||
logger.warn("source_image_archive_failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
sourceHost: (() => {
|
||||
try {
|
||||
return new URL(imageUrl).host;
|
||||
} catch {
|
||||
return "invalid-url";
|
||||
}
|
||||
})(),
|
||||
});
|
||||
return imageUrl;
|
||||
}
|
||||
@ -491,12 +513,20 @@ async function generateTextEditImageWithFailover(params: {
|
||||
],
|
||||
imageParams: {
|
||||
imageCount: 1,
|
||||
timeoutMs: TEXT_EDIT_GENERATION_TIMEOUT_MS,
|
||||
},
|
||||
};
|
||||
if (selected.provider.type === "atlascloud") {
|
||||
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
|
||||
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
|
||||
}
|
||||
const result = await provider.chat(runtimeModelId).doGenerate(generationOptions);
|
||||
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 finishReason = String(result?.finishReason || "").toLowerCase();
|
||||
|
||||
78
app/api/canvas/video-agent/route.ts
Normal file
78
app/api/canvas/video-agent/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
|
||||
import {
|
||||
readOwnedVideoCanvas,
|
||||
saveVideoAgentCanvas,
|
||||
} from "@/lib/ai/canvas-agent-runtime/video-store";
|
||||
import { AgentRunError } from "@/lib/ai/canvas-agent-runtime/store";
|
||||
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const projectId = z.string().uuid().parse(new URL(request.url).searchParams.get("projectId"));
|
||||
await readOwnedVideoCanvas(session.user.id, projectId);
|
||||
// One stable conversation per video workspace survives browser reloads.
|
||||
const chat = await getChatById({ id: projectId });
|
||||
if (chat && chat.userId !== session.user.id) throw new AgentRunError("对话不存在", 404);
|
||||
const messages = chat ? (await getMessagesByChatId({ id: projectId })).slice(-40) : [];
|
||||
return NextResponse.json(
|
||||
{ messages: messages.map(({ id, role, parts }) => ({ id, role, parts })) },
|
||||
{ headers: { "Cache-Control": "private, no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: publicAgentError(error) },
|
||||
{
|
||||
status:
|
||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const saveSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
baseRevision: z.string().datetime(),
|
||||
canvasData: z.object({
|
||||
nodes: z.array(z.record(z.unknown())).max(2000),
|
||||
edges: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.max(4000),
|
||||
selectedNodeIds: z.array(z.string()).max(20),
|
||||
viewport: z.object({
|
||||
x: z.number().finite(),
|
||||
y: z.number().finite(),
|
||||
zoom: z.number().positive(),
|
||||
}),
|
||||
tool: z.literal("select"),
|
||||
}),
|
||||
});
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const body = saveSchema.parse(await request.json());
|
||||
await saveVideoAgentCanvas(session.user.id, body.projectId, body.baseRevision, body.canvasData);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: publicAgentError(error) },
|
||||
{
|
||||
status:
|
||||
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/api/canvas/video-upscale/capabilities/route.ts
Normal file
39
app/api/canvas/video-upscale/capabilities/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { upscaleConfig } from "@/lib/video-canvas/video-upscale-remote";
|
||||
import { NextResponse } from "next/server";
|
||||
export async function GET() {
|
||||
if (!(await auth())?.user?.id)
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const config = upscaleConfig();
|
||||
if (!config)
|
||||
return NextResponse.json({
|
||||
available: false,
|
||||
message: "视频高清服务尚未接通",
|
||||
});
|
||||
const response = await fetch(config.base + "/health", {
|
||||
headers: { authorization: "Bearer " + config.token },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw Error("unavailable");
|
||||
const data = await response.json();
|
||||
return NextResponse.json(
|
||||
{
|
||||
available: data.status === "ok",
|
||||
engine: "Real-ESRGAN",
|
||||
resolutions: ["1080P", "2K", "4K"],
|
||||
maxScale: 4,
|
||||
maxSeconds: 120,
|
||||
queued: data.queued,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ available: false, message: "视频高清服务暂时不可用,请稍后重试" },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
app/api/canvas/video/archive/route.ts
Normal file
23
app/api/canvas/video/archive/route.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { archiveCanvasVideo } from "@/lib/video-canvas/archive-video";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
const running = new Set<string>();
|
||||
const schema = z.object({ url: z.string().url().max(12000) });
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ success: false, error: "视频地址无效" }, { status: 400 });
|
||||
if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "视频正在保存,请稍候" }, { status: 429 });
|
||||
running.add(session.user.id);
|
||||
try {
|
||||
const url = await archiveCanvasVideo(parsed.data.url);
|
||||
return NextResponse.json({ success: true, data: { url, storageStatus: "archived", permanentVideoUrl: url } });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "视频转存失败,原视频仍保留;请稍后重试保存,无需重新生成" }, { status: 502 });
|
||||
} finally { running.delete(session.user.id); }
|
||||
}
|
||||
96
app/api/canvas/video/process/route.ts
Normal file
96
app/api/canvas/video/process/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { downloadCanvasMedia } from "@/lib/video-canvas/download-media";
|
||||
import { processVideoFile } from "@/lib/video-canvas/media-processing";
|
||||
import { videoProcessSchema } from "@/lib/video-canvas/media-contract";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
const running = new Set<string>();
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const parsed = videoProcessSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ success: false, error: "视频处理参数无效" }, { status: 400 });
|
||||
if (running.has(session.user.id) || running.size >= 2)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "视频处理中,请稍后再提交" },
|
||||
{ status: 429 },
|
||||
);
|
||||
running.add(session.user.id);
|
||||
let dir = "";
|
||||
try {
|
||||
dir = await mkdtemp(join(tmpdir(), "canvas-video-"));
|
||||
const input = join(dir, "input");
|
||||
await downloadCanvasMedia(parsed.data.url, input);
|
||||
const save = async (name: string) => {
|
||||
const buffer = await readFile(join(dir, name));
|
||||
return (
|
||||
await uploadFile(`canvas-video-${randomUUID()}-${name}`, new Uint8Array(buffer).buffer)
|
||||
).url;
|
||||
};
|
||||
if (parsed.data.operation === "frame" || parsed.data.operation === "slow") {
|
||||
const name = parsed.data.operation === "frame" ? "frame.png" : "slow.mp4";
|
||||
const metadata = await processVideoFile(
|
||||
input,
|
||||
join(dir, name),
|
||||
parsed.data.operation,
|
||||
undefined,
|
||||
undefined,
|
||||
parsed.data,
|
||||
);
|
||||
return NextResponse.json({ success: true, data: { url: await save(name), ...metadata } });
|
||||
}
|
||||
if (parsed.data.operation === "trim") {
|
||||
const metadata = await processVideoFile(
|
||||
input,
|
||||
join(dir, "clip.mp4"),
|
||||
"trim",
|
||||
undefined,
|
||||
parsed.data.range,
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { url: await save("clip.mp4"), ...metadata },
|
||||
});
|
||||
}
|
||||
if (parsed.data.operation === "crop") {
|
||||
const metadata = await processVideoFile(
|
||||
input,
|
||||
join(dir, "crop.mp4"),
|
||||
"crop",
|
||||
parsed.data.rect,
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { url: await save("crop.mp4"), ...metadata },
|
||||
});
|
||||
}
|
||||
const metadata = await processVideoFile(input, join(dir, "audio.m4a"), "extract-audio");
|
||||
await processVideoFile(input, join(dir, "silent.mp4"), "silent-video");
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { audioUrl: await save("audio.m4a"), url: await save("silent.mp4"), ...metadata },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "视频处理失败";
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: message.startsWith("Command failed")
|
||||
? "视频处理失败,请确认文件完整且格式受支持"
|
||||
: message,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
} finally {
|
||||
running.delete(session.user.id);
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,9 +7,16 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
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 type {
|
||||
WorldLabsExternalTaskResponse,
|
||||
WorldLabsExternalTaskResultData,
|
||||
WorldLabsOperation,
|
||||
WorldLabsWorld,
|
||||
WorldLabsWorldAssets,
|
||||
@ -183,6 +190,48 @@ function assertArchivedAssetBuffer(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyArchivedAssetUpload(params: {
|
||||
url: string;
|
||||
expectedBytes: number;
|
||||
validateAs?: ArchiveValidationKind;
|
||||
}) {
|
||||
const headResponse = await fetch(params.url, {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!headResponse.ok) {
|
||||
throw new Error(`归档文件校验失败(HTTP ${headResponse.status})`);
|
||||
}
|
||||
|
||||
const archivedLength = Number(headResponse.headers.get("content-length"));
|
||||
if (
|
||||
Number.isFinite(archivedLength) &&
|
||||
archivedLength > 0 &&
|
||||
archivedLength !== params.expectedBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`归档文件大小不一致(源文件 ${params.expectedBytes},归档文件 ${archivedLength})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!params.validateAs) return;
|
||||
|
||||
const rangeResponse = await fetch(params.url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-63" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!rangeResponse.ok) {
|
||||
throw new Error(`归档文件头校验失败(HTTP ${rangeResponse.status})`);
|
||||
}
|
||||
const headerBuffer = await rangeResponse.arrayBuffer();
|
||||
assertArchivedAssetBuffer({
|
||||
buffer: headerBuffer,
|
||||
contentType: rangeResponse.headers.get("content-type"),
|
||||
validateAs: params.validateAs,
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveRemoteAssetIfNeeded(params: {
|
||||
cacheKey: string;
|
||||
sourceUrl?: string | null;
|
||||
@ -242,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: {
|
||||
);
|
||||
const filename = `${params.filenamePrefix}.${ext}`;
|
||||
const uploaded = await uploadFile(filename, assetBuffer);
|
||||
await verifyArchivedAssetUpload({
|
||||
url: uploaded.url,
|
||||
expectedBytes: assetBuffer.byteLength,
|
||||
validateAs: params.validateAs,
|
||||
});
|
||||
cache.set(params.cacheKey, {
|
||||
sourceUrl,
|
||||
archivedUrl: uploaded.url,
|
||||
@ -292,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: {
|
||||
)
|
||||
).filter((entry): entry is readonly [string, string] => Boolean(entry));
|
||||
|
||||
return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null;
|
||||
if (archivedEntries.length !== entries.length) return null;
|
||||
return Object.fromEntries(archivedEntries);
|
||||
}
|
||||
|
||||
function getStringValue(...values: unknown[]) {
|
||||
@ -361,33 +416,96 @@ async function updatePersistedWorldAsset(params: {
|
||||
function extractWorldId(operation: WorldLabsOperation) {
|
||||
const response = getObjectValue(operation.response);
|
||||
const metadata = getObjectValue(operation.metadata);
|
||||
return getStringValue(
|
||||
response?.world_id,
|
||||
response?.worldId,
|
||||
metadata?.world_id,
|
||||
metadata?.worldId
|
||||
);
|
||||
const nestedWorldId =
|
||||
getStringValue(response?.world_id, response?.worldId) ||
|
||||
findNestedString(response, ["world_id", "worldId"]);
|
||||
const metadataWorldId =
|
||||
getStringValue(metadata?.world_id, metadata?.worldId) ||
|
||||
findNestedString(metadata, ["world_id", "worldId"]);
|
||||
|
||||
return getStringValue(nestedWorldId, metadataWorldId);
|
||||
}
|
||||
|
||||
function isWorldLikePayload(value: unknown): value is WorldLabsWorld {
|
||||
const record = getObjectValue(value);
|
||||
if (!record) return false;
|
||||
|
||||
const hasCoreWorldField =
|
||||
typeof record.world_id === "string" ||
|
||||
typeof record.world_marble_url === "string" ||
|
||||
typeof record.display_name === "string" ||
|
||||
typeof record.worldId === "string";
|
||||
const hasPossibleWorldContainerFields = record.assets || record.permission || record.tags || record.world_prompt;
|
||||
const hasModelAliasOnly =
|
||||
typeof record.model === "string" && /world/i.test(record.model);
|
||||
|
||||
return Boolean(hasCoreWorldField || hasPossibleWorldContainerFields || hasModelAliasOnly);
|
||||
}
|
||||
|
||||
function extractWorldCandidate(value: unknown, depth = 0): WorldLabsWorld | null {
|
||||
if (depth > 8) return null;
|
||||
const record = getObjectValue(value);
|
||||
if (!record) return null;
|
||||
|
||||
if (isWorldLikePayload(record)) return record;
|
||||
|
||||
const childCandidates: Array<Record<string, unknown> | null> = [
|
||||
getObjectValue(record.world),
|
||||
getObjectValue(record.result),
|
||||
getObjectValue(record.output),
|
||||
getObjectValue(record.data),
|
||||
getObjectValue(record.payload),
|
||||
];
|
||||
|
||||
for (const child of childCandidates) {
|
||||
if (!child) continue;
|
||||
if (isWorldLikePayload(child)) return child;
|
||||
const nested = extractWorldCandidate(child, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
for (const child of Object.values(record)) {
|
||||
const nested = extractWorldCandidate(child, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractWorldFromOperation(operation: WorldLabsOperation) {
|
||||
const response = operation.response;
|
||||
if (response && typeof response === "object" && "world_marble_url" in (response as Record<string, unknown>)) {
|
||||
return response as WorldLabsWorld;
|
||||
}
|
||||
return null;
|
||||
return extractWorldCandidate(operation.response);
|
||||
}
|
||||
|
||||
function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
|
||||
const assets = world?.assets || null;
|
||||
if (!assets) return false;
|
||||
const spzUrls = assets.splats?.spz_urls;
|
||||
const meshRecord = getObjectValue(assets.mesh);
|
||||
const imageryRecord = getObjectValue(assets.imagery);
|
||||
const splatsRecord = getObjectValue(assets.splats);
|
||||
const spzUrls =
|
||||
splatsRecord?.spz_urls || splatsRecord?.spzUrls || splatsRecord?.splat_urls;
|
||||
return Boolean(
|
||||
getStringValue(assets.thumbnail_url) ||
|
||||
getStringValue(assets.imagery?.pano_url) ||
|
||||
getStringValue(assets.mesh?.collider_mesh_url) ||
|
||||
getStringValue(
|
||||
imageryRecord?.pano_url,
|
||||
imageryRecord?.panoUrl,
|
||||
imageryRecord?.panorama_url,
|
||||
imageryRecord?.panoramaUrl
|
||||
) ||
|
||||
getStringValue(
|
||||
meshRecord?.collider_mesh_url,
|
||||
meshRecord?.colliderMeshUrl,
|
||||
meshRecord?.collider_mesh,
|
||||
meshRecord?.mesh_url,
|
||||
meshRecord?.meshUrl,
|
||||
meshRecord?.model_url,
|
||||
meshRecord?.modelUrl
|
||||
) ||
|
||||
(spzUrls &&
|
||||
typeof spzUrls === "object" &&
|
||||
Object.values(spzUrls).some((value) => typeof value === "string" && value.trim()))
|
||||
Object.values(spzUrls).some(
|
||||
(value) => typeof value === "string" && value.trim()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@ -419,6 +537,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 {
|
||||
params: Promise<{ taskId: string }>;
|
||||
}
|
||||
@ -452,6 +778,226 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
});
|
||||
providerForLog = config.providerId || null;
|
||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||
let persistedAssetPayload =
|
||||
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
||||
let shouldUseExternalTaskResultApi =
|
||||
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
||||
persistedAssetPayload.externalTaskApi !== false &&
|
||||
persistedAssetPayload.marbleApi !== true;
|
||||
if (shouldUseExternalTaskResultApi) {
|
||||
try {
|
||||
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 sourceSpzEntryCount = Object.keys(
|
||||
extractedAssetPayload.spzUrls || {}
|
||||
).length;
|
||||
const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length;
|
||||
const assetsArchivingPending = Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
(rawThumbnailUrl && !stableThumbnailUrl) ||
|
||||
(rawPanoUrl && !stablePanoUrl) ||
|
||||
(sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount)
|
||||
);
|
||||
const storageArchivedAt = assetsArchivingPending
|
||||
? null
|
||||
: new Date().toISOString();
|
||||
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,
|
||||
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
|
||||
storageArchivedAt,
|
||||
assetsArchivingPending,
|
||||
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),
|
||||
assetsArchivingPending,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallbackAssetPayload = {
|
||||
...persistedAssetPayload,
|
||||
providerId: config.providerId || null,
|
||||
providerName: config.providerName || null,
|
||||
marbleApi: true,
|
||||
externalTaskApi: false,
|
||||
};
|
||||
shouldUseExternalTaskResultApi = false;
|
||||
persistedAssetPayload = fallbackAssetPayload;
|
||||
await updatePersistedWorldAsset({
|
||||
id: persistedWorldAsset?.id,
|
||||
status: "running",
|
||||
assetPayload: fallbackAssetPayload,
|
||||
}).catch(() => {});
|
||||
logger.warn("external task result failed, fallback to marble status", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: normalizedTaskId.length,
|
||||
providerId: config.providerId || null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||
method: "GET",
|
||||
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
||||
@ -512,22 +1058,68 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
let world = extractWorldFromOperation(operation);
|
||||
const worldId = extractWorldId(operation);
|
||||
if ((!world || !hasUsableWorldAssets(world)) && worldId) {
|
||||
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
|
||||
method: "GET",
|
||||
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
|
||||
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||
});
|
||||
world = world
|
||||
? {
|
||||
...world,
|
||||
...detailedWorld,
|
||||
assets: detailedWorld.assets || world.assets || null,
|
||||
}
|
||||
: detailedWorld;
|
||||
try {
|
||||
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
|
||||
method: "GET",
|
||||
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
|
||||
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||
});
|
||||
world = world
|
||||
? {
|
||||
...world,
|
||||
...detailedWorld,
|
||||
assets: detailedWorld.assets || world.assets || null,
|
||||
}
|
||||
: detailedWorld;
|
||||
} catch (error) {
|
||||
logger.warn("world detail fetch failed, retry with running status", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: normalizedTaskId.length,
|
||||
providerId: config.providerId || null,
|
||||
worldId,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!world) {
|
||||
throw new Error("World Labs 任务已完成,但未返回 world 数据");
|
||||
const pendingPayload = {
|
||||
...(getObjectValue(persistedWorldAsset?.assetPayload) || {}),
|
||||
providerId: config.providerId || null,
|
||||
providerName: config.providerName || null,
|
||||
worldId: worldId || null,
|
||||
worldUrl: null,
|
||||
displayName: null,
|
||||
model: null,
|
||||
assetsPending: true,
|
||||
operationMetadata: operation.metadata ?? null,
|
||||
};
|
||||
await updatePersistedWorldAsset({
|
||||
id: persistedWorldAsset?.id,
|
||||
status: "running",
|
||||
progress: 95,
|
||||
assetPayload: pendingPayload,
|
||||
});
|
||||
logger.warn("task completed but world payload is missing", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: normalizedTaskId.length,
|
||||
providerId: config.providerId || null,
|
||||
worldId: worldId || null,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "world",
|
||||
status: "running",
|
||||
progress: 95,
|
||||
output: operation.response ?? null,
|
||||
worldId: worldId || null,
|
||||
worldUrl: null,
|
||||
previewImageUrl: null,
|
||||
assetPayload: pendingPayload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasUsableWorldAssets(world)) {
|
||||
@ -643,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
: 0;
|
||||
const hasMissingSpzArchive =
|
||||
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
|
||||
const storageArchivedAt =
|
||||
[
|
||||
[rawWorldUrl, stableWorldUrl],
|
||||
[rawMeshUrl, stableModelUrl],
|
||||
[rawThumbnailUrl, stableThumbnailUrl],
|
||||
[rawPanoUrl, stablePanoUrl],
|
||||
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
|
||||
Boolean(
|
||||
stableSpzUrls &&
|
||||
assetPayload.spzUrls &&
|
||||
Object.entries(stableSpzUrls).some(
|
||||
([key, value]) =>
|
||||
typeof assetPayload.spzUrls?.[key] === "string" &&
|
||||
assetPayload.spzUrls[key] !== value
|
||||
)
|
||||
)
|
||||
? new Date().toISOString()
|
||||
: null;
|
||||
const assetsArchivingPending = Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
(rawThumbnailUrl && !stableThumbnailUrl) ||
|
||||
(rawPanoUrl && !stablePanoUrl) ||
|
||||
hasMissingSpzArchive
|
||||
);
|
||||
const storageArchivedAt = assetsArchivingPending
|
||||
? null
|
||||
: new Date().toISOString();
|
||||
|
||||
logger.info("task success status", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
@ -672,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||
storageArchivedAt,
|
||||
assetsArchivingPending: Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
hasMissingSpzArchive
|
||||
),
|
||||
assetsArchivingPending,
|
||||
});
|
||||
|
||||
const responseAssetPayload = {
|
||||
@ -685,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
panoUrl: stablePanoUrl || assetPayload.panoUrl,
|
||||
meshUrl: stableModelUrl,
|
||||
spzUrls: stableSpzUrls,
|
||||
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
|
||||
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
|
||||
storageArchivedAt,
|
||||
assetsArchivingPending: Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
hasMissingSpzArchive
|
||||
),
|
||||
assetsArchivingPending,
|
||||
sourceWorldUrl: rawWorldUrl || null,
|
||||
sourceMeshUrl:
|
||||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||
|
||||
@ -12,13 +12,23 @@ import {
|
||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { get3DWorldCreditCost } from "@/lib/points/config";
|
||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||
import {
|
||||
isWorldLabsExternalTaskApi,
|
||||
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||
WorldLabsApiError,
|
||||
worldLabsRequest,
|
||||
} from "@/lib/worldlabs/client";
|
||||
import {
|
||||
getWorldLabsProviderConfig,
|
||||
getWorldLabsProviderConfigs,
|
||||
reportWorldLabsProviderRuntimeFailure,
|
||||
} 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 maxDuration = 180;
|
||||
@ -47,8 +57,10 @@ type CreateWorldTaskRequest = {
|
||||
makePublic?: boolean;
|
||||
reconstructImages?: boolean;
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
mediaAssets?: Array<{
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
azimuth?: number | null;
|
||||
}>;
|
||||
};
|
||||
@ -58,10 +70,152 @@ function readOptionalTrimmedText(input: unknown) {
|
||||
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) {
|
||||
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
||||
}
|
||||
|
||||
function shouldUseExternalTaskCreateApi(
|
||||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||
mode: WorldTaskMode
|
||||
) {
|
||||
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "image";
|
||||
}
|
||||
|
||||
function isWorldLabsModeUnsupportedByExternalProvider(
|
||||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||
mode: WorldTaskMode
|
||||
) {
|
||||
return isWorldLabsExternalTaskApi(config.baseUrl) && mode !== "image";
|
||||
}
|
||||
|
||||
function assertWorldModeSupportedByCandidate(
|
||||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||
mode: WorldTaskMode,
|
||||
) {
|
||||
if (isWorldLabsModeUnsupportedByExternalProvider(config, mode)) {
|
||||
const normalizedBaseUrl = String(config.baseUrl || "").trim() || "未知服务地址";
|
||||
const normalizedMode = String(mode || "").trim() || "未知模式";
|
||||
throw new Error(
|
||||
`${normalizedBaseUrl} 的 world-generation 当前仅支持图生世界(image)。当前提交的是 ${normalizedMode},请先切换供应商或使用图生世界。`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOfficialMarbleModel(model?: string | null) {
|
||||
const normalized = readOptionalTrimmedText(model);
|
||||
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
|
||||
return "marble-1.1";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildExternalWorldTaskPayload(
|
||||
mode: WorldTaskMode,
|
||||
worldPrompt: Record<string, unknown>,
|
||||
prompt: string | null,
|
||||
displayName: string,
|
||||
) {
|
||||
const normalizedPrompt = readOptionalTrimmedText(prompt);
|
||||
if (!normalizedPrompt && mode === "text") {
|
||||
throw new Error("请输入世界描述");
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
model: WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||
displayName,
|
||||
};
|
||||
if (normalizedPrompt) {
|
||||
payload.prompt = normalizedPrompt;
|
||||
} else if (mode !== "text") {
|
||||
payload.prompt = "图生世界";
|
||||
}
|
||||
|
||||
if (mode === "image" || mode === "multi-image") {
|
||||
payload.world_prompt = worldPrompt;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildOfficialWorldTaskPayload(
|
||||
displayName: string,
|
||||
worldPrompt: Record<string, unknown>,
|
||||
officialMarbleModel: string,
|
||||
body: CreateWorldTaskRequest
|
||||
) {
|
||||
return {
|
||||
display_name: displayName,
|
||||
model: officialMarbleModel,
|
||||
permission: {
|
||||
public: body.makePublic === true,
|
||||
},
|
||||
world_prompt: worldPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
const textPrompt = readOptionalTrimmedText(body.prompt);
|
||||
|
||||
@ -80,15 +234,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
|
||||
if (mode === "image") {
|
||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||
if (!mediaAssetId) {
|
||||
throw new Error("图生世界缺少 mediaAssetId");
|
||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) {
|
||||
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
image_prompt: {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
},
|
||||
image_prompt: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||
...(body.disableRecaption !== undefined
|
||||
? { disable_recaption: body.disableRecaption }
|
||||
@ -102,7 +262,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
const multiImagePrompt = mediaAssets
|
||||
.map((item) => {
|
||||
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
||||
if (!mediaAssetId) return null;
|
||||
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) return null;
|
||||
const azimuth =
|
||||
item?.azimuth === null || item?.azimuth === undefined
|
||||
? null
|
||||
@ -111,10 +272,15 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
: null;
|
||||
return {
|
||||
...(azimuth === null ? {} : { azimuth }),
|
||||
content: {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
},
|
||||
content: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
@ -137,16 +303,22 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
}
|
||||
|
||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||
if (!mediaAssetId) {
|
||||
throw new Error("视频生成世界缺少 mediaAssetId");
|
||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) {
|
||||
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "video",
|
||||
video_prompt: {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
},
|
||||
video_prompt: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||
};
|
||||
}
|
||||
@ -187,12 +359,29 @@ export async function POST(request: Request) {
|
||||
);
|
||||
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 shouldChargePoints = shouldCharge3DPoints(mode);
|
||||
const unitCost = get3DWorldCreditCost(
|
||||
pointsConfig,
|
||||
resolvedWorldModel || "marble-1.1",
|
||||
);
|
||||
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
|
||||
|
||||
if (
|
||||
shouldChargePoints &&
|
||||
@ -220,42 +409,83 @@ 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 usedProvider = null as Awaited<
|
||||
ReturnType<typeof getWorldLabsProviderConfig>
|
||||
> | null;
|
||||
let usedExternalTaskApi = false;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||
method: "POST",
|
||||
path: "/marble/v1/worlds:generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: {
|
||||
display_name: displayName,
|
||||
model: resolvedWorldModel || "marble-1.1",
|
||||
permission: {
|
||||
public: body.makePublic === true,
|
||||
},
|
||||
world_prompt: worldPrompt,
|
||||
},
|
||||
});
|
||||
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
||||
assertWorldModeSupportedByCandidate(candidate, mode);
|
||||
let requestUsedExternalTaskApi = false;
|
||||
if (useExternalTaskApi) {
|
||||
const payload = buildExternalWorldTaskPayload(
|
||||
mode,
|
||||
worldPrompt,
|
||||
readOptionalTrimmedText(body.prompt),
|
||||
displayName,
|
||||
);
|
||||
try {
|
||||
const externalResult = await worldLabsRequest<
|
||||
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||
>(candidate, {
|
||||
method: "POST",
|
||||
path: "/api/external/task/generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: payload,
|
||||
});
|
||||
result = normalizeExternalTaskCreateOperation(externalResult);
|
||||
requestUsedExternalTaskApi = true;
|
||||
} catch (error) {
|
||||
const externalError = error instanceof Error ? error.message : String(error);
|
||||
logger.warn("external world create failed, fallback to marble endpoint", {
|
||||
providerId: candidate.providerId || null,
|
||||
error: externalError,
|
||||
});
|
||||
if (isCredentialOrBillingProviderError(error)) {
|
||||
await reportWorldLabsProviderRuntimeFailure(
|
||||
candidate.providerId,
|
||||
externalError
|
||||
);
|
||||
}
|
||||
|
||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||
method: "POST",
|
||||
path: "/marble/v1/worlds:generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: buildOfficialWorldTaskPayload(
|
||||
displayName,
|
||||
worldPrompt,
|
||||
officialMarbleModel,
|
||||
body
|
||||
),
|
||||
});
|
||||
requestUsedExternalTaskApi = false;
|
||||
}
|
||||
} else {
|
||||
requestUsedExternalTaskApi = false;
|
||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||
method: "POST",
|
||||
path: "/marble/v1/worlds:generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: buildOfficialWorldTaskPayload(
|
||||
displayName,
|
||||
worldPrompt,
|
||||
officialMarbleModel,
|
||||
body
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!result?.operation_id) {
|
||||
throw new Error("创建 World Labs 世界任务返回缺少 operation_id");
|
||||
}
|
||||
|
||||
usedProvider = candidate;
|
||||
usedExternalTaskApi = requestUsedExternalTaskApi;
|
||||
providerForLog = candidate.providerId || null;
|
||||
break;
|
||||
} catch (error) {
|
||||
@ -292,12 +522,18 @@ export async function POST(request: Request) {
|
||||
|
||||
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
||||
try {
|
||||
const usedBillingModel = usedExternalTaskApi
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: officialMarbleModel;
|
||||
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: worldModelDisplayName;
|
||||
await consumeUserPoints({
|
||||
userId: session.user.id,
|
||||
amount: unitCost,
|
||||
reason: "world3d_generation",
|
||||
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
||||
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
||||
modelId: `worldlabs-3d:${usedBillingModel}`,
|
||||
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("points consume failed", error, {
|
||||
@ -313,10 +549,13 @@ export async function POST(request: Request) {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: taskId.length,
|
||||
mode,
|
||||
model: resolvedWorldModel || "marble-1.1",
|
||||
model: usedExternalTaskApi
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: officialMarbleModel,
|
||||
providerId: usedProvider.providerId || null,
|
||||
status: result.done ? "success" : "running",
|
||||
unitCost,
|
||||
externalTaskApi: usedExternalTaskApi,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@ -327,6 +566,8 @@ export async function POST(request: Request) {
|
||||
assetPayload: {
|
||||
providerId: usedProvider.providerId || null,
|
||||
providerName: usedProvider.providerName || null,
|
||||
externalTaskApi: usedExternalTaskApi,
|
||||
marbleApi: !usedExternalTaskApi,
|
||||
},
|
||||
raw: result,
|
||||
},
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
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 {
|
||||
getWorldLabsProviderConfig,
|
||||
getWorldLabsProviderConfigs,
|
||||
@ -112,6 +116,81 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
|
||||
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
||||
}
|
||||
|
||||
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 (
|
||||
error instanceof WorldLabsApiError &&
|
||||
@ -202,24 +281,24 @@ export async function POST(request: Request) {
|
||||
);
|
||||
|
||||
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
||||
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
|
||||
const { uploadUrl, uploadMethod, requiredHeaders } =
|
||||
resolvePrepareUploadDetails(prepareUpload);
|
||||
if (!mediaAssetId || !uploadUrl) {
|
||||
throw new Error("World Labs 返回的上传信息不完整");
|
||||
}
|
||||
|
||||
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)) {
|
||||
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
||||
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(
|
||||
prepareUpload.upload_info?.upload_method || "PUT"
|
||||
).toUpperCase();
|
||||
const normalizedUploadMethod = uploadMethod.toUpperCase();
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
|
||||
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
|
||||
headers: uploadHeaders,
|
||||
body: file,
|
||||
cache: "no-store",
|
||||
@ -252,6 +331,28 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isSignedUploadFailure(error)) {
|
||||
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,
|
||||
|
||||
@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||
import { db } from "@/lib/db";
|
||||
import { clientPerformanceEvent } from "@/lib/db/schema";
|
||||
|
||||
@ -167,9 +168,12 @@ export async function POST(request: Request) {
|
||||
console.warn("[ClientPerformance] prune failed", error);
|
||||
});
|
||||
|
||||
const persistedChatId =
|
||||
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
|
||||
|
||||
await db.insert(clientPerformanceEvent).values({
|
||||
userId: session?.user?.id || null,
|
||||
chatId: event.chatId || null,
|
||||
chatId: persistedChatId,
|
||||
projectId: clampText(event.projectId, 64),
|
||||
eventType: clampText(event.eventType, 64) || "unknown",
|
||||
section: event.section || "unknown",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,234 +1,10 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import {
|
||||
getFriendlyRateLimitMessage,
|
||||
getUserFacingRequestErrorMessage,
|
||||
isRateLimitError,
|
||||
} from "@/lib/request-error";
|
||||
|
||||
import { executeExtendPOST } from "@/lib/canvas/operations/extend";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
|
||||
function parseIncludeBase64(value: unknown): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
function buildExtendSuccessPayload(result: Awaited<ReturnType<typeof executeExtendJob>>) {
|
||||
return {
|
||||
success: true,
|
||||
original_url: result.original_url,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
...(result.image_base64 ? { base64: result.image_base64 } : {}),
|
||||
processing_time: result.processing_time,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
|
||||
let imageUrl: string | null = null;
|
||||
let imageBase64: string | null = null;
|
||||
let prompt = "图像拓展处理";
|
||||
let chatId: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let includeBase64 = false;
|
||||
const requestId = `extend-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
imageUrl = body.image_url ? String(body.image_url).trim() : null;
|
||||
imageBase64 = body.image_base64 ? String(body.image_base64).trim() : null;
|
||||
prompt = body.prompt || prompt;
|
||||
chatId = body.chatId || null;
|
||||
messageId = body.messageId || null;
|
||||
includeBase64 = parseIncludeBase64(body.includeBase64);
|
||||
|
||||
if (!imageUrl && !imageBase64) {
|
||||
return NextResponse.json(
|
||||
{ error: "缺少image_base64或image_url参数" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Extend API] Request received:", {
|
||||
requestId,
|
||||
source: imageUrl ? "url" : "base64",
|
||||
includeBase64,
|
||||
chatId,
|
||||
messageId,
|
||||
xScale: body.xScale,
|
||||
yScale: body.yScale,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageUrl,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
xScale: body.xScale ? Number(body.xScale) : undefined,
|
||||
yScale: body.yScale ? Number(body.yScale) : undefined,
|
||||
includeBase64,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Extend API] Request completed:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
processingTime: result.processing_time,
|
||||
originalUrl: result.original_url.slice(0, 120),
|
||||
});
|
||||
|
||||
return NextResponse.json(buildExtendSuccessPayload(result));
|
||||
}
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const multipartChatId = String(formData.get("chatId") || "") || null;
|
||||
const multipartMessageId = String(formData.get("messageId") || "") || null;
|
||||
includeBase64 = parseIncludeBase64(formData.get("includeBase64"));
|
||||
console.log("[Extend API] Multipart request received:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
chatId: multipartChatId,
|
||||
messageId: multipartMessageId,
|
||||
bytes: buffer.length,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageBase64: buffer.toString("base64"),
|
||||
prompt: String(formData.get("prompt") || prompt),
|
||||
chatId: multipartChatId,
|
||||
messageId: multipartMessageId,
|
||||
includeBase64,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Extend API] Multipart request completed:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
processingTime: result.processing_time,
|
||||
originalUrl: result.original_url.slice(0, 120),
|
||||
});
|
||||
|
||||
return NextResponse.json(buildExtendSuccessPayload(result));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ExtendServiceError) {
|
||||
if (error.status === 429 || isRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
|
||||
detail: error.detail || error.message,
|
||||
retryAfter: 10,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": "10",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
|
||||
detail: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[Extend API] 错误:", error);
|
||||
if (isRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
retryAfter: 10,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": "10",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return executeExtendPOST(request, session.user.id);
|
||||
}
|
||||
|
||||
119
app/api/external/image/generate/route.ts
vendored
119
app/api/external/image/generate/route.ts
vendored
@ -21,103 +21,6 @@ import {
|
||||
export const runtime = "nodejs";
|
||||
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({
|
||||
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
||||
negativePrompt: z.string().trim().optional(),
|
||||
@ -294,10 +197,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const startedAt = Date.now();
|
||||
let logId: string | null = null;
|
||||
let releaseExternalImageApiSlot: (() => void) | null = null;
|
||||
|
||||
try {
|
||||
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
||||
const payload = await parseRequestPayload(request);
|
||||
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
||||
const candidateModels = await buildExternalImageApiModelCandidates({
|
||||
@ -344,6 +245,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const generationResult = await generateImagesWithFailover({
|
||||
candidates: candidateModels,
|
||||
ownerKey: `external-image-api:${authResult.accessKey.id}`,
|
||||
prompt: payload.prompt,
|
||||
negativePrompt: payload.negativePrompt || null,
|
||||
imageCount: payload.imageCount || 1,
|
||||
@ -467,21 +369,6 @@ export async function POST(request: Request) {
|
||||
.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(
|
||||
{
|
||||
success: false,
|
||||
@ -489,9 +376,5 @@ export async function POST(request: Request) {
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
} finally {
|
||||
if (releaseExternalImageApiSlot) {
|
||||
releaseExternalImageApiSlot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
|
||||
import type {
|
||||
FlowBoardDraft,
|
||||
FlowBoardSheetSnapshotData,
|
||||
FlowBoardSnapshotData,
|
||||
} from "@/lib/db/schema";
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@ -8,6 +12,73 @@ function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
const DEFAULT_FLOW_BOARD_CANVAS = {
|
||||
backgroundColor: "#f7f8fb",
|
||||
gridColor: "#d8dde8",
|
||||
gridEnabled: false,
|
||||
gridSize: 24,
|
||||
};
|
||||
|
||||
function normalizeCanvasColor(value: unknown, fallback: string) {
|
||||
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
|
||||
? value.toLowerCase()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeFlowBoardCanvas(value: unknown): Record<string, unknown> {
|
||||
const canvas = isRecord(value) ? value : {};
|
||||
const requestedGridSize =
|
||||
typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize)
|
||||
? Math.round(canvas.gridSize)
|
||||
: DEFAULT_FLOW_BOARD_CANVAS.gridSize;
|
||||
|
||||
return {
|
||||
backgroundColor: normalizeCanvasColor(
|
||||
canvas.backgroundColor,
|
||||
DEFAULT_FLOW_BOARD_CANVAS.backgroundColor
|
||||
),
|
||||
gridColor: normalizeCanvasColor(
|
||||
canvas.gridColor,
|
||||
DEFAULT_FLOW_BOARD_CANVAS.gridColor
|
||||
),
|
||||
gridEnabled:
|
||||
typeof canvas.gridEnabled === "boolean"
|
||||
? canvas.gridEnabled
|
||||
: DEFAULT_FLOW_BOARD_CANVAS.gridEnabled,
|
||||
gridSize: Math.min(64, Math.max(8, requestedGridSize)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSheetId(value: unknown, index: number) {
|
||||
const id = typeof value === "string" ? value.trim().slice(0, 128) : "";
|
||||
return id || `sheet-${index + 1}`;
|
||||
}
|
||||
|
||||
function normalizeSheetTitle(value: unknown, index: number) {
|
||||
const title = typeof value === "string" ? value.trim().slice(0, 128) : "";
|
||||
return title || `画布 ${index + 1}`;
|
||||
}
|
||||
|
||||
function normalizeFlowBoardSheet(
|
||||
value: unknown,
|
||||
index: number
|
||||
): FlowBoardSheetSnapshotData | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: normalizeSheetId(value.id, index),
|
||||
title: normalizeSheetTitle(value.title, index),
|
||||
children: Array.isArray(value.children)
|
||||
? value.children.filter(isRecord)
|
||||
: [],
|
||||
viewport: normalizeRecord(value.viewport),
|
||||
theme: normalizeRecord(value.theme),
|
||||
canvas: normalizeFlowBoardCanvas(value.canvas),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFlowBoardTitle(value: unknown) {
|
||||
const title = String(value || "").trim();
|
||||
return title.slice(0, 256) || "未命名流程白板";
|
||||
@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot(
|
||||
value: unknown
|
||||
): FlowBoardSnapshotData {
|
||||
const raw = isRecord(value) ? value : {};
|
||||
const legacyChildren = Array.isArray(raw.children)
|
||||
? raw.children.filter(isRecord)
|
||||
: [];
|
||||
const normalizedSheets = Array.isArray(raw.sheets)
|
||||
? raw.sheets
|
||||
.map(normalizeFlowBoardSheet)
|
||||
.filter(
|
||||
(sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet)
|
||||
)
|
||||
: [];
|
||||
const sheets =
|
||||
normalizedSheets.length > 0
|
||||
? normalizedSheets
|
||||
: [
|
||||
{
|
||||
id: "sheet-1",
|
||||
title: "画布 1",
|
||||
children: legacyChildren,
|
||||
viewport: normalizeRecord(raw.viewport),
|
||||
theme: normalizeRecord(raw.theme),
|
||||
canvas: normalizeFlowBoardCanvas(raw.canvas),
|
||||
},
|
||||
];
|
||||
const requestedActiveSheetId =
|
||||
typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : "";
|
||||
const activeSheet =
|
||||
sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0];
|
||||
const activeChildren = Array.isArray(raw.children)
|
||||
? legacyChildren
|
||||
: activeSheet.children;
|
||||
const activeViewport = isRecord(raw.viewport)
|
||||
? raw.viewport
|
||||
: activeSheet.viewport;
|
||||
const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme;
|
||||
const activeCanvas = isRecord(raw.canvas)
|
||||
? normalizeFlowBoardCanvas(raw.canvas)
|
||||
: normalizeFlowBoardCanvas(activeSheet.canvas);
|
||||
const nextSheets = sheets.map((sheet) =>
|
||||
sheet.id === activeSheet.id
|
||||
? {
|
||||
...sheet,
|
||||
children: activeChildren,
|
||||
viewport: activeViewport,
|
||||
theme: activeTheme,
|
||||
canvas: activeCanvas,
|
||||
}
|
||||
: sheet
|
||||
);
|
||||
const updatedAt =
|
||||
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||
? raw.updatedAt
|
||||
: new Date().toISOString();
|
||||
|
||||
return {
|
||||
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
|
||||
viewport: normalizeRecord(raw.viewport),
|
||||
theme: normalizeRecord(raw.theme),
|
||||
version: 2,
|
||||
activeSheetId: activeSheet.id,
|
||||
sheets: nextSheets,
|
||||
children: activeChildren,
|
||||
viewport: activeViewport,
|
||||
theme: activeTheme,
|
||||
canvas: activeCanvas,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { generatedImage, imageArchiveTask } from "@/lib/db/schema";
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@ -19,12 +19,23 @@ export async function GET(request: NextRequest) {
|
||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
||||
const offset = (page - 1) * pageSize;
|
||||
const visibleImageFilter = and(
|
||||
eq(generatedImage.userId, userId),
|
||||
isNull(imageArchiveTask.id)
|
||||
);
|
||||
|
||||
// 查询总数
|
||||
const countResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.where(eq(generatedImage.userId, userId));
|
||||
.leftJoin(
|
||||
imageArchiveTask,
|
||||
and(
|
||||
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
|
||||
eq(imageArchiveTask.status, "abandoned")
|
||||
)
|
||||
)
|
||||
.where(visibleImageFilter);
|
||||
|
||||
const total = countResult[0]?.count || 0;
|
||||
|
||||
@ -42,7 +53,14 @@ export async function GET(request: NextRequest) {
|
||||
modelId: generatedImage.modelId, // 添加 modelId 字段
|
||||
})
|
||||
.from(generatedImage)
|
||||
.where(eq(generatedImage.userId, userId))
|
||||
.leftJoin(
|
||||
imageArchiveTask,
|
||||
and(
|
||||
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
|
||||
eq(imageArchiveTask.status, "abandoned")
|
||||
)
|
||||
)
|
||||
.where(visibleImageFilter)
|
||||
.orderBy(desc(generatedImage.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
34
app/api/generated-image-preview/[key]/route.ts
Normal file
34
app/api/generated-image-preview/[key]/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { imageArchiveTask } from "@/lib/db/schema";
|
||||
|
||||
// The SHA-256 URL is a capability, like the provider's signed image URL. It must be
|
||||
// readable by image nodes and reference-image providers without a session cookie.
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ key: string }> }) {
|
||||
const { key } = await params;
|
||||
if (!/^[a-f0-9]{64}$/.test(key)) return new NextResponse(null, { status: 404 });
|
||||
const configured =
|
||||
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
||||
const origin = configured
|
||||
? new URL(configured).origin
|
||||
: `http://localhost:${process.env.PORT || "3000"}`;
|
||||
const sourceUrl = `${origin}/api/generated-image-preview/${key}`;
|
||||
const [task] = await db
|
||||
.select({ targetUrl: imageArchiveTask.targetUrl, metadata: imageArchiveTask.metadata })
|
||||
.from(imageArchiveTask)
|
||||
.where(eq(imageArchiveTask.sourceUrl, sourceUrl))
|
||||
.limit(1);
|
||||
if (task?.targetUrl) return NextResponse.redirect(task.targetUrl, 307);
|
||||
const inline = (task?.metadata as { inlineSource?: string } | null)?.inlineSource;
|
||||
const match = inline?.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||
if (!match) return new NextResponse(null, { status: 404 });
|
||||
return new NextResponse(Buffer.from(match[2], "base64"), {
|
||||
headers: {
|
||||
"Content-Type": match[1],
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -14,6 +14,10 @@ import {
|
||||
updateMessageById,
|
||||
} from "@/lib/db/queries";
|
||||
import type { DBMessage } from "@/lib/db/schema";
|
||||
import {
|
||||
buildOpenClawArtifactProxyUrl,
|
||||
normalizeOpenClawArtifacts,
|
||||
} from "@/lib/openclaw-chat/artifacts";
|
||||
import {
|
||||
getFalImageTaskStatus,
|
||||
resolveFalImageTaskToMarkdown,
|
||||
@ -32,6 +36,32 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
||||
const falTaskLastPolledAt = new Map<string, number>();
|
||||
const falTaskSyncInFlight = new Set<string>();
|
||||
|
||||
function toPublicVideoMetadata(params: {
|
||||
message: DBMessage;
|
||||
requestOrigin: string;
|
||||
}) {
|
||||
const value = params.message.videoMetadata;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const metadata = value as Record<string, unknown>;
|
||||
if (metadata.taskKind !== "clawd_chat") return value;
|
||||
const publicMetadata = { ...metadata };
|
||||
delete publicMetadata.upstreamTaskKey;
|
||||
publicMetadata.artifacts = normalizeOpenClawArtifacts(metadata.artifacts).map(
|
||||
(artifact, artifactIndex) => ({
|
||||
...artifact,
|
||||
downloadUrl: new URL(
|
||||
buildOpenClawArtifactProxyUrl({
|
||||
chatId: params.message.chatId,
|
||||
messageId: params.message.id,
|
||||
artifactIndex,
|
||||
}),
|
||||
params.requestOrigin
|
||||
).toString(),
|
||||
})
|
||||
);
|
||||
return publicMetadata;
|
||||
}
|
||||
|
||||
function createEmptyMessagesResponse() {
|
||||
return NextResponse.json(
|
||||
{ messages: [] },
|
||||
@ -277,7 +307,11 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: msg.createdAt,
|
||||
parts: parts,
|
||||
attachments: msg.attachments || [],
|
||||
videoMetadata: msg.videoMetadata || null,
|
||||
videoMetadata:
|
||||
toPublicVideoMetadata({
|
||||
message: msg,
|
||||
requestOrigin: request.nextUrl.origin,
|
||||
}) || 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,138 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
executeRmbgJob,
|
||||
fetchImageBufferFromUrl,
|
||||
RmbgServiceError,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
|
||||
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
|
||||
let imageBuffer: Buffer;
|
||||
let imageBase64: string | undefined;
|
||||
let prompt = "抠图处理";
|
||||
let chatId: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let skipGallerySave = false;
|
||||
let sourceImageUrl: string | null = null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
prompt = body.prompt || "抠图处理";
|
||||
chatId = body.chatId || null;
|
||||
messageId = body.messageId || null;
|
||||
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
|
||||
|
||||
const imageUrl = body.imageUrl || body.image_url;
|
||||
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
|
||||
|
||||
if (imageUrl) {
|
||||
sourceImageUrl = String(imageUrl);
|
||||
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
||||
} else if (body.image_base64) {
|
||||
imageBase64 = String(body.image_base64);
|
||||
imageBuffer = Buffer.from(imageBase64, "base64");
|
||||
if (explicitSourceImageUrl) {
|
||||
sourceImageUrl = String(explicitSourceImageUrl);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "缺少 imageUrl 或 image_base64 参数" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
imageBuffer = Buffer.from(arrayBuffer);
|
||||
imageBase64 = imageBuffer.toString("base64");
|
||||
prompt = (formData.get("prompt") as string) || "抠图处理";
|
||||
chatId = (formData.get("chatId") as string) || null;
|
||||
messageId = (formData.get("messageId") as string) || null;
|
||||
skipGallerySave =
|
||||
formData.get("skipGallerySave") === "true" || formData.get("skip_gallery_save") === "1";
|
||||
sourceImageUrl =
|
||||
(formData.get("sourceImageUrl") as string) ||
|
||||
(formData.get("source_image_url") as string) ||
|
||||
null;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "remove-background"),
|
||||
reason: "image_remove_background",
|
||||
modelId: "rmbg",
|
||||
note: "图片抠图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
userId: session.user.id,
|
||||
imageBuffer,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
skipGallerySave,
|
||||
sourceImageUrl,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume RMBG points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RmbgServiceError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
detail: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[RMBG API] 处理错误:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return executeRemoveBackgroundPOST(request, session.user.id);
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ 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 { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import {
|
||||
canProcessSceneFusionProviderType,
|
||||
@ -1565,6 +1566,18 @@ async function processSceneWithProvider(
|
||||
imageGenConfig,
|
||||
);
|
||||
|
||||
case "vip98k":
|
||||
return processWithVip98k(
|
||||
productImageUrl,
|
||||
sceneImageUrl,
|
||||
prompt,
|
||||
provider,
|
||||
runtimeModelId || modelId,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
imageGenConfig,
|
||||
);
|
||||
|
||||
case "gemini":
|
||||
case "gemini-image":
|
||||
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||
@ -1933,6 +1946,45 @@ async function processWithAtlasCloud(
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
async function processWithVip98k(
|
||||
productImageUrl: string,
|
||||
sceneImageUrl: string,
|
||||
prompt: string,
|
||||
provider: { baseUrl: string; apiKey: string },
|
||||
modelId: string,
|
||||
aspectRatio?: string,
|
||||
imageSize?: string,
|
||||
imageGenConfig?: Record<string, any> | null
|
||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||
logger.info("provider_processing", { providerType: "vip98k", modelId });
|
||||
|
||||
const vip98kProvider = createVip98k({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
const result = await vip98kProvider.chat(modelId, imageGenConfig || null).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
aspectRatio: aspectRatio || "auto",
|
||||
imageSize: imageSize || "2K",
|
||||
imageCount: 1,
|
||||
},
|
||||
imageGenConfig,
|
||||
});
|
||||
|
||||
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用既梦/火山模型处理
|
||||
*/
|
||||
|
||||
@ -399,7 +399,7 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
|
||||
function normalizeLovartComposerPrefix(value: unknown): string {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return "让「由壹点AI」";
|
||||
return "让「𝗕𝘂𝗬𝗶」";
|
||||
}
|
||||
return normalized.slice(0, 40);
|
||||
}
|
||||
@ -573,17 +573,21 @@ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||
]);
|
||||
|
||||
const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||
siteName: "AI Chatbot",
|
||||
siteName: "𝗕𝘂𝗬𝗶",
|
||||
siteLogo: null,
|
||||
siteLogoDark: null,
|
||||
favicon: null,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
bannerActionText: "立即查看",
|
||||
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||
surpriseCategories: ["全部", "精选"],
|
||||
canvasDefaultModelId: null,
|
||||
lovartComposerPrefix: "让「由壹点AI」",
|
||||
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
|
||||
lovartComposerPreviewTexts: [],
|
||||
lovartPromptTagVisibility: {},
|
||||
contactConfig: normalizeContactConfig(undefined),
|
||||
@ -660,7 +664,7 @@ async function readScopedSiteSettings(keys: string[]) {
|
||||
|
||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||
return {
|
||||
siteName: String(settings?.siteName || "AI Chatbot"),
|
||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||
siteLogo: settings?.siteLogo || null,
|
||||
siteLogoDark: settings?.siteLogoDark || null,
|
||||
favicon: settings?.favicon || null,
|
||||
@ -818,7 +822,7 @@ async function getScopedPublicSiteSettings(
|
||||
usageTimeStart: typedSettings?.usageTimeStart || "08:00",
|
||||
usageTimeEnd: typedSettings?.usageTimeEnd || "20:00",
|
||||
usageTimeMessage:
|
||||
typedSettings?.usageTimeMessage || "由壹点累了也是需要休息的",
|
||||
typedSettings?.usageTimeMessage || "BuYi 累了也是需要休息的",
|
||||
});
|
||||
}
|
||||
|
||||
@ -897,6 +901,7 @@ async function getScopedPublicSiteSettings(
|
||||
bannerText:
|
||||
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: typedSettings?.bannerLink || null,
|
||||
bannerActionText: typedSettings?.bannerActionText || "立即查看",
|
||||
lovartComposerPrefix: normalizeLovartComposerPrefix(
|
||||
valueByKey.get(LOVART_COMPOSER_PREFIX_KEY)
|
||||
),
|
||||
@ -939,13 +944,18 @@ async function getScopedPublicSiteSettings(
|
||||
|
||||
return createScopedSiteSettingsResponse(scope, {
|
||||
...createBrandingSettings(typedSettings),
|
||||
bannerEnabled: typedSettings?.bannerEnabled ?? true,
|
||||
bannerText:
|
||||
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: typedSettings?.bannerLink || null,
|
||||
bannerActionText: typedSettings?.bannerActionText || "立即查看",
|
||||
lovartPromoText:
|
||||
typedSettings?.lovartPromoText ||
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级",
|
||||
lovartHeroTitle: typedSettings?.lovartHeroTitle || "由壹点AI 让设计更简单",
|
||||
lovartHeroTitle: typedSettings?.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle:
|
||||
typedSettings?.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
||||
typedSettings?.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||
surpriseCategories: normalizeSurpriseCategories(
|
||||
valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY)
|
||||
),
|
||||
@ -1466,10 +1476,11 @@ export async function GET(request: NextRequest) {
|
||||
usageTimeEnabled: false,
|
||||
usageTimeStart: "08:00",
|
||||
usageTimeEnd: "20:00",
|
||||
usageTimeMessage: "由壹点累了也是需要休息的",
|
||||
usageTimeMessage: "BuYi 累了也是需要休息的",
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
bannerActionText: "立即查看",
|
||||
toolbox1Name: "工具箱",
|
||||
toolbox1Enabled: true,
|
||||
toolbox2Name: "工具箱2",
|
||||
@ -1514,8 +1525,8 @@ export async function GET(request: NextRequest) {
|
||||
canvasDetailAgentModelId: null,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||
surpriseCategories,
|
||||
aiDetailGenerationEnabled,
|
||||
lovartComposerPrefix,
|
||||
@ -1535,8 +1546,8 @@ export async function GET(request: NextRequest) {
|
||||
baseSettings.lovartPromoText ||
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级",
|
||||
lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: baseSettings.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||
notification,
|
||||
surpriseCategories,
|
||||
aiDetailGenerationEnabled,
|
||||
@ -1651,6 +1662,7 @@ const updateSchema = z.object({
|
||||
bannerEnabled: z.boolean().optional(),
|
||||
bannerText: z.string().optional(),
|
||||
bannerLink: z.string().nullable().optional(),
|
||||
bannerActionText: z.string().max(32).optional(),
|
||||
toolbox1Name: z.string().max(64).optional(),
|
||||
toolbox1Enabled: z.boolean().optional(),
|
||||
toolbox2Name: z.string().max(64).optional(),
|
||||
@ -3107,10 +3119,11 @@ export async function PUT(request: NextRequest) {
|
||||
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
|
||||
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
|
||||
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
|
||||
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的",
|
||||
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "BuYi 累了也是需要休息的",
|
||||
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
||||
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: siteSettingsPayload.bannerLink,
|
||||
bannerActionText: siteSettingsPayload.bannerActionText ?? "立即查看",
|
||||
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
|
||||
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
|
||||
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",
|
||||
@ -3131,8 +3144,8 @@ export async function PUT(request: NextRequest) {
|
||||
siteSettingsPayload.lovartPromoText ??
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级",
|
||||
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "下达指令,即刻呈现",
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { getSystemSetting, getUserById } from "@/lib/db/queries";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelDisplayEntries,
|
||||
@ -26,10 +26,16 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser = await getUserById({ id: session.user.id });
|
||||
const allowHiddenModels =
|
||||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
||||
|
||||
const [displayEntries, entries, realisticModelEntries, setting] =
|
||||
await Promise.all([
|
||||
getStyleProImageModelDisplayEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: false }),
|
||||
getStyleProImageModelDisplayEntries({
|
||||
includeHidden: allowHiddenModels,
|
||||
}),
|
||||
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
|
||||
getStyleProImageModelEntries({ includeHidden: true }),
|
||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
|
||||
35
app/api/tvstar/plugin/download/route.ts
Normal file
35
app/api/tvstar/plugin/download/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const packageFiles = [
|
||||
"package.json", "package-lock.json", "api-client.mjs", "cli.mjs", "mcp-server.mjs",
|
||||
"yedir-canvas/SKILL.md", "yedir-canvas/agents/openai.yaml",
|
||||
"yedir-canvas/references/operation-protocol.md",
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.type === "guest") {
|
||||
return NextResponse.json({ error: "请先登录正式账号" }, { status: 401 });
|
||||
}
|
||||
const zip = new JSZip();
|
||||
const base = path.join(process.cwd(), "integrations", "openclaw");
|
||||
try {
|
||||
await Promise.all(packageFiles.map(async file => {
|
||||
zip.file(`tvstar-mcp/${file}`, await readFile(path.join(base, file)));
|
||||
}));
|
||||
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
||||
return new NextResponse(archive, { headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": 'attachment; filename="tvstar-mcp.zip"',
|
||||
"Cache-Control": "private, no-store",
|
||||
} });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "插件安装包暂不可用" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
40
app/api/user/access-keys/[id]/route.ts
Normal file
40
app/api/user/access-keys/[id]/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { revokeUserAccessKey } from "@/lib/access-keys/service";
|
||||
|
||||
const idSchema = z.string().uuid();
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.type === "guest") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请先登录正式账号" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsedId = idSchema.safeParse((await params).id);
|
||||
if (!parsedId.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Access Key ID 无效" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const revoked = await revokeUserAccessKey({
|
||||
userId: session.user.id,
|
||||
accessKeyId: parsedId.data,
|
||||
});
|
||||
if (!revoked) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Access Key 不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
76
app/api/user/access-keys/route.ts
Normal file
76
app/api/user/access-keys/route.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createUserAccessKey,
|
||||
listUserAccessKeys,
|
||||
} from "@/lib/access-keys/service";
|
||||
|
||||
const createAccessKeySchema = z.object({
|
||||
name: 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(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.type === "guest") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请先登录正式账号" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const keys = await listUserAccessKeys(session.user.id);
|
||||
return NextResponse.json(
|
||||
{ success: true, data: keys },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.type === "guest") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请先登录正式账号" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingKeys = await listUserAccessKeys(session.user.id);
|
||||
const activeKeyCount = existingKeys.filter(
|
||||
(item) =>
|
||||
!item.revokedAt &&
|
||||
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
|
||||
).length;
|
||||
if (activeKeyCount >= 10) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "最多保留 10 个有效 Access Key" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = createAccessKeySchema.parse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
const created = await createUserAccessKey({
|
||||
userId: session.user.id,
|
||||
name: body?.name,
|
||||
scopes: body?.scopes,
|
||||
expiresInDays: body?.expiresInDays,
|
||||
});
|
||||
return NextResponse.json({ success: true, data: created }, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "创建 Access Key 失败",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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