Compare commits
193 Commits
main
...
codex/canv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@ -153,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
|
|||||||
# Home Performance / Queues
|
# Home Performance / Queues
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
CANVAS_IMAGE_TASK_CONCURRENCY=3
|
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
|
||||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
|
CANVAS_IMAGE_TASK_CONCURRENCY=0
|
||||||
|
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
|
||||||
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
||||||
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
||||||
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
||||||
|
|||||||
28
.gitignore
vendored
28
.gitignore
vendored
@ -50,6 +50,7 @@ yarn-error.log*
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/*
|
/playwright/*
|
||||||
|
/output/
|
||||||
deploy.tar.gz
|
deploy.tar.gz
|
||||||
libvips-8.14.5-linux-x64.tar.br
|
libvips-8.14.5-linux-x64.tar.br
|
||||||
.temp-packages/
|
.temp-packages/
|
||||||
@ -70,6 +71,10 @@ migration-*.txt
|
|||||||
artifacts/liblib-*/
|
artifacts/liblib-*/
|
||||||
analysis/qiaodou-extract/
|
analysis/qiaodou-extract/
|
||||||
|
|
||||||
|
# 本地运行时数据库与上传文件
|
||||||
|
.local/
|
||||||
|
public/uploads/local/
|
||||||
|
|
||||||
# 模型文件
|
# 模型文件
|
||||||
*.model
|
*.model
|
||||||
*.pth
|
*.pth
|
||||||
@ -100,3 +105,26 @@ public/vendor/zoo-modeling-app/
|
|||||||
|
|
||||||
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||||
!lib/db/migrations/*.sql
|
!lib/db/migrations/*.sql
|
||||||
|
|
||||||
|
# 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__/
|
||||||
|
|||||||
@ -94,7 +94,7 @@ pnpm db:generate
|
|||||||
推荐提交前至少运行:
|
推荐提交前至少运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch github main --prune
|
git fetch origin --prune
|
||||||
pnpm check:branch
|
pnpm check:branch
|
||||||
pnpm check:quality
|
pnpm check:quality
|
||||||
pnpm audit:security
|
pnpm audit:security
|
||||||
|
|||||||
@ -665,7 +665,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
</p>
|
</p>
|
||||||
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
|
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
|
||||||
<p>
|
<p>
|
||||||
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
|
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
|
||||||
</p>
|
</p>
|
||||||
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
|
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
|
||||||
</div>
|
</div>
|
||||||
@ -784,7 +784,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
<p>• 支持2x、4x、8x放大倍数</p>
|
<p>• 支持2x、4x、8x放大倍数</p>
|
||||||
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
|
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
|
||||||
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
|
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
|
||||||
<p>• 文件限制:最大50MB</p>
|
<p>• 文件限制:最大20MB</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -2377,13 +2377,10 @@ export function LocalAiImagePipelineSections({
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
extendConfig.provider === "vip98k"
|
||||||
? "Key 已配置"
|
? "VIP98K 服务商"
|
||||||
: "Key 待配置",
|
: "阿里云服务商",
|
||||||
tone:
|
tone: "enabled",
|
||||||
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
|
||||||
? "enabled"
|
|
||||||
: "warning",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
||||||
@ -2400,22 +2397,11 @@ export function LocalAiImagePipelineSections({
|
|||||||
VIP98K 服务商读取,仅在这里维护扩展比例。
|
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||||
<Label htmlFor="extendApiKey">API密钥 *</Label>
|
使用阿里云 DashScope 的 image-out-painting 模型。API Key
|
||||||
<Input
|
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
|
||||||
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
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -2470,7 +2456,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
|
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
|
||||||
</p>
|
</p>
|
||||||
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
|
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
|
||||||
<p>• 最大图片大小:4MB</p>
|
<p>• 最大图片大小:10MB</p>
|
||||||
<p>• 处理时间:约5-15秒/张</p>
|
<p>• 处理时间:约5-15秒/张</p>
|
||||||
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
|
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
|
|||||||
</Button>
|
</Button>
|
||||||
</LocalAiSectionShell>
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
{/* 全站生图单模型并发 */}
|
|
||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
|
||||||
<>
|
|
||||||
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
|
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
|
||||||
id="local-ai-section-canvas-image-task-queue"
|
id="local-ai-section-canvas-image-task-queue"
|
||||||
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
|
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
|
||||||
summary={
|
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
|
||||||
<LocalAiSectionSummary
|
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
|
||||||
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>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
|
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
|
||||||
<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>
|
|
||||||
</LocalAiSectionShell>
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
{/* 批量处理配置 */}
|
{/* 批量处理配置 */}
|
||||||
|
|||||||
@ -275,7 +275,6 @@ export type EnhanceConfig = {
|
|||||||
|
|
||||||
export type ExtendConfig = {
|
export type ExtendConfig = {
|
||||||
provider: "vip98k" | "dashscope";
|
provider: "vip98k" | "dashscope";
|
||||||
apiKey: string;
|
|
||||||
xScale: string;
|
xScale: string;
|
||||||
yScale: string;
|
yScale: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
ImagePlus,
|
ImagePlus,
|
||||||
Key,
|
Key,
|
||||||
Layers,
|
Layers,
|
||||||
|
LayoutTemplate,
|
||||||
Layers3,
|
Layers3,
|
||||||
Loader2,
|
Loader2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@ -1061,6 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
|||||||
imageRemoveBackground: Scissors,
|
imageRemoveBackground: Scissors,
|
||||||
imageBoxCutout: Scan,
|
imageBoxCutout: Scan,
|
||||||
imageLocalEdit: Wand2,
|
imageLocalEdit: Wand2,
|
||||||
|
imageLayoutReplace: LayoutTemplate,
|
||||||
imageRemoveWatermark: Eraser,
|
imageRemoveWatermark: Eraser,
|
||||||
imageResize: Maximize2,
|
imageResize: Maximize2,
|
||||||
imageTextEdit: Type,
|
imageTextEdit: Type,
|
||||||
@ -2180,7 +2182,7 @@ export function AdminSettingsClient() {
|
|||||||
usageTimeEnabled: false,
|
usageTimeEnabled: false,
|
||||||
usageTimeStart: "08:00",
|
usageTimeStart: "08:00",
|
||||||
usageTimeEnd: "20:00",
|
usageTimeEnd: "20:00",
|
||||||
usageTimeMessage: "由壹点累了也是需要休息的",
|
usageTimeMessage: "BuYi 累了也是需要休息的",
|
||||||
defaultTheme: "system",
|
defaultTheme: "system",
|
||||||
bannerEnabled: true,
|
bannerEnabled: true,
|
||||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
@ -2222,7 +2224,7 @@ export function AdminSettingsClient() {
|
|||||||
agentWorkflowDefaultModelId: null,
|
agentWorkflowDefaultModelId: null,
|
||||||
canvasDetailDefaultModelId: null,
|
canvasDetailDefaultModelId: null,
|
||||||
canvasDetailAgentModelId: null,
|
canvasDetailAgentModelId: null,
|
||||||
lovartComposerPrefix: "让「由壹点AI」",
|
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
|
||||||
lovartComposerPreviewTexts: [
|
lovartComposerPreviewTexts: [
|
||||||
"打造引人注目的社交媒体视觉",
|
"打造引人注目的社交媒体视觉",
|
||||||
"帮我把这张产品图做成高级电商海报",
|
"帮我把这张产品图做成高级电商海报",
|
||||||
@ -2238,8 +2240,8 @@ export function AdminSettingsClient() {
|
|||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
lovartPromoActionText: "立即升级",
|
lovartPromoActionText: "立即升级",
|
||||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||||
surpriseCategories: ["全部", "精选"],
|
surpriseCategories: ["全部", "精选"],
|
||||||
membershipConfig: defaultMembershipConfig,
|
membershipConfig: defaultMembershipConfig,
|
||||||
membershipUserOverrides: defaultMembershipUserOverrides,
|
membershipUserOverrides: defaultMembershipUserOverrides,
|
||||||
@ -2300,7 +2302,7 @@ export function AdminSettingsClient() {
|
|||||||
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
|
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
|
||||||
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
|
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
|
||||||
usageTimeMessage:
|
usageTimeMessage:
|
||||||
siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的",
|
siteSettingsData.usageTimeMessage || "BuYi 累了也是需要休息的",
|
||||||
defaultTheme: siteSettingsData.defaultTheme || "system",
|
defaultTheme: siteSettingsData.defaultTheme || "system",
|
||||||
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
|
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
|
||||||
bannerText:
|
bannerText:
|
||||||
@ -2396,7 +2398,7 @@ export function AdminSettingsClient() {
|
|||||||
(siteSettingsData as any).canvasDetailAgentModelId || null,
|
(siteSettingsData as any).canvasDetailAgentModelId || null,
|
||||||
lovartComposerPrefix:
|
lovartComposerPrefix:
|
||||||
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
|
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
|
||||||
"让「由壹点AI」",
|
"让「𝗕𝘂𝗬𝗶」",
|
||||||
lovartComposerPreviewTexts: Array.isArray(
|
lovartComposerPreviewTexts: Array.isArray(
|
||||||
siteSettingsData.lovartComposerPreviewTexts
|
siteSettingsData.lovartComposerPreviewTexts
|
||||||
)
|
)
|
||||||
@ -2498,9 +2500,9 @@ export function AdminSettingsClient() {
|
|||||||
lovartPromoActionText:
|
lovartPromoActionText:
|
||||||
siteSettingsData.lovartPromoActionText || "立即升级",
|
siteSettingsData.lovartPromoActionText || "立即升级",
|
||||||
lovartHeroTitle:
|
lovartHeroTitle:
|
||||||
siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
|
siteSettingsData.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||||
lovartHeroSubtitle:
|
lovartHeroSubtitle:
|
||||||
siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
siteSettingsData.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||||
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
|
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
|
||||||
? siteSettingsData.surpriseCategories
|
? siteSettingsData.surpriseCategories
|
||||||
.map((item: any) => String(item).trim())
|
.map((item: any) => String(item).trim())
|
||||||
@ -2631,8 +2633,7 @@ export function AdminSettingsClient() {
|
|||||||
|
|
||||||
// 图像拓展API配置状态
|
// 图像拓展API配置状态
|
||||||
const [extendConfig, setExtendConfig] = useState({
|
const [extendConfig, setExtendConfig] = useState({
|
||||||
provider: "vip98k" as "vip98k" | "dashscope",
|
provider: "dashscope" as "vip98k" | "dashscope",
|
||||||
apiKey: "",
|
|
||||||
xScale: "1.5", // X轴放大倍数
|
xScale: "1.5", // X轴放大倍数
|
||||||
yScale: "1.5", // Y轴放大倍数
|
yScale: "1.5", // Y轴放大倍数
|
||||||
});
|
});
|
||||||
@ -2951,13 +2952,11 @@ export function AdminSettingsClient() {
|
|||||||
if (extendConfigData?.setting?.value) {
|
if (extendConfigData?.setting?.value) {
|
||||||
const config = extendConfigData.setting.value as {
|
const config = extendConfigData.setting.value as {
|
||||||
provider?: "vip98k" | "dashscope";
|
provider?: "vip98k" | "dashscope";
|
||||||
apiKey?: string;
|
|
||||||
xScale?: number;
|
xScale?: number;
|
||||||
yScale?: number;
|
yScale?: number;
|
||||||
};
|
};
|
||||||
setExtendConfig({
|
setExtendConfig({
|
||||||
provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
|
provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
|
||||||
apiKey: config.apiKey || "",
|
|
||||||
xScale: config.xScale ? String(config.xScale) : "1.5",
|
xScale: config.xScale ? String(config.xScale) : "1.5",
|
||||||
yScale: config.yScale ? String(config.yScale) : "1.5",
|
yScale: config.yScale ? String(config.yScale) : "1.5",
|
||||||
});
|
});
|
||||||
@ -5554,11 +5553,6 @@ export function AdminSettingsClient() {
|
|||||||
// --- 图像拓展API配置逻辑 ---
|
// --- 图像拓展API配置逻辑 ---
|
||||||
const handleSaveExtendConfig = async () => {
|
const handleSaveExtendConfig = async () => {
|
||||||
try {
|
try {
|
||||||
if (extendConfig.provider !== "vip98k" && !extendConfig.apiKey.trim()) {
|
|
||||||
toast.error("请输入API密钥");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const xScale = Number.parseFloat(extendConfig.xScale);
|
const xScale = Number.parseFloat(extendConfig.xScale);
|
||||||
const yScale = Number.parseFloat(extendConfig.yScale);
|
const yScale = Number.parseFloat(extendConfig.yScale);
|
||||||
|
|
||||||
@ -5579,7 +5573,6 @@ export function AdminSettingsClient() {
|
|||||||
key: "extend_api_config",
|
key: "extend_api_config",
|
||||||
value: {
|
value: {
|
||||||
provider: extendConfig.provider,
|
provider: extendConfig.provider,
|
||||||
apiKey: extendConfig.apiKey.trim(),
|
|
||||||
xScale,
|
xScale,
|
||||||
yScale,
|
yScale,
|
||||||
},
|
},
|
||||||
@ -8913,9 +8906,7 @@ export function AdminSettingsClient() {
|
|||||||
{
|
{
|
||||||
key: "realtime-canvas",
|
key: "realtime-canvas",
|
||||||
title: "实时与队列",
|
title: "实时与队列",
|
||||||
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${(
|
description: "生图并发不限,任务即时发起",
|
||||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
|
||||||
).toFixed(1)} 秒`,
|
|
||||||
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
|
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
|
||||||
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
|
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
|
||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
@ -8998,7 +8989,7 @@ export function AdminSettingsClient() {
|
|||||||
key: "extend",
|
key: "extend",
|
||||||
label: "图像拓展",
|
label: "图像拓展",
|
||||||
group: "图像处理",
|
group: "图像处理",
|
||||||
tone: extendConfig.apiKey ? "enabled" : "warning",
|
tone: "enabled",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "watermark-remove",
|
key: "watermark-remove",
|
||||||
@ -10522,7 +10513,7 @@ export function AdminSettingsClient() {
|
|||||||
lovartComposerPrefix: e.target.value.slice(0, 40),
|
lovartComposerPrefix: e.target.value.slice(0, 40),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="让「由壹点AI」"
|
placeholder="让「𝗕𝘂𝗬𝗶」"
|
||||||
value={siteSettings.lovartComposerPrefix}
|
value={siteSettings.lovartComposerPrefix}
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
@ -10724,7 +10715,7 @@ export function AdminSettingsClient() {
|
|||||||
lovartHeroTitle: e.target.value,
|
lovartHeroTitle: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="由壹点AI 让设计更简单"
|
placeholder="𝗕𝘂𝗬𝗶 让设计触手可及"
|
||||||
value={siteSettings.lovartHeroTitle}
|
value={siteSettings.lovartHeroTitle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -10738,7 +10729,7 @@ export function AdminSettingsClient() {
|
|||||||
lovartHeroSubtitle: e.target.value,
|
lovartHeroSubtitle: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="懂你的设计代理,帮你搞定一切"
|
placeholder="下达指令,即刻呈现"
|
||||||
value={siteSettings.lovartHeroSubtitle}
|
value={siteSettings.lovartHeroSubtitle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
@ -24,10 +25,6 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
|
||||||
isImageGenerationModelDescriptor,
|
|
||||||
isVideoModelDescriptor,
|
|
||||||
} from "@/lib/ai/model-classification";
|
|
||||||
import { fetcher } from "@/lib/utils";
|
import { fetcher } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
getVideoCanvasFunctionBillingCopy,
|
getVideoCanvasFunctionBillingCopy,
|
||||||
@ -87,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) {
|
|||||||
return `${name}${id}`;
|
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({
|
function ModelSelect({
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
kind,
|
kind,
|
||||||
|
functionKey,
|
||||||
models,
|
models,
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
allowInherit?: boolean;
|
allowInherit?: boolean;
|
||||||
kind: VideoCanvasFunctionDefinition["kind"];
|
kind: VideoCanvasFunctionDefinition["kind"];
|
||||||
|
functionKey?: string;
|
||||||
models: AiModelOption[];
|
models: AiModelOption[];
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
value: string;
|
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 normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
|
||||||
const hasCurrent =
|
const hasCurrent =
|
||||||
normalizedValue === "auto" ||
|
normalizedValue === "auto" ||
|
||||||
@ -851,13 +815,14 @@ export function VideoCanvasSettingsClient() {
|
|||||||
<div className="grid gap-4 md:grid-cols-1">
|
<div className="grid gap-4 md:grid-cols-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
|
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
|
||||||
<ModelSelect
|
{["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
|
||||||
allowInherit
|
allowInherit
|
||||||
|
functionKey={item.key}
|
||||||
kind={item.kind}
|
kind={item.kind}
|
||||||
models={modelOptions}
|
models={modelOptions}
|
||||||
onChange={(value) => updateFunction(item.key, { model: value })}
|
onChange={(value) => updateFunction(item.key, { model: value })}
|
||||||
value={item.model || ""}
|
value={item.model || ""}
|
||||||
/>
|
/> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
|
<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">
|
<div className="text-xs font-medium text-foreground">
|
||||||
|
|||||||
@ -2,9 +2,31 @@ import { createLogger } from "@/lib/observability/logger";
|
|||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
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 { 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 { 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 { uploadFile } from "@/lib/storage/service";
|
||||||
import { bufferToArrayBuffer } from "./persistence";
|
import { bufferToArrayBuffer } from "./persistence";
|
||||||
import { isVideoProviderType } from "./video-models";
|
import { isVideoProviderType } from "./video-models";
|
||||||
@ -234,6 +256,8 @@ export async function normalizeReplacementResultImage(params: {
|
|||||||
referenceImageUrl: string;
|
referenceImageUrl: string;
|
||||||
referenceGeometry?: ReplacementReferenceGeometry | null;
|
referenceGeometry?: ReplacementReferenceGeometry | null;
|
||||||
scenePlan?: ReplacementScenePlan | null;
|
scenePlan?: ReplacementScenePlan | null;
|
||||||
|
preserveReferenceOutsideSubject?: boolean;
|
||||||
|
preserveReferenceText?: boolean;
|
||||||
uploadPrefix: string;
|
uploadPrefix: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
originalUrl: string;
|
originalUrl: string;
|
||||||
@ -242,6 +266,11 @@ export async function normalizeReplacementResultImage(params: {
|
|||||||
}> {
|
}> {
|
||||||
const sharp = (await import("sharp")).default;
|
const sharp = (await import("sharp")).default;
|
||||||
const generatedBuffer = await fetchImageBufferFromUrl(params.generatedImageUrl);
|
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 targetWidth = params.referenceGeometry?.width;
|
||||||
let targetHeight = params.referenceGeometry?.height;
|
let targetHeight = params.referenceGeometry?.height;
|
||||||
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
|
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
|
||||||
@ -250,8 +279,8 @@ export async function normalizeReplacementResultImage(params: {
|
|||||||
|
|
||||||
if (!targetWidth || !targetHeight) {
|
if (!targetWidth || !targetHeight) {
|
||||||
try {
|
try {
|
||||||
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl);
|
const resolvedReferenceBuffer = await getReferenceBuffer();
|
||||||
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
|
const referenceMetadata = await sharp(resolvedReferenceBuffer, { failOn: "none" })
|
||||||
.rotate()
|
.rotate()
|
||||||
.metadata();
|
.metadata();
|
||||||
targetWidth = referenceMetadata.width;
|
targetWidth = referenceMetadata.width;
|
||||||
@ -298,7 +327,7 @@ export async function normalizeReplacementResultImage(params: {
|
|||||||
: shouldPreserveFullGeneratedCanvas
|
: shouldPreserveFullGeneratedCanvas
|
||||||
? "contain"
|
? "contain"
|
||||||
: "cover";
|
: "cover";
|
||||||
const normalizedBuffer =
|
let normalizedBuffer =
|
||||||
targetWidth && targetHeight
|
targetWidth && targetHeight
|
||||||
? await imagePipeline
|
? await imagePipeline
|
||||||
.resize(targetWidth, targetHeight, {
|
.resize(targetWidth, targetHeight, {
|
||||||
@ -312,6 +341,82 @@ export async function normalizeReplacementResultImage(params: {
|
|||||||
.png()
|
.png()
|
||||||
.toBuffer()
|
.toBuffer()
|
||||||
: await imagePipeline.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 thumbnailBuffer = await createAgentImageThumbnailBuffer(normalizedBuffer);
|
||||||
|
|
||||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@ -399,6 +504,12 @@ export type ReplacementScenePlan = {
|
|||||||
generationPrompt: string;
|
generationPrompt: string;
|
||||||
selectedProductImageIndexes: number[];
|
selectedProductImageIndexes: number[];
|
||||||
productSelectionReason: string;
|
productSelectionReason: string;
|
||||||
|
productIdentitySummary?: string;
|
||||||
|
productStructureSummary?: string;
|
||||||
|
productStructuralRisks?: string;
|
||||||
|
subjectBoundingBox?: NormalizedReplacementBox | null;
|
||||||
|
textBoundingBoxes?: NormalizedReplacementBox[];
|
||||||
|
layoutLockAnalyzed?: boolean;
|
||||||
analysisModelName?: string | null;
|
analysisModelName?: string | null;
|
||||||
analysisSource: "local_moonshot" | "configured_vision" | "heuristic";
|
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 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(
|
export function normalizeDetailReplacementStrategy(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): AgentDetailReplacementStrategy {
|
): AgentDetailReplacementStrategy {
|
||||||
@ -1441,7 +1734,27 @@ export function parseReplacementScenePlan(
|
|||||||
visualProfile: params.visualProfile,
|
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 = {
|
let scenePlan: ReplacementScenePlan = {
|
||||||
pageKind,
|
pageKind,
|
||||||
generationRoute,
|
generationRoute,
|
||||||
@ -1486,6 +1799,9 @@ export function parseReplacementScenePlan(
|
|||||||
generationPrompt,
|
generationPrompt,
|
||||||
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
|
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
|
||||||
productSelectionReason: productImageSelection.productSelectionReason,
|
productSelectionReason: productImageSelection.productSelectionReason,
|
||||||
|
subjectBoundingBox: layoutLocks.subjectBoundingBox,
|
||||||
|
textBoundingBoxes: layoutLocks.textBoundingBoxes,
|
||||||
|
layoutLockAnalyzed: layoutLocks.analyzed,
|
||||||
analysisModelName: null,
|
analysisModelName: null,
|
||||||
analysisSource: "heuristic",
|
analysisSource: "heuristic",
|
||||||
};
|
};
|
||||||
@ -1844,6 +2160,27 @@ export async function resolveDirectReplacementModelRef(params: {
|
|||||||
finalModelId?: string | null;
|
finalModelId?: string | null;
|
||||||
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
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();
|
const explicitModel = String(params.selectedChatModel || "").trim();
|
||||||
if (explicitModel && explicitModel !== "auto") {
|
if (explicitModel && explicitModel !== "auto") {
|
||||||
return explicitModel;
|
return explicitModel;
|
||||||
@ -1853,13 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
|
|||||||
return routedModel;
|
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 =
|
const preferred =
|
||||||
imageModels.find(({ model }) => model.isDefault) ||
|
imageModels.find(({ model }) => model.isDefault) ||
|
||||||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
|
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
|
||||||
@ -2121,6 +2451,16 @@ export async function analyzeReplacementReferenceImage(params: {
|
|||||||
export async function resolveReplacementScenePlanningRuntime(params: {
|
export async function resolveReplacementScenePlanningRuntime(params: {
|
||||||
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
||||||
}): Promise<ReplacementScenePlanningRuntime> {
|
}): 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 {
|
try {
|
||||||
const siteSettings = await getSiteSettings().catch(() => null);
|
const siteSettings = await getSiteSettings().catch(() => null);
|
||||||
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
|
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
|
||||||
@ -2191,6 +2531,13 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
const referenceVisualProfile = isDetailReplacement
|
const referenceVisualProfile = isDetailReplacement
|
||||||
? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl)
|
? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl)
|
||||||
: null;
|
: null;
|
||||||
|
const productIdentityProfile =
|
||||||
|
params.planningRuntime && params.productImageUrls.length > 0
|
||||||
|
? await resolveReplacementProductIdentityProfile({
|
||||||
|
productImageUrls: params.productImageUrls,
|
||||||
|
planningRuntime: params.planningRuntime,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (params.planningRuntime) {
|
if (params.planningRuntime) {
|
||||||
try {
|
try {
|
||||||
const replacementScenePlanningTimeoutMs =
|
const replacementScenePlanningTimeoutMs =
|
||||||
@ -2221,7 +2568,10 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
|
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
|
||||||
: "你是电商详情替换流程中的“场景理解规划器”。",
|
: "你是电商详情替换流程中的“场景理解规划器”。",
|
||||||
`当前要分析的参考图:${params.referenceLabel}`,
|
`当前要分析的参考图:${params.referenceLabel}`,
|
||||||
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
|
"当前视觉输入只包含这一张详情参考图;产品图已经单独做过跨视角结构分析,请根据下面的产品结构画像选择最匹配当前机位的产品视角,不要臆测产品结构。",
|
||||||
|
productIdentityProfile
|
||||||
|
? `【产品结构画像】\n${formatReplacementProductIdentityProfile(productIdentityProfile)}`
|
||||||
|
: "产品结构画像暂不可用:仍需按当前参考图机位选择产品图,但不得把旧产品的腿型、支架或构件拓扑当成新产品结构。",
|
||||||
referenceVisualProfile
|
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}`
|
? `像素初筛: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
|
!referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene
|
||||||
? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。"
|
? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。"
|
||||||
: "",
|
: "",
|
||||||
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
|
"产品图和模特图会在后续生图阶段单独提供给生图模型。你必须依据产品结构画像输出 selectedProductImageIndexes,选择最多两张最能匹配当前参考图机位并共同说明完整结构的产品图;不得固定选择前两张,也不要要求强行使用全部产品图。",
|
||||||
params.modelImageUrl && !isProductOnlyDetailReplacement
|
params.modelImageUrl && !isProductOnlyDetailReplacement
|
||||||
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
|
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
|
||||||
: "本轮没有模特图,useModelImage 必须为 false。",
|
: "本轮没有模特图,useModelImage 必须为 false。",
|
||||||
@ -2278,12 +2628,18 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
isDetailReplacement
|
isDetailReplacement
|
||||||
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
|
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
|
||||||
: "",
|
: "",
|
||||||
|
isDetailReplacement
|
||||||
|
? "5a. 必须输出 subjectBoundingBox 和 textBoundingBoxes。所有坐标为 0到1000 归一化坐标 [x1,y1,x2,y2],不是 x/y/宽/高。subjectBoundingBox 紧密框住旧商品主体及自然阴影,不含文字,无商品时为 null;textBoundingBoxes 覆盖所有中英文文案、标题、卖点、参数、标签、数字、品牌字样、按钮文字及紧邻图标,无文字时为 []。"
|
||||||
|
: "",
|
||||||
isDetailReplacement
|
isDetailReplacement
|
||||||
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
|
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
|
||||||
: "",
|
: "",
|
||||||
isDetailReplacement
|
isDetailReplacement
|
||||||
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
|
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
|
||||||
: "",
|
: "",
|
||||||
|
isDetailReplacement
|
||||||
|
? "7a. 输出 selectedProductImageIndexes:根据当前详情图机位,从产品结构画像的图片编号中选择最多两张最相关的产品图;第一张应最接近当前机位,第二张补足腿、支架、横梁、侧框或其他容易变形的结构。输出 productSelectionReason 说明选择依据。"
|
||||||
|
: "",
|
||||||
isProductOnlyDetailReplacement
|
isProductOnlyDetailReplacement
|
||||||
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
|
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
|
||||||
: isDetailReplacement
|
: isDetailReplacement
|
||||||
@ -2309,7 +2665,7 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
: "",
|
: "",
|
||||||
"只返回严格 JSON,不要任何额外解释。",
|
"只返回严格 JSON,不要任何额外解释。",
|
||||||
`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)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
@ -2334,7 +2690,7 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
system:
|
system:
|
||||||
"你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。",
|
"你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。",
|
||||||
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
|
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
|
||||||
maxTokens: 1200,
|
maxTokens: 1600,
|
||||||
abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs),
|
abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@ -2368,6 +2724,9 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
: "configured_vision",
|
: "configured_vision",
|
||||||
scenePlan: {
|
scenePlan: {
|
||||||
...normalizedPlan.scenePlan,
|
...normalizedPlan.scenePlan,
|
||||||
|
productIdentitySummary: productIdentityProfile?.identitySummary || "",
|
||||||
|
productStructureSummary: productIdentityProfile?.structureSummary || "",
|
||||||
|
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
|
||||||
analysisModelName: params.planningRuntime.modelName,
|
analysisModelName: params.planningRuntime.modelName,
|
||||||
analysisSource:
|
analysisSource:
|
||||||
params.planningRuntime.kind === "local_moonshot"
|
params.planningRuntime.kind === "local_moonshot"
|
||||||
@ -2413,7 +2772,12 @@ export async function analyzeReplacementExecutionPlan(params: {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...normalizedFallbackPlan.decision,
|
...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 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 normalizedBasePrompt = String(params.basePrompt || "").trim();
|
||||||
const promptPackInstructionLines =
|
const promptPackInstructionLines =
|
||||||
params.modeType === "detail_replacement" &&
|
params.modeType === "detail_replacement" &&
|
||||||
@ -2641,6 +3011,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
...promptPackInstructionLines,
|
...promptPackInstructionLines,
|
||||||
...scenePlanLines,
|
...scenePlanLines,
|
||||||
...marketplaceBrandRemovalLines,
|
...marketplaceBrandRemovalLines,
|
||||||
|
...productStructureLockLines,
|
||||||
...productAngleLockLines,
|
...productAngleLockLines,
|
||||||
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
|
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
|
||||||
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
|
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
|
||||||
@ -2670,6 +3041,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
...promptPackInstructionLines,
|
...promptPackInstructionLines,
|
||||||
...scenePlanLines,
|
...scenePlanLines,
|
||||||
...marketplaceBrandRemovalLines,
|
...marketplaceBrandRemovalLines,
|
||||||
|
...productStructureLockLines,
|
||||||
...productAngleLockLines,
|
...productAngleLockLines,
|
||||||
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
|
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
|
||||||
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
|
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
|
||||||
@ -2785,6 +3157,7 @@ export function buildAgentReplacementPrompt(params: {
|
|||||||
"【图片顺序规则】",
|
"【图片顺序规则】",
|
||||||
...imageOrderLines,
|
...imageOrderLines,
|
||||||
...scenePlanLines,
|
...scenePlanLines,
|
||||||
|
...productStructureLockLines,
|
||||||
...productAngleLockLines,
|
...productAngleLockLines,
|
||||||
params.pairedProductIndex !== undefined
|
params.pairedProductIndex !== undefined
|
||||||
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRequestErrorMessage } from "@/lib/request-error";
|
import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.errors");
|
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();
|
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
|
||||||
const normalized = rawMessage.toLowerCase();
|
const normalized = rawMessage.toLowerCase();
|
||||||
|
|
||||||
|
if (isProviderBalanceError(error)) {
|
||||||
|
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalized.includes("token quota is not enough") ||
|
normalized.includes("token quota is not enough") ||
|
||||||
normalized.includes("pre_consume_token_quota_failed") ||
|
normalized.includes("pre_consume_token_quota_failed") ||
|
||||||
@ -47,6 +55,16 @@ function resolveChatApiFailure(error: unknown) {
|
|||||||
const detailMessage = getRequestErrorMessage(error);
|
const detailMessage = getRequestErrorMessage(error);
|
||||||
const normalized = String(detailMessage || "").toLowerCase();
|
const normalized = String(detailMessage || "").toLowerCase();
|
||||||
|
|
||||||
|
if (isProviderBalanceError(error)) {
|
||||||
|
return {
|
||||||
|
code: "server_error:chat",
|
||||||
|
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
|
||||||
|
status: 503,
|
||||||
|
category: "provider_balance",
|
||||||
|
detailMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalized.includes("payload too large") ||
|
normalized.includes("payload too large") ||
|
||||||
normalized.includes("request entity 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 { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
|
||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertMessagesWithImages } from "./image-media";
|
import { convertMessagesWithImages } from "./image-media";
|
||||||
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.imageFailover");
|
const chatApiLogger = createLogger("chat.imageFailover");
|
||||||
|
|
||||||
@ -57,7 +59,21 @@ export function resolveImageModelRuntimeId(
|
|||||||
export function normalizeFailoverErrorMessage(error: unknown): string {
|
export function normalizeFailoverErrorMessage(error: unknown): string {
|
||||||
if (!error) return "";
|
if (!error) return "";
|
||||||
if (typeof error === "string") return error;
|
if (typeof error === "string") return error;
|
||||||
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 {
|
try {
|
||||||
return JSON.stringify(error);
|
return JSON.stringify(error);
|
||||||
} catch {
|
} catch {
|
||||||
@ -68,6 +84,7 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
|
|||||||
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
|
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
|
||||||
const msg = errorMessage.toLowerCase();
|
const msg = errorMessage.toLowerCase();
|
||||||
return (
|
return (
|
||||||
|
isProviderBalanceError(errorMessage) ||
|
||||||
msg.includes("model not found") ||
|
msg.includes("model not found") ||
|
||||||
msg.includes("model_not_found") ||
|
msg.includes("model_not_found") ||
|
||||||
msg.includes("no available channel for model") ||
|
msg.includes("no available channel for model") ||
|
||||||
@ -76,6 +93,13 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
|
|||||||
msg.includes("api key is missing") ||
|
msg.includes("api key is missing") ||
|
||||||
msg.includes("invalid api key") ||
|
msg.includes("invalid api key") ||
|
||||||
msg.includes("unauthorized") ||
|
msg.includes("unauthorized") ||
|
||||||
|
/(?:^|\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("cannot convert argument to a bytestring") ||
|
||||||
msg.includes("model maintenance") ||
|
msg.includes("model maintenance") ||
|
||||||
msg.includes("maintenance") ||
|
msg.includes("maintenance") ||
|
||||||
@ -302,9 +326,9 @@ export function buildImageFallbackCandidates(
|
|||||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isImageFallbackModelCoolingDown(item.model.id)) {
|
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
||||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
|
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -464,9 +488,9 @@ export function getImageCrossProviderCandidates(
|
|||||||
}
|
}
|
||||||
if (m.model.id === options.currentModelId) return false;
|
if (m.model.id === options.currentModelId) return false;
|
||||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) 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);
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -179,6 +179,7 @@ import { ChatSDKError } from "@/lib/errors";
|
|||||||
import {
|
import {
|
||||||
getFriendlyRateLimitMessage,
|
getFriendlyRateLimitMessage,
|
||||||
getRequestErrorMessage,
|
getRequestErrorMessage,
|
||||||
|
getUserFacingImageGenerationErrorMessage,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
} from "@/lib/request-error";
|
} from "@/lib/request-error";
|
||||||
import {
|
import {
|
||||||
@ -197,6 +198,7 @@ import {
|
|||||||
recordAiModelHealthFailure,
|
recordAiModelHealthFailure,
|
||||||
recordAiModelHealthSuccess,
|
recordAiModelHealthSuccess,
|
||||||
shouldFailoverAiModelError,
|
shouldFailoverAiModelError,
|
||||||
|
shouldFailoverVip98kRequestRejection,
|
||||||
} from "@/lib/ai/runtime/model-health";
|
} from "@/lib/ai/runtime/model-health";
|
||||||
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
|
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
|
||||||
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
|
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
|
||||||
@ -363,6 +365,10 @@ import {
|
|||||||
resolveReplacementReferenceGeometry,
|
resolveReplacementReferenceGeometry,
|
||||||
resolveReplacementScenePlanningRuntime,
|
resolveReplacementScenePlanningRuntime,
|
||||||
} from "./_lib/detail-replacement";
|
} from "./_lib/detail-replacement";
|
||||||
|
import {
|
||||||
|
selectDetailReplacementGenerationInputs,
|
||||||
|
shouldIncludeDetailReplacementModelImage,
|
||||||
|
} from "@/lib/ai/detail-replacement-inputs";
|
||||||
import {
|
import {
|
||||||
normalizeAgentReportForDisplay,
|
normalizeAgentReportForDisplay,
|
||||||
sanitizeAgentOutputSettingsSection,
|
sanitizeAgentOutputSettingsSection,
|
||||||
@ -417,8 +423,6 @@ const chatApiLogger = createLogger("chat.route");
|
|||||||
|
|
||||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||||
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
|
||||||
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
|
||||||
|
|
||||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||||
const normalizedMarkdown = String(markdown || "").trim();
|
const normalizedMarkdown = String(markdown || "").trim();
|
||||||
@ -435,41 +439,13 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
|
|||||||
|
|
||||||
function getPublicImageGenerationErrorText(error: unknown) {
|
function getPublicImageGenerationErrorText(error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
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 "图片生成失败,请稍后重试";
|
||||||
}
|
}
|
||||||
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(
|
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||||
1,
|
1,
|
||||||
@ -5723,12 +5699,15 @@ export async function POST(request: Request) {
|
|||||||
sourceImageUrls: string[];
|
sourceImageUrls: string[];
|
||||||
streamLabel: string;
|
streamLabel: string;
|
||||||
promptForPersistence?: string | null;
|
promptForPersistence?: string | null;
|
||||||
|
isFallback?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const persistedImageSize =
|
const persistedImageSize =
|
||||||
String(params.imageSizeLabel || "").trim() || resolvedImageSize;
|
String(params.imageSizeLabel || "").trim() || resolvedImageSize;
|
||||||
const assistantText = prependImageModelBadge(
|
const assistantText = prependImageModelBadge(
|
||||||
[
|
[
|
||||||
`<!-- AGENT_REPLACEMENT_CARD_ID:${String(params.reference.id || "").trim()} -->`,
|
`<!-- 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}`,
|
`### ${params.reference.label}`,
|
||||||
String(params.bodyText || "").trim(),
|
String(params.bodyText || "").trim(),
|
||||||
buildAgentToolImagesMarkdown([
|
buildAgentToolImagesMarkdown([
|
||||||
@ -5764,26 +5743,28 @@ export async function POST(request: Request) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await saveGeneratedImages(
|
if (!params.isFallback) {
|
||||||
assistantText,
|
await saveGeneratedImages(
|
||||||
session.user.id,
|
assistantText,
|
||||||
id,
|
session.user.id,
|
||||||
assistantMessageId,
|
id,
|
||||||
params.promptForPersistence || baseReplacementPrompt,
|
assistantMessageId,
|
||||||
resolvedModelRef,
|
params.promptForPersistence || baseReplacementPrompt,
|
||||||
{
|
resolvedModelRef,
|
||||||
sourceAssetIds,
|
{
|
||||||
sourceImageUrls: params.sourceImageUrls,
|
sourceAssetIds,
|
||||||
relationType: "edited_from",
|
sourceImageUrls: params.sourceImageUrls,
|
||||||
sourceType: "detail_replacement",
|
relationType: "edited_from",
|
||||||
projectId: contextPayload?.projectId || null,
|
sourceType: "detail_replacement",
|
||||||
userPrompt: requestTextContent,
|
projectId: contextPayload?.projectId || null,
|
||||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
userPrompt: requestTextContent,
|
||||||
requestedImageCount: 1,
|
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||||
aspectRatio: resolvedAspectRatio,
|
requestedImageCount: 1,
|
||||||
imageSize: persistedImageSize,
|
aspectRatio: resolvedAspectRatio,
|
||||||
},
|
imageSize: persistedImageSize,
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (persistError) {
|
} catch (persistError) {
|
||||||
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
||||||
chatId: id,
|
chatId: id,
|
||||||
@ -5933,20 +5914,18 @@ export async function POST(request: Request) {
|
|||||||
const shouldIncludeModelImage =
|
const shouldIncludeModelImage =
|
||||||
detailReplacementStrategy !== "product_only" &&
|
detailReplacementStrategy !== "product_only" &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(replacementScenePlanShouldIncludeModelImage(
|
(shouldIncludeDetailReplacementModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
selectedProductContextImageUrls.length === 0);
|
selectedProductContextImageUrls.length === 0);
|
||||||
const sourceImageUrls = Array.from(
|
const generationInputs = selectDetailReplacementGenerationInputs({
|
||||||
new Set([
|
productImageUrls: selectedProductContextImageUrls,
|
||||||
...selectedProductContextImageUrls,
|
modelImageUrl: modelInputImageUrl,
|
||||||
...(shouldIncludeModelImage && modelInputImageUrl
|
includeModelImage: shouldIncludeModelImage,
|
||||||
? [modelInputImageUrl]
|
rerunReferenceImageUrls,
|
||||||
: []),
|
referenceImageUrl: reference.imageUrl,
|
||||||
...rerunReferenceImageUrls,
|
});
|
||||||
reference.imageUrl,
|
const sourceImageUrls = generationInputs.imageUrls;
|
||||||
]),
|
|
||||||
);
|
|
||||||
chatApiLogger.info("[Chat API] Direct detail replacement image generation started:", {
|
chatApiLogger.info("[Chat API] Direct detail replacement image generation started:", {
|
||||||
referenceIndex: referenceIndex + 1,
|
referenceIndex: referenceIndex + 1,
|
||||||
totalReferences: replacementReferences.length,
|
totalReferences: replacementReferences.length,
|
||||||
@ -5954,10 +5933,10 @@ export async function POST(request: Request) {
|
|||||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||||
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||||
selectedProductImages: selectedProductContextImageUrls.length,
|
selectedProductImages: generationInputs.productImageUrls.length,
|
||||||
sourceProductImages: productInputImageUrls.length,
|
sourceProductImages: productInputImageUrls.length,
|
||||||
includeModelImage: shouldIncludeModelImage,
|
includeModelImage: Boolean(generationInputs.modelImageUrl),
|
||||||
rerunReferenceImages: rerunReferenceImageUrls.length,
|
rerunReferenceImages: generationInputs.rerunImageUrls.length,
|
||||||
sourceImageCount: sourceImageUrls.length,
|
sourceImageCount: sourceImageUrls.length,
|
||||||
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
|
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
|
||||||
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
|
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
|
||||||
@ -5972,10 +5951,10 @@ export async function POST(request: Request) {
|
|||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
referenceIndex,
|
referenceIndex,
|
||||||
totalReferences: replacementReferences.length,
|
totalReferences: replacementReferences.length,
|
||||||
hasModelImage: shouldIncludeModelImage,
|
hasModelImage: Boolean(generationInputs.modelImageUrl),
|
||||||
productImageCount: selectedProductContextImageUrls.length,
|
productImageCount: generationInputs.productImageUrls.length,
|
||||||
originalProductImageCount: productInputImageUrls.length,
|
originalProductImageCount: productInputImageUrls.length,
|
||||||
rerunReferenceCount: rerunReferenceImageUrls.length,
|
rerunReferenceCount: generationInputs.rerunImageUrls.length,
|
||||||
templateReferenceCount: 0,
|
templateReferenceCount: 0,
|
||||||
scenePlan: replacementExecutionPlan.scenePlan,
|
scenePlan: replacementExecutionPlan.scenePlan,
|
||||||
referenceGeometry,
|
referenceGeometry,
|
||||||
@ -6007,6 +5986,10 @@ export async function POST(request: Request) {
|
|||||||
imageSize: resolvedImageSize,
|
imageSize: resolvedImageSize,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
referenceImages: sourceImageUrls,
|
referenceImages: sourceImageUrls,
|
||||||
|
promptExtend: false,
|
||||||
|
providerImageParams: {
|
||||||
|
inputImageRoleByUrl: generationInputs.inputImageRoleByUrl,
|
||||||
|
},
|
||||||
debugLabel: "agent-detail-replacement",
|
debugLabel: "agent-detail-replacement",
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
@ -6054,6 +6037,9 @@ export async function POST(request: Request) {
|
|||||||
referenceImageUrl: reference.imageUrl,
|
referenceImageUrl: reference.imageUrl,
|
||||||
referenceGeometry,
|
referenceGeometry,
|
||||||
scenePlan: replacementExecutionPlan.scenePlan,
|
scenePlan: replacementExecutionPlan.scenePlan,
|
||||||
|
preserveReferenceOutsideSubject:
|
||||||
|
detailReplacementStrategy === "product_only",
|
||||||
|
preserveReferenceText: true,
|
||||||
uploadPrefix: "agent-detail-replacement",
|
uploadPrefix: "agent-detail-replacement",
|
||||||
});
|
});
|
||||||
finalImage = {
|
finalImage = {
|
||||||
@ -6136,8 +6122,8 @@ export async function POST(request: Request) {
|
|||||||
modelDisplayName: null,
|
modelDisplayName: null,
|
||||||
sourceImageUrls: referenceSourceImageUrls,
|
sourceImageUrls: referenceSourceImageUrls,
|
||||||
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||||
|
isFallback: true,
|
||||||
});
|
});
|
||||||
successCount += 1;
|
|
||||||
}
|
}
|
||||||
} catch (replacementPlanError) {
|
} catch (replacementPlanError) {
|
||||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
||||||
@ -6160,8 +6146,8 @@ export async function POST(request: Request) {
|
|||||||
modelDisplayName: null,
|
modelDisplayName: null,
|
||||||
sourceImageUrls: fallbackSourceImageUrls,
|
sourceImageUrls: fallbackSourceImageUrls,
|
||||||
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||||
|
isFallback: true,
|
||||||
});
|
});
|
||||||
successCount += 1;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@ -6489,9 +6475,20 @@ export async function POST(request: Request) {
|
|||||||
...(selectedAgentPreset.defaultParams || {}),
|
...(selectedAgentPreset.defaultParams || {}),
|
||||||
...(replacementSlot?.defaultParams || {}),
|
...(replacementSlot?.defaultParams || {}),
|
||||||
} as Record<string, any>;
|
} as Record<string, any>;
|
||||||
const resolvedModelRef =
|
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
|
||||||
String(selectedChatModel || "").trim() &&
|
(error) => {
|
||||||
String(selectedChatModel || "").trim() !== "auto"
|
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()
|
? String(selectedChatModel || "").trim()
|
||||||
: presetModelId;
|
: presetModelId;
|
||||||
const configuredReplacementAspectRatio =
|
const configuredReplacementAspectRatio =
|
||||||
@ -6513,12 +6510,6 @@ export async function POST(request: Request) {
|
|||||||
configuredAspectRatio: configuredReplacementAspectRatio,
|
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 =
|
const replacementScenePlanningRuntime =
|
||||||
await resolveReplacementScenePlanningRuntime({
|
await resolveReplacementScenePlanningRuntime({
|
||||||
models: replacementVisionModels,
|
models: replacementVisionModels,
|
||||||
@ -6816,24 +6807,36 @@ export async function POST(request: Request) {
|
|||||||
!(isDetailReplacementPreset &&
|
!(isDetailReplacementPreset &&
|
||||||
detailReplacementStrategy === "product_only") &&
|
detailReplacementStrategy === "product_only") &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(replacementScenePlanShouldIncludeModelImage(
|
(shouldIncludeDetailReplacementModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
(isDetailReplacementPreset &&
|
(isDetailReplacementPreset &&
|
||||||
selectedProductContextImageUrls.length === 0));
|
selectedProductContextImageUrls.length === 0));
|
||||||
const sourceImageUrls = Array.from(
|
const detailGenerationInputs = isDetailReplacementPreset
|
||||||
new Set(
|
? selectDetailReplacementGenerationInputs({
|
||||||
[
|
productImageUrls: selectedProductContextImageUrls,
|
||||||
...selectedProductContextImageUrls,
|
modelImageUrl: modelInputImageUrl,
|
||||||
...(shouldIncludeModelImage && modelInputImageUrl
|
includeModelImage: shouldIncludeModelImage,
|
||||||
? [modelInputImageUrl]
|
templateReferenceImageUrls: replacementTemplateReferenceImageUrls,
|
||||||
: []),
|
rerunReferenceImageUrls,
|
||||||
...replacementTemplateReferenceImageUrls,
|
referenceImageUrl: reference.imageUrl,
|
||||||
...rerunReferenceImageUrls,
|
})
|
||||||
reference.imageUrl,
|
: null;
|
||||||
].filter(Boolean),
|
const sourceImageUrls = detailGenerationInputs
|
||||||
),
|
? detailGenerationInputs.imageUrls
|
||||||
);
|
: Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
...selectedProductContextImageUrls,
|
||||||
|
...(shouldIncludeModelImage && modelInputImageUrl
|
||||||
|
? [modelInputImageUrl]
|
||||||
|
: []),
|
||||||
|
...replacementTemplateReferenceImageUrls,
|
||||||
|
...rerunReferenceImageUrls,
|
||||||
|
reference.imageUrl,
|
||||||
|
].filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
const replacementPrompt = buildAgentReplacementPrompt({
|
const replacementPrompt = buildAgentReplacementPrompt({
|
||||||
basePrompt:
|
basePrompt:
|
||||||
baseReplacementPrompt ||
|
baseReplacementPrompt ||
|
||||||
@ -6850,11 +6853,19 @@ export async function POST(request: Request) {
|
|||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
referenceIndex,
|
referenceIndex,
|
||||||
totalReferences: replacementReferences.length,
|
totalReferences: replacementReferences.length,
|
||||||
hasModelImage: shouldIncludeModelImage,
|
hasModelImage: detailGenerationInputs
|
||||||
productImageCount: selectedProductContextImageUrls.length,
|
? Boolean(detailGenerationInputs.modelImageUrl)
|
||||||
|
: shouldIncludeModelImage,
|
||||||
|
productImageCount: detailGenerationInputs
|
||||||
|
? detailGenerationInputs.productImageUrls.length
|
||||||
|
: selectedProductContextImageUrls.length,
|
||||||
originalProductImageCount: productInputImageUrls.length,
|
originalProductImageCount: productInputImageUrls.length,
|
||||||
rerunReferenceCount: rerunReferenceImageUrls.length,
|
rerunReferenceCount: detailGenerationInputs
|
||||||
templateReferenceCount: replacementTemplateReferenceImageUrls.length,
|
? detailGenerationInputs.rerunImageUrls.length
|
||||||
|
: rerunReferenceImageUrls.length,
|
||||||
|
templateReferenceCount: detailGenerationInputs
|
||||||
|
? detailGenerationInputs.templateImageUrls.length
|
||||||
|
: replacementTemplateReferenceImageUrls.length,
|
||||||
pairedProductIndex,
|
pairedProductIndex,
|
||||||
scenePlan: replacementExecutionPlan.scenePlan,
|
scenePlan: replacementExecutionPlan.scenePlan,
|
||||||
referenceGeometry,
|
referenceGeometry,
|
||||||
@ -6875,11 +6886,20 @@ export async function POST(request: Request) {
|
|||||||
sourceImageCount: sourceImageUrls.length,
|
sourceImageCount: sourceImageUrls.length,
|
||||||
generationAspectRatio,
|
generationAspectRatio,
|
||||||
imageOrder: {
|
imageOrder: {
|
||||||
productImages: selectedProductContextImageUrls.length,
|
productImages:
|
||||||
|
detailGenerationInputs !== null
|
||||||
|
? detailGenerationInputs.productImageUrls.length
|
||||||
|
: selectedProductContextImageUrls.length,
|
||||||
originalProductImages: productInputImageUrls.length,
|
originalProductImages: productInputImageUrls.length,
|
||||||
hasModelImage: shouldIncludeModelImage,
|
hasModelImage: detailGenerationInputs
|
||||||
templateReferenceImages: replacementTemplateReferenceImageUrls.length,
|
? Boolean(detailGenerationInputs.modelImageUrl)
|
||||||
rerunReferenceImages: rerunReferenceImageUrls.length,
|
: shouldIncludeModelImage,
|
||||||
|
templateReferenceImages: detailGenerationInputs
|
||||||
|
? detailGenerationInputs.templateImageUrls.length
|
||||||
|
: replacementTemplateReferenceImageUrls.length,
|
||||||
|
rerunReferenceImages: detailGenerationInputs
|
||||||
|
? detailGenerationInputs.rerunImageUrls.length
|
||||||
|
: rerunReferenceImageUrls.length,
|
||||||
finalReferenceImageIndex: sourceImageUrls.length,
|
finalReferenceImageIndex: sourceImageUrls.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -6897,6 +6917,17 @@ export async function POST(request: Request) {
|
|||||||
imageSize: resolvedImageSize,
|
imageSize: resolvedImageSize,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
referenceImages: sourceImageUrls,
|
referenceImages: sourceImageUrls,
|
||||||
|
promptExtend: isDetailReplacementPreset
|
||||||
|
? false
|
||||||
|
: typeof mergedImageParams.promptExtend === "boolean"
|
||||||
|
? mergedImageParams.promptExtend
|
||||||
|
: undefined,
|
||||||
|
providerImageParams: detailGenerationInputs
|
||||||
|
? {
|
||||||
|
inputImageRoleByUrl:
|
||||||
|
detailGenerationInputs.inputImageRoleByUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
debugLabel: `agent-${replacementSourceType}`,
|
debugLabel: `agent-${replacementSourceType}`,
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
@ -6943,6 +6974,10 @@ export async function POST(request: Request) {
|
|||||||
referenceImageUrl: reference.imageUrl,
|
referenceImageUrl: reference.imageUrl,
|
||||||
referenceGeometry,
|
referenceGeometry,
|
||||||
scenePlan: replacementExecutionPlan.scenePlan,
|
scenePlan: replacementExecutionPlan.scenePlan,
|
||||||
|
preserveReferenceOutsideSubject:
|
||||||
|
isDetailReplacementPreset &&
|
||||||
|
detailReplacementStrategy === "product_only",
|
||||||
|
preserveReferenceText: isDetailReplacementPreset,
|
||||||
uploadPrefix: isDetailReplacementPreset
|
uploadPrefix: isDetailReplacementPreset
|
||||||
? "agent-detail-replacement"
|
? "agent-detail-replacement"
|
||||||
: "agent-sku-replacement",
|
: "agent-sku-replacement",
|
||||||
@ -9635,6 +9670,11 @@ export async function POST(request: Request) {
|
|||||||
alibabaModelConfig?.fallbackModelId2,
|
alibabaModelConfig?.fallbackModelId2,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
const allModels = await getEnabledAiModelsWithProvider();
|
const allModels = await getEnabledAiModelsWithProvider();
|
||||||
|
const currentModelEntry = resolveModelEntryByRuntimeModelId(
|
||||||
|
allModels,
|
||||||
|
actualModelId,
|
||||||
|
finalModelId,
|
||||||
|
);
|
||||||
const fallbackCandidates = buildImageFallbackCandidates(allModels, {
|
const fallbackCandidates = buildImageFallbackCandidates(allModels, {
|
||||||
currentProviderId: alibabaModelConfig?.providerId,
|
currentProviderId: alibabaModelConfig?.providerId,
|
||||||
currentModelId: alibabaModelConfig?.id,
|
currentModelId: alibabaModelConfig?.id,
|
||||||
@ -9880,6 +9920,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
let imageMarkdown = "";
|
let imageMarkdown = "";
|
||||||
let usedModelId = actualModelId;
|
let usedModelId = actualModelId;
|
||||||
|
const generationStartedAt = Date.now();
|
||||||
|
|
||||||
// 🆕 图片生成真正开始时发出标记(用于前端显示占位图)
|
// 🆕 图片生成真正开始时发出标记(用于前端显示占位图)
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
@ -9906,14 +9947,43 @@ export async function POST(request: Request) {
|
|||||||
} catch (primaryError) {
|
} catch (primaryError) {
|
||||||
chatApiLogger.error("[Chat API] Alibaba primary model failed:", 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.");
|
chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error.");
|
||||||
throw primaryError;
|
throw primaryError;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fallbackSuccess = false;
|
let fallbackSuccess = false;
|
||||||
|
const triedFallbackModelIds = new Set<string>();
|
||||||
for (const candidate of fallbackCandidates) {
|
for (const candidate of fallbackCandidates) {
|
||||||
try {
|
try {
|
||||||
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||||
candidate,
|
candidate,
|
||||||
@ -9938,6 +10008,22 @@ export async function POST(request: Request) {
|
|||||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, 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);
|
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9966,10 +10052,11 @@ export async function POST(request: Request) {
|
|||||||
currentModelKey: actualModelId,
|
currentModelKey: actualModelId,
|
||||||
currentProviderId: alibabaModelConfig?.providerId,
|
currentProviderId: alibabaModelConfig?.providerId,
|
||||||
providerPriority,
|
providerPriority,
|
||||||
});
|
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
|
||||||
|
|
||||||
for (const candidate of crossProviderCandidates) {
|
for (const candidate of crossProviderCandidates) {
|
||||||
try {
|
try {
|
||||||
|
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||||
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||||
if (
|
if (
|
||||||
candidate.provider.type === "fal-image" &&
|
candidate.provider.type === "fal-image" &&
|
||||||
@ -10053,6 +10140,22 @@ export async function POST(request: Request) {
|
|||||||
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||||
setImageFallbackModelCooldown(candidate.model.id, 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);
|
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10066,6 +10169,19 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length);
|
chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length);
|
||||||
chatApiLogger.info("[Chat API] Alibaba used model:", usedModelId);
|
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 =
|
const usedModelDisplayName =
|
||||||
resolveModelDisplayNameByModelId(allModels, usedModelId) ||
|
resolveModelDisplayNameByModelId(allModels, usedModelId) ||
|
||||||
alibabaModelConfig?.displayName ||
|
alibabaModelConfig?.displayName ||
|
||||||
|
|||||||
@ -13,6 +13,10 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
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 { createLogger } from "@/lib/observability/logger";
|
||||||
import {
|
import {
|
||||||
buildS3PublicUrl,
|
buildS3PublicUrl,
|
||||||
@ -160,7 +164,10 @@ function isAllowedDirectUpload(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPurposeMaxBytes(purpose?: string) {
|
function getPurposeMaxBytes(purpose?: string, contentType?: string) {
|
||||||
|
if (String(contentType || "").toLowerCase().startsWith("image/")) {
|
||||||
|
return IMAGE_UPLOAD_MAX_BYTES;
|
||||||
|
}
|
||||||
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
|
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
|
||||||
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
|
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
|
||||||
}
|
}
|
||||||
@ -281,9 +288,14 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
const maxBytes = getPurposeMaxBytes(payload.purpose, contentType);
|
||||||
|
if (payload.size > maxBytes) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` },
|
{
|
||||||
|
error: contentType.startsWith("image/")
|
||||||
|
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
|
||||||
|
: `文件不能超过 ${maxBytes / 1024 / 1024}MB`,
|
||||||
|
},
|
||||||
{ status: 413 }
|
{ status: 413 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -390,8 +402,15 @@ export async function POST(request: Request) {
|
|||||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||||
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
|
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
|
||||||
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: contentType.startsWith("image/")
|
||||||
|
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
|
||||||
|
: "文件超过允许的上传大小",
|
||||||
|
},
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const expectedPartSize = computePartSize(payload.size);
|
const expectedPartSize = computePartSize(payload.size);
|
||||||
if (payload.partSize !== expectedPartSize) {
|
if (payload.partSize !== expectedPartSize) {
|
||||||
@ -505,8 +524,15 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
|
||||||
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: contentType.startsWith("image/")
|
||||||
|
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
|
||||||
|
: "文件超过允许的上传大小",
|
||||||
|
},
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.uploadId) {
|
if (payload.uploadId) {
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||||
|
isImageUploadTooLarge,
|
||||||
|
} from "@/lib/image-upload-limits";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
||||||
@ -260,6 +264,12 @@ export async function POST(request: Request) {
|
|||||||
const clientUploadId =
|
const clientUploadId =
|
||||||
String(formData.get("clientUploadId") || "").trim() || null;
|
String(formData.get("clientUploadId") || "").trim() || null;
|
||||||
const isSvgUpload = contentType === "image/svg+xml";
|
const isSvgUpload = contentType === "image/svg+xml";
|
||||||
|
if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
isSvgUpload &&
|
isSvgUpload &&
|
||||||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
||||||
|
|||||||
@ -102,8 +102,8 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "local-e2e-video-model",
|
id: "local-e2e-video-model",
|
||||||
modelId: "kling-3-local",
|
modelId: "veo-3.1-fast",
|
||||||
displayName: "可灵 3.0",
|
displayName: "Veo 3.1 Fast(本地布局测试)",
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
description: "本地视频画布测试视频模型",
|
description: "本地视频画布测试视频模型",
|
||||||
supportsVision: true,
|
supportsVision: true,
|
||||||
@ -111,18 +111,18 @@ export async function GET(request: Request) {
|
|||||||
isMultimodal: true,
|
isMultimodal: true,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
providerName: "Local E2E",
|
providerName: "Local E2E",
|
||||||
providerType: "local",
|
providerType: "vip98k",
|
||||||
isSubModel: false,
|
isSubModel: false,
|
||||||
parentModelId: null,
|
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: {
|
videoToolCapability: {
|
||||||
supported: true,
|
supported: true,
|
||||||
durations: [5, 10],
|
durations: [8],
|
||||||
resolutions: ["720p", "1080p"],
|
resolutions: ["720p", "1080p"],
|
||||||
},
|
},
|
||||||
imageGenConfig: {
|
imageGenConfig: {
|
||||||
creditCostPerVideo: 20,
|
creditCostPerVideo: 20,
|
||||||
videoDurationOptions: [5, 10],
|
videoDurationOptions: [8],
|
||||||
},
|
},
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
|
|||||||
@ -3,10 +3,5 @@ export default function FlowBoardLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <>{children}</>;
|
||||||
<>
|
|
||||||
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { ChatShell } from "@/components/chat-shell";
|
import { ChatShell } from "@/components/chat-shell";
|
||||||
|
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
|
||||||
import { DataStreamProvider } from "@/components/data-stream-provider";
|
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||||
import { auth } from "../(auth)/auth";
|
import { auth } from "../(auth)/auth";
|
||||||
import {
|
import {
|
||||||
@ -8,10 +9,14 @@ import {
|
|||||||
getTutorialVideos,
|
getTutorialVideos,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DataStreamProvider>
|
<DataStreamProvider>
|
||||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
<Suspense
|
||||||
|
fallback={<CanvasLogoLoadingView label="正在加载页面..." />}
|
||||||
|
>
|
||||||
<SidebarWrapper>{children}</SidebarWrapper>
|
<SidebarWrapper>{children}</SidebarWrapper>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</DataStreamProvider>
|
</DataStreamProvider>
|
||||||
|
|||||||
@ -1,79 +1,75 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { redirect } from "next/navigation";
|
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 { 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 = {
|
type Props = { searchParams?: Promise<Record<string, string | string[] | undefined>> };
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSearchParam(
|
function first(value: string | string[] | undefined) {
|
||||||
params: Record<string, string | string[] | undefined>,
|
|
||||||
key: string
|
|
||||||
) {
|
|
||||||
const value = params[key];
|
|
||||||
return Array.isArray(value) ? value[0] : value;
|
return Array.isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVideoCanvasUrl(
|
function canvasHref(id: string, incoming: Record<string, string | string[] | undefined>) {
|
||||||
canvasId: string,
|
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
|
||||||
params: Record<string, string | string[] | undefined>
|
for (const [key, value] of Object.entries(incoming)) {
|
||||||
) {
|
if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue;
|
||||||
const query = new URLSearchParams();
|
for (const item of Array.isArray(value) ? value : [value]) params.append(key, item);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
return `/canvas/${id}?${params.toString()}`;
|
||||||
return `/canvas/${canvasId}?${query.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestVideoCanvasId(userId: string) {
|
function projectPreview(thumbnail: string | null, canvasData: unknown) {
|
||||||
const [latestProject] = await db
|
const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData
|
||||||
.select({ id: canvasProject.id })
|
? (canvasData as { nodes?: unknown }).nodes : null;
|
||||||
.from(canvasProject)
|
const candidates = [thumbnail];
|
||||||
.where(
|
if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) {
|
||||||
and(
|
if (!node || typeof node !== "object") continue;
|
||||||
eq(canvasProject.userId, userId),
|
const item = node as { inputType?: string; draftValues?: Record<string, unknown> };
|
||||||
eq(canvasProject.workspaceType, "video")
|
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] : "") : "") || ""),
|
||||||
.orderBy(desc(canvasProject.updatedAt))
|
);
|
||||||
.limit(1);
|
}
|
||||||
|
return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null;
|
||||||
return latestProject?.id || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function VideoCanvasRedirectPage({
|
export default async function VideoCanvasHomePage({ searchParams }: Props) {
|
||||||
searchParams,
|
|
||||||
}: VideoCanvasRedirectPageProps) {
|
|
||||||
const params = (await searchParams) || {};
|
|
||||||
const shouldCreateNew = getSearchParam(params, "new") === "1";
|
|
||||||
|
|
||||||
if (shouldCreateNew) {
|
|
||||||
redirect(buildVideoCanvasUrl(randomUUID(), params));
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const latestCanvasId = session?.user?.id
|
if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas");
|
||||||
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
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 || ""} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,6 +94,8 @@ export async function POST(request: Request) {
|
|||||||
providerId,
|
providerId,
|
||||||
modelId: preset.modelId,
|
modelId: preset.modelId,
|
||||||
...values,
|
...values,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isSubModel: false,
|
isSubModel: false,
|
||||||
parentModelId: null,
|
parentModelId: null,
|
||||||
|
|||||||
@ -76,8 +76,6 @@ export async function POST(request: Request) {
|
|||||||
description: preset.description,
|
description: preset.description,
|
||||||
supportsVision: preset.supportsVision,
|
supportsVision: preset.supportsVision,
|
||||||
supportsImageGen: preset.supportsImageGen,
|
supportsImageGen: preset.supportsImageGen,
|
||||||
isEnabled: true,
|
|
||||||
isHidden: false,
|
|
||||||
sortOrder: preset.sortOrder,
|
sortOrder: preset.sortOrder,
|
||||||
capabilities: preset.capabilities || null,
|
capabilities: preset.capabilities || null,
|
||||||
defaultParams: preset.defaultParams || null,
|
defaultParams: preset.defaultParams || null,
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,385 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import {
|
|
||||||
createMediaAssetRelation,
|
|
||||||
getMediaAssetsByUserAndUrls,
|
|
||||||
getPointsSystemConfig,
|
|
||||||
getSystemSetting,
|
|
||||||
upsertMediaAsset,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
|
||||||
import { db } from "@/lib/db";
|
export const runtime = "nodejs";
|
||||||
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秒)
|
|
||||||
export const maxDuration = 180;
|
export const maxDuration = 180;
|
||||||
|
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);
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
"worldlabs.ai",
|
"worldlabs.ai",
|
||||||
"s3.1080p.hk",
|
"s3.1080p.hk",
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
|
"scqny.jiansun.vip",
|
||||||
"blob.vercel-storage.com",
|
"blob.vercel-storage.com",
|
||||||
"aliyuncs.com",
|
"aliyuncs.com",
|
||||||
"amazonaws.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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { auth } from '@/app/(auth)/auth';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { canvasProject } from '@/lib/db/schema';
|
import { canvasProject } from '@/lib/db/schema';
|
||||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||||
|
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
buildStaleCanvasSaveConflictPayload,
|
buildStaleCanvasSaveConflictPayload,
|
||||||
@ -102,7 +103,7 @@ export async function GET(
|
|||||||
// 更新画布(支持 upsert:如果不存在则创建)
|
// 更新画布(支持 upsert:如果不存在则创建)
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@ -116,29 +117,34 @@ export async function PUT(
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, thumbnail, canvasData } = body;
|
const { title, description, canvasData } = body;
|
||||||
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
|
||||||
|
const baseCanvasRevision =
|
||||||
|
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||||
const workspaceType =
|
const workspaceType =
|
||||||
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
|
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
|
||||||
? resolveWorkspaceType(body.workspaceType)
|
? resolveWorkspaceType(body.workspaceType)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 先检查画布是否存在
|
return await db.transaction(async (tx) => {
|
||||||
const [existingProject] = await db
|
const [existingProject] = await tx
|
||||||
.select()
|
.select()
|
||||||
.from(canvasProject)
|
.from(canvasProject)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(canvasProject.id, id),
|
eq(canvasProject.id, id),
|
||||||
eq(canvasProject.userId, session.user.id)
|
eq(canvasProject.userId, session.user.id),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
);
|
.for("update");
|
||||||
|
|
||||||
if (existingProject) {
|
|
||||||
if (
|
if (
|
||||||
|
existingProject &&
|
||||||
canvasData !== undefined &&
|
canvasData !== undefined &&
|
||||||
baseCanvasRevision &&
|
baseCanvasRevision &&
|
||||||
isStaleCanvasSaveBaseRevision({
|
isStaleCanvasSaveBaseRevision({
|
||||||
@ -149,101 +155,66 @@ export async function PUT(
|
|||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
|
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 =
|
const syncedCanvasData =
|
||||||
canvasData !== undefined
|
canvasData !== undefined
|
||||||
? await syncCanvasNodesForProject({
|
? await syncCanvasNodesForProject(
|
||||||
projectId: id,
|
{ projectId: id, userId: session.user.id, canvasData },
|
||||||
userId: session.user.id,
|
tx,
|
||||||
canvasData,
|
)
|
||||||
})
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
|
||||||
// 更新现有画布
|
|
||||||
const updateData: any = { updatedAt: new Date() };
|
|
||||||
if (title !== undefined) updateData.title = title;
|
if (title !== undefined) updateData.title = title;
|
||||||
if (description !== undefined) updateData.description = description;
|
if (description !== undefined) updateData.description = description;
|
||||||
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
||||||
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
|
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
|
||||||
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
|
if (syncedCanvasData !== undefined)
|
||||||
|
updateData.canvasData = syncedCanvasData;
|
||||||
const [updatedProject] = await db
|
const [saved] = await tx
|
||||||
.update(canvasProject)
|
.update(canvasProject)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(canvasProject.id, id),
|
eq(canvasProject.id, id),
|
||||||
eq(canvasProject.userId, session.user.id)
|
eq(canvasProject.userId, session.user.id),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
return NextResponse.json({ success: true, data: saved });
|
||||||
return NextResponse.json({ success: true, data: updatedProject });
|
});
|
||||||
} else {
|
|
||||||
const syncedCanvasData =
|
|
||||||
canvasData !== undefined
|
|
||||||
? await syncCanvasNodesForProject({
|
|
||||||
projectId: id,
|
|
||||||
userId: session.user.id,
|
|
||||||
canvasData,
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const nextWorkspaceType = workspaceType || "canvas";
|
|
||||||
const nextCanvasData = syncedCanvasData || {
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
|
||||||
};
|
|
||||||
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
|
|
||||||
const insertData: any = {
|
|
||||||
id, // 使用指定的 ID
|
|
||||||
userId: session.user.id,
|
|
||||||
title: nextTitle,
|
|
||||||
workspaceType: nextWorkspaceType,
|
|
||||||
canvasData: nextCanvasData,
|
|
||||||
};
|
|
||||||
const conflictUpdateData: any = {
|
|
||||||
title: nextTitle,
|
|
||||||
workspaceType: nextWorkspaceType,
|
|
||||||
canvasData: nextCanvasData,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
if (description !== undefined) {
|
|
||||||
insertData.description = description;
|
|
||||||
conflictUpdateData.description = description;
|
|
||||||
}
|
|
||||||
if (thumbnail !== undefined) {
|
|
||||||
insertData.thumbnail = thumbnail;
|
|
||||||
conflictUpdateData.thumbnail = thumbnail;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
|
|
||||||
const [newProject] = await db
|
|
||||||
.insert(canvasProject)
|
|
||||||
.values(insertData)
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: canvasProject.id,
|
|
||||||
set: conflictUpdateData,
|
|
||||||
where: eq(canvasProject.userId, session.user.id),
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!newProject) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '画布ID已被占用' },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: newProject });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新/创建画布失败:', error);
|
console.error("更新/创建画布失败:", error);
|
||||||
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
|
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 });
|
||||||
|
}
|
||||||
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");
|
||||||
|
}
|
||||||
@ -25,7 +25,7 @@ export async function GET(request: Request) {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const limit = Math.max(
|
const limit = Math.max(
|
||||||
1,
|
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({
|
const chats = await getClawdChatsByUserId({
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
|
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
|
||||||
type OpenClawChatMediaAttachment,
|
type OpenClawChatMediaAttachment,
|
||||||
} from "@/lib/openclaw-chat/media-attachments";
|
} from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
import { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
buildOpenClawDisplayMessage,
|
buildOpenClawDisplayMessage,
|
||||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||||
@ -298,7 +299,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
|||||||
params.spreadsheetMarkdownTruncated || undefined,
|
params.spreadsheetMarkdownTruncated || undefined,
|
||||||
...params.metadata,
|
...params.metadata,
|
||||||
},
|
},
|
||||||
stream_progress: false,
|
stream_progress: !params.sensitiveInput,
|
||||||
sensitive_input: params.sensitiveInput,
|
sensitive_input: params.sensitiveInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -337,6 +338,9 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
|||||||
assistantMessageId: params.assistantMessageId,
|
assistantMessageId: params.assistantMessageId,
|
||||||
answer,
|
answer,
|
||||||
requestId: getRequestId(payload),
|
requestId: getRequestId(payload),
|
||||||
|
artifacts: extractOpenClawArtifacts(payload, {
|
||||||
|
baseUrl: params.config.baseUrl,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
clearClawdRequestCancellation(params.cancelKey);
|
clearClawdRequestCancellation(params.cancelKey);
|
||||||
return;
|
return;
|
||||||
@ -370,6 +374,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
|||||||
...target,
|
...target,
|
||||||
answer: outcome.answer,
|
answer: outcome.answer,
|
||||||
requestId: outcome.requestId,
|
requestId: outcome.requestId,
|
||||||
|
artifacts: outcome.artifacts,
|
||||||
});
|
});
|
||||||
} else if (outcome.status === "canceled") {
|
} else if (outcome.status === "canceled") {
|
||||||
await markClawdTaskCanceled({
|
await markClawdTaskCanceled({
|
||||||
@ -576,7 +581,7 @@ export async function POST(request: Request) {
|
|||||||
: "image/jpeg"),
|
: "image/jpeg"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await persistPendingClawdTask({
|
const persistedTask = await persistPendingClawdTask({
|
||||||
chatId: parsed.id,
|
chatId: parsed.id,
|
||||||
userMessageId,
|
userMessageId,
|
||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
@ -587,6 +592,22 @@ export async function POST(request: Request) {
|
|||||||
skillTagName,
|
skillTagName,
|
||||||
processingHint,
|
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(
|
const userProfile = await getUserById({ id: session.user.id }).catch(
|
||||||
() => null
|
() => null
|
||||||
|
|||||||
@ -75,6 +75,7 @@ async function reconcileDetachedOpenClawTask(params: {
|
|||||||
...target,
|
...target,
|
||||||
answer: outcome.answer,
|
answer: outcome.answer,
|
||||||
requestId: outcome.requestId,
|
requestId: outcome.requestId,
|
||||||
|
artifacts: outcome.artifacts,
|
||||||
});
|
});
|
||||||
} else if (outcome.status === "failed") {
|
} else if (outcome.status === "failed") {
|
||||||
await markClawdTaskFailed({ ...target, error: outcome.error });
|
await markClawdTaskFailed({ ...target, error: outcome.error });
|
||||||
|
|||||||
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,4 +1,6 @@
|
|||||||
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 { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import {
|
||||||
buildAttachmentContentDisposition,
|
buildAttachmentContentDisposition,
|
||||||
@ -60,6 +62,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
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") || "";
|
const cookie = request.headers.get("cookie") || "";
|
||||||
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
|
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { writeDownloadZip, readDownloadZip, removeDownloadZip } from "@/lib/canvas/download-zip-stream";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
@ -27,7 +28,7 @@ type ZipTask = {
|
|||||||
status: ZipTaskStatus;
|
status: ZipTaskStatus;
|
||||||
stage: string;
|
stage: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
zipBuffer?: Buffer;
|
zipFile?: { path: string; bytes: number };
|
||||||
contentDisposition?: string;
|
contentDisposition?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@ -116,6 +117,7 @@ function cleanupExpiredTasks() {
|
|||||||
for (const [taskId, task] of store.entries()) {
|
for (const [taskId, task] of store.entries()) {
|
||||||
if (task.expiresAt <= now) {
|
if (task.expiresAt <= now) {
|
||||||
store.delete(taskId);
|
store.delete(taskId);
|
||||||
|
if (task.zipFile) void removeDownloadZip(task.zipFile.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -242,17 +244,10 @@ async function processZipTask(params: {
|
|||||||
|
|
||||||
task.stage = "正在生成 ZIP 文件";
|
task.stage = "正在生成 ZIP 文件";
|
||||||
task.updatedAt = Date.now();
|
task.updatedAt = Date.now();
|
||||||
task.zipBuffer = await zip.generateAsync(
|
task.zipFile = await writeDownloadZip(zip, task.id, percent => {
|
||||||
{
|
task.compressPercent = Math.max(0, Math.min(100, percent || 0));
|
||||||
type: "nodebuffer",
|
task.updatedAt = Date.now();
|
||||||
compression: "DEFLATE",
|
});
|
||||||
compressionOptions: { level: 6 },
|
|
||||||
},
|
|
||||||
(metadata) => {
|
|
||||||
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
|
|
||||||
task.updatedAt = Date.now();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||||
task.status = "completed";
|
task.status = "completed";
|
||||||
task.stage = "打包完成";
|
task.stage = "打包完成";
|
||||||
@ -340,15 +335,15 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
if (request.nextUrl.searchParams.get("download") === "1") {
|
||||||
if (task.status !== "completed" || !task.zipBuffer) {
|
if (task.status !== "completed" || !task.zipFile) {
|
||||||
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
|
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
|
||||||
}
|
}
|
||||||
return new NextResponse(task.zipBuffer, {
|
return new NextResponse(readDownloadZip(task.zipFile.path), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
|
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
|
||||||
"Content-Length": String(task.zipBuffer.length),
|
"Content-Length": String(task.zipFile.bytes),
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
"X-Download-Success-Count": String(task.successCount),
|
"X-Download-Success-Count": String(task.successCount),
|
||||||
"X-Download-Failed-Count": String(task.failedCount),
|
"X-Download-Failed-Count": String(task.failedCount),
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
@ -159,13 +160,7 @@ export async function POST(request: NextRequest) {
|
|||||||
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
|
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
|
||||||
}
|
}
|
||||||
|
|
||||||
const zipBuffer = await zip.generateAsync({
|
return new NextResponse(streamDownloadZip(zip), {
|
||||||
type: "nodebuffer",
|
|
||||||
compression: "DEFLATE",
|
|
||||||
compressionOptions: { level: 6 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return new NextResponse(zipBuffer, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
|
|||||||
@ -1,62 +1,10 @@
|
|||||||
|
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||||
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";
|
|
||||||
|
|
||||||
export const maxDuration = 1200;
|
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) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -67,10 +15,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const taskId = String(searchParams.get("taskId") || "").trim();
|
const taskId = String(searchParams.get("taskId") || "").trim();
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 });
|
||||||
{ success: false, error: "缺少 taskId 参数" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
||||||
@ -99,105 +44,15 @@ export async function GET(request: Request) {
|
|||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "查询任务失败",
|
error: error instanceof Error ? error.message : "查询任务失败",
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
const session = await auth();
|
||||||
const session = await auth();
|
if (!session?.user?.id)
|
||||||
if (!session?.user?.id) {
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
return NextResponse.json(
|
const body = await request.json().catch(() => ({}));
|
||||||
{ success: false, error: "未登录" },
|
return executeCanvasImageResponse(body, session.user.id);
|
||||||
{ 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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
|
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";
|
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
||||||
|
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
@ -10,6 +11,8 @@ interface GenerateVideoRequest {
|
|||||||
prompt?: string;
|
prompt?: string;
|
||||||
mode?: VideoGenerationMode;
|
mode?: VideoGenerationMode;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
audioUrls?: string[];
|
||||||
|
generateAudio?: boolean;
|
||||||
resolution?: VideoResolution;
|
resolution?: VideoResolution;
|
||||||
ratio?: VideoRatio;
|
ratio?: VideoRatio;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
@ -46,23 +49,18 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputImages = Array.isArray(body.images)
|
const referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 }));
|
||||||
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
|
if (referenceCheck.error) {
|
||||||
: [];
|
return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status });
|
||||||
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 reachableImages = referenceCheck.images;
|
||||||
|
|
||||||
const mode = body.mode || inferMode(reachableImages);
|
const mode = body.mode || inferMode(reachableImages);
|
||||||
const result = await executeDirectVideoGeneration({
|
const result = await executeDirectVideoGeneration({
|
||||||
prompt,
|
prompt,
|
||||||
mode,
|
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,
|
images: reachableImages,
|
||||||
resolution: body.resolution,
|
resolution: body.resolution,
|
||||||
ratio: body.ratio,
|
ratio: body.ratio,
|
||||||
@ -90,13 +88,13 @@ export async function POST(request: Request) {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
url: result.videoUrl,
|
url: result.videoUrl,
|
||||||
metadata: result.metadata || null,
|
metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" },
|
||||||
modelDisplayName: result.modelDisplayName,
|
modelDisplayName: result.modelDisplayName,
|
||||||
modelId: result.modelId,
|
modelId: result.modelId,
|
||||||
modelRef: result.modelRef,
|
modelRef: result.modelRef,
|
||||||
mode,
|
mode,
|
||||||
images: reachableImages,
|
images: reachableImages,
|
||||||
droppedImages: unreachableImages,
|
droppedImages: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
.insert(canvasProject)
|
||||||
.values({
|
.values({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
title: title || getDefaultWorkspaceTitle(workspaceType),
|
title: title || getDefaultWorkspaceTitle(workspaceType),
|
||||||
description,
|
description,
|
||||||
workspaceType,
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,8 +29,9 @@ export const runtime = "nodejs";
|
|||||||
export const maxDuration = 1200;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
const logger = createLogger("canvas-text-edit");
|
const logger = createLogger("canvas-text-edit");
|
||||||
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
|
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 120_000;
|
||||||
const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3;
|
const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3;
|
||||||
|
const TEXT_EDIT_GENERATION_TIMEOUT_MS = 180_000;
|
||||||
|
|
||||||
const textItemSchema = z.object({
|
const textItemSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
@ -68,6 +69,13 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
|||||||
|
|
||||||
type TextEditModelCandidate = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
|
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(
|
function getTextEditModelCandidates(
|
||||||
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
|
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
|
||||||
) {
|
) {
|
||||||
@ -82,16 +90,19 @@ function getTextEditModelCandidates(
|
|||||||
}) &&
|
}) &&
|
||||||
item.model.isEnabled &&
|
item.model.isEnabled &&
|
||||||
item.provider.isEnabled &&
|
item.provider.isEnabled &&
|
||||||
|
(!item.model.excludeFromFailover || item.model.isDefaultTextEditModel) &&
|
||||||
SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type),
|
SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type),
|
||||||
)
|
)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aScore =
|
const aScore =
|
||||||
(a.model.isDefaultTextEditModel ? 100 : 0) +
|
(a.model.isDefaultTextEditModel ? 100 : 0) +
|
||||||
|
(isExplicitTextEditModel(a) ? 30 : 0) +
|
||||||
(a.model.isDefault ? 10 : 0) +
|
(a.model.isDefault ? 10 : 0) +
|
||||||
(a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
|
(a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
|
||||||
(a.model.displayName?.includes("火山") ? 3 : 0);
|
(a.model.displayName?.includes("火山") ? 3 : 0);
|
||||||
const bScore =
|
const bScore =
|
||||||
(b.model.isDefaultTextEditModel ? 100 : 0) +
|
(b.model.isDefaultTextEditModel ? 100 : 0) +
|
||||||
|
(isExplicitTextEditModel(b) ? 30 : 0) +
|
||||||
(b.model.isDefault ? 10 : 0) +
|
(b.model.isDefault ? 10 : 0) +
|
||||||
(b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
|
(b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
|
||||||
(b.model.displayName?.includes("火山") ? 3 : 0);
|
(b.model.displayName?.includes("火山") ? 3 : 0);
|
||||||
@ -220,6 +231,13 @@ async function resolveTextEditSourceImageUrl(imageUrl: string) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("source_image_archive_failed", {
|
logger.warn("source_image_archive_failed", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
sourceHost: (() => {
|
||||||
|
try {
|
||||||
|
return new URL(imageUrl).host;
|
||||||
|
} catch {
|
||||||
|
return "invalid-url";
|
||||||
|
}
|
||||||
|
})(),
|
||||||
});
|
});
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
}
|
}
|
||||||
@ -495,6 +513,7 @@ async function generateTextEditImageWithFailover(params: {
|
|||||||
],
|
],
|
||||||
imageParams: {
|
imageParams: {
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
|
timeoutMs: TEXT_EDIT_GENERATION_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
|
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
|
||||||
|
|||||||
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
@ -190,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: {
|
async function archiveRemoteAssetIfNeeded(params: {
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
@ -249,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: {
|
|||||||
);
|
);
|
||||||
const filename = `${params.filenamePrefix}.${ext}`;
|
const filename = `${params.filenamePrefix}.${ext}`;
|
||||||
const uploaded = await uploadFile(filename, assetBuffer);
|
const uploaded = await uploadFile(filename, assetBuffer);
|
||||||
|
await verifyArchivedAssetUpload({
|
||||||
|
url: uploaded.url,
|
||||||
|
expectedBytes: assetBuffer.byteLength,
|
||||||
|
validateAs: params.validateAs,
|
||||||
|
});
|
||||||
cache.set(params.cacheKey, {
|
cache.set(params.cacheKey, {
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
archivedUrl: uploaded.url,
|
archivedUrl: uploaded.url,
|
||||||
@ -299,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: {
|
|||||||
)
|
)
|
||||||
).filter((entry): entry is readonly [string, string] => Boolean(entry));
|
).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[]) {
|
function getStringValue(...values: unknown[]) {
|
||||||
@ -368,33 +416,96 @@ async function updatePersistedWorldAsset(params: {
|
|||||||
function extractWorldId(operation: WorldLabsOperation) {
|
function extractWorldId(operation: WorldLabsOperation) {
|
||||||
const response = getObjectValue(operation.response);
|
const response = getObjectValue(operation.response);
|
||||||
const metadata = getObjectValue(operation.metadata);
|
const metadata = getObjectValue(operation.metadata);
|
||||||
return getStringValue(
|
const nestedWorldId =
|
||||||
response?.world_id,
|
getStringValue(response?.world_id, response?.worldId) ||
|
||||||
response?.worldId,
|
findNestedString(response, ["world_id", "worldId"]);
|
||||||
metadata?.world_id,
|
const metadataWorldId =
|
||||||
metadata?.worldId
|
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) {
|
function extractWorldFromOperation(operation: WorldLabsOperation) {
|
||||||
const response = operation.response;
|
return extractWorldCandidate(operation.response);
|
||||||
if (response && typeof response === "object" && "world_marble_url" in (response as Record<string, unknown>)) {
|
|
||||||
return response as WorldLabsWorld;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
|
function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
|
||||||
const assets = world?.assets || null;
|
const assets = world?.assets || null;
|
||||||
if (!assets) return false;
|
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(
|
return Boolean(
|
||||||
getStringValue(assets.thumbnail_url) ||
|
getStringValue(assets.thumbnail_url) ||
|
||||||
getStringValue(assets.imagery?.pano_url) ||
|
getStringValue(
|
||||||
getStringValue(assets.mesh?.collider_mesh_url) ||
|
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 &&
|
(spzUrls &&
|
||||||
typeof spzUrls === "object" &&
|
typeof spzUrls === "object" &&
|
||||||
Object.values(spzUrls).some((value) => typeof value === "string" && value.trim()))
|
Object.values(spzUrls).some(
|
||||||
|
(value) => typeof value === "string" && value.trim()
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -667,185 +778,224 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
providerForLog = config.providerId || null;
|
providerForLog = config.providerId || null;
|
||||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
const persistedAssetPayload =
|
let persistedAssetPayload =
|
||||||
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
||||||
const shouldUseExternalTaskResultApi =
|
let shouldUseExternalTaskResultApi =
|
||||||
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
||||||
persistedAssetPayload.externalTaskApi !== false &&
|
persistedAssetPayload.externalTaskApi !== false &&
|
||||||
persistedAssetPayload.marbleApi !== true;
|
persistedAssetPayload.marbleApi !== true;
|
||||||
if (shouldUseExternalTaskResultApi) {
|
if (shouldUseExternalTaskResultApi) {
|
||||||
const externalPayload = await worldLabsRequest<
|
try {
|
||||||
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
const externalPayload = await worldLabsRequest<
|
||||||
>(config, {
|
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||||
method: "GET",
|
>(config, {
|
||||||
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
|
method: "GET",
|
||||||
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
const externalData =
|
||||||
|
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
|
||||||
if (externalStatus === "failed") {
|
const externalStatusText = getStringValue(externalData.status) || "running";
|
||||||
const errorMessage = extractExternalTaskErrorMessage(
|
const externalStatus = normalizeExternalTaskStatus(
|
||||||
externalData,
|
externalStatusText,
|
||||||
externalPayload
|
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({
|
await updatePersistedWorldAsset({
|
||||||
id: persistedWorldAsset?.id,
|
id: persistedWorldAsset?.id,
|
||||||
status: "failed",
|
status: "success",
|
||||||
progress: null,
|
progress: 100,
|
||||||
error: errorMessage,
|
modelUrl: stableModelUrl,
|
||||||
assetPayload: externalAssetPayloadBase,
|
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||||
|
error: null,
|
||||||
|
assetPayload,
|
||||||
});
|
});
|
||||||
logger.warn("external task failed status", {
|
|
||||||
|
logger.info("external task success status", {
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
taskIdLength: normalizedTaskId.length,
|
taskIdLength: normalizedTaskId.length,
|
||||||
providerId: config.providerId || null,
|
providerId: config.providerId || null,
|
||||||
error: errorMessage,
|
hasMesh: Boolean(stableModelUrl),
|
||||||
|
hasPano: Boolean(stablePanoUrl),
|
||||||
|
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||||
|
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||||
|
assetsArchivingPending,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
taskId: normalizedTaskId,
|
taskId: normalizedTaskId,
|
||||||
type: "world",
|
type: "world",
|
||||||
status: "failed",
|
status: "success",
|
||||||
progress: null,
|
progress: 100,
|
||||||
errorMessage,
|
|
||||||
output: externalData,
|
output: externalData,
|
||||||
assetPayload: externalAssetPayloadBase,
|
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 extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
|
|
||||||
const rawMeshUrl = extractedAssetPayload.meshUrl;
|
|
||||||
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
|
|
||||||
const rawPanoUrl = extractedAssetPayload.panoUrl;
|
|
||||||
const stableWorldUrl: string | null = null;
|
|
||||||
const stableModelUrl = await archiveRemoteAssetIfNeeded({
|
|
||||||
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
|
|
||||||
sourceUrl: rawMeshUrl,
|
|
||||||
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
|
||||||
fallbackExtension: "glb",
|
|
||||||
validateAs: "glb",
|
|
||||||
});
|
|
||||||
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
|
||||||
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
|
||||||
sourceUrl: rawThumbnailUrl,
|
|
||||||
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
|
|
||||||
fallbackExtension: "jpg",
|
|
||||||
});
|
|
||||||
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
|
|
||||||
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
|
|
||||||
sourceUrl: rawPanoUrl,
|
|
||||||
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
|
|
||||||
fallbackExtension: "jpg",
|
|
||||||
});
|
|
||||||
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
|
|
||||||
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
|
|
||||||
sourceMap: extractedAssetPayload.spzUrls,
|
|
||||||
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
|
||||||
fallbackExtension: "spz",
|
|
||||||
validateAs: "spz",
|
|
||||||
});
|
|
||||||
const assetPayload = {
|
|
||||||
...externalAssetPayloadBase,
|
|
||||||
worldId: extractedAssetPayload.worldId,
|
|
||||||
worldUrl: stableWorldUrl,
|
|
||||||
displayName: extractedAssetPayload.displayName,
|
|
||||||
model: resolveThreeDModelAliasId(
|
|
||||||
extractedAssetPayload.model,
|
|
||||||
modelAliasConfig.worldModels
|
|
||||||
),
|
|
||||||
caption: extractedAssetPayload.caption,
|
|
||||||
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
|
|
||||||
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
|
|
||||||
meshUrl: stableModelUrl,
|
|
||||||
spzUrls: stableSpzUrls,
|
|
||||||
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
|
|
||||||
storageArchivedAt: new Date().toISOString(),
|
|
||||||
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
|
|
||||||
sourceMeshUrl:
|
|
||||||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
|
||||||
? rawMeshUrl
|
|
||||||
: null,
|
|
||||||
sourceThumbnailUrl:
|
|
||||||
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
|
|
||||||
? rawThumbnailUrl
|
|
||||||
: null,
|
|
||||||
sourcePanoUrl:
|
|
||||||
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
|
|
||||||
? rawPanoUrl
|
|
||||||
: null,
|
|
||||||
sourceSpzUrls:
|
|
||||||
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
|
|
||||||
? extractedAssetPayload.spzUrls
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await updatePersistedWorldAsset({
|
|
||||||
id: persistedWorldAsset?.id,
|
|
||||||
status: "success",
|
|
||||||
progress: 100,
|
|
||||||
modelUrl: stableModelUrl,
|
|
||||||
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
|
||||||
error: null,
|
|
||||||
assetPayload,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("external task success status", {
|
|
||||||
elapsedMs: Date.now() - startedAt,
|
|
||||||
taskIdLength: normalizedTaskId.length,
|
|
||||||
providerId: config.providerId || null,
|
|
||||||
hasMesh: Boolean(stableModelUrl),
|
|
||||||
hasPano: Boolean(stablePanoUrl),
|
|
||||||
hasThumbnail: Boolean(stableThumbnailUrl),
|
|
||||||
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
taskId: normalizedTaskId,
|
|
||||||
type: "world",
|
|
||||||
status: "success",
|
|
||||||
progress: 100,
|
|
||||||
output: externalData,
|
|
||||||
modelUrl: stableModelUrl,
|
|
||||||
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
|
||||||
worldId: assetPayload.worldId,
|
|
||||||
worldUrl: stableWorldUrl,
|
|
||||||
panoUrl: stablePanoUrl,
|
|
||||||
caption: assetPayload.caption,
|
|
||||||
assetPayload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||||
@ -908,22 +1058,68 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
let world = extractWorldFromOperation(operation);
|
let world = extractWorldFromOperation(operation);
|
||||||
const worldId = extractWorldId(operation);
|
const worldId = extractWorldId(operation);
|
||||||
if ((!world || !hasUsableWorldAssets(world)) && worldId) {
|
if ((!world || !hasUsableWorldAssets(world)) && worldId) {
|
||||||
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
|
try {
|
||||||
method: "GET",
|
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
|
||||||
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
|
method: "GET",
|
||||||
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
|
||||||
});
|
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||||
world = world
|
});
|
||||||
? {
|
world = world
|
||||||
...world,
|
? {
|
||||||
...detailedWorld,
|
...world,
|
||||||
assets: detailedWorld.assets || world.assets || null,
|
...detailedWorld,
|
||||||
}
|
assets: detailedWorld.assets || world.assets || null,
|
||||||
: detailedWorld;
|
}
|
||||||
|
: 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) {
|
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)) {
|
if (!hasUsableWorldAssets(world)) {
|
||||||
@ -1039,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
: 0;
|
: 0;
|
||||||
const hasMissingSpzArchive =
|
const hasMissingSpzArchive =
|
||||||
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
|
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
|
||||||
const storageArchivedAt =
|
const assetsArchivingPending = Boolean(
|
||||||
[
|
(rawMeshUrl && !stableModelUrl) ||
|
||||||
[rawWorldUrl, stableWorldUrl],
|
(rawThumbnailUrl && !stableThumbnailUrl) ||
|
||||||
[rawMeshUrl, stableModelUrl],
|
(rawPanoUrl && !stablePanoUrl) ||
|
||||||
[rawThumbnailUrl, stableThumbnailUrl],
|
hasMissingSpzArchive
|
||||||
[rawPanoUrl, stablePanoUrl],
|
);
|
||||||
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
|
const storageArchivedAt = assetsArchivingPending
|
||||||
Boolean(
|
? null
|
||||||
stableSpzUrls &&
|
: new Date().toISOString();
|
||||||
assetPayload.spzUrls &&
|
|
||||||
Object.entries(stableSpzUrls).some(
|
|
||||||
([key, value]) =>
|
|
||||||
typeof assetPayload.spzUrls?.[key] === "string" &&
|
|
||||||
assetPayload.spzUrls[key] !== value
|
|
||||||
)
|
|
||||||
)
|
|
||||||
? new Date().toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
logger.info("task success status", {
|
logger.info("task success status", {
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
@ -1068,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
hasThumbnail: Boolean(stableThumbnailUrl),
|
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||||
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||||
storageArchivedAt,
|
storageArchivedAt,
|
||||||
assetsArchivingPending: Boolean(
|
assetsArchivingPending,
|
||||||
(rawMeshUrl && !stableModelUrl) ||
|
|
||||||
hasMissingSpzArchive
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseAssetPayload = {
|
const responseAssetPayload = {
|
||||||
@ -1081,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
panoUrl: stablePanoUrl || assetPayload.panoUrl,
|
panoUrl: stablePanoUrl || assetPayload.panoUrl,
|
||||||
meshUrl: stableModelUrl,
|
meshUrl: stableModelUrl,
|
||||||
spzUrls: stableSpzUrls,
|
spzUrls: stableSpzUrls,
|
||||||
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
|
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
|
||||||
storageArchivedAt,
|
storageArchivedAt,
|
||||||
assetsArchivingPending: Boolean(
|
assetsArchivingPending,
|
||||||
(rawMeshUrl && !stableModelUrl) ||
|
|
||||||
hasMissingSpzArchive
|
|
||||||
),
|
|
||||||
sourceWorldUrl: rawWorldUrl || null,
|
sourceWorldUrl: rawWorldUrl || null,
|
||||||
sourceMeshUrl:
|
sourceMeshUrl:
|
||||||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||||
|
|||||||
@ -142,7 +142,27 @@ function shouldUseExternalTaskCreateApi(
|
|||||||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||||
mode: WorldTaskMode
|
mode: WorldTaskMode
|
||||||
) {
|
) {
|
||||||
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text";
|
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) {
|
function resolveOfficialMarbleModel(model?: string | null) {
|
||||||
@ -153,6 +173,49 @@ function resolveOfficialMarbleModel(model?: string | null) {
|
|||||||
return normalized;
|
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) {
|
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||||
const textPrompt = readOptionalTrimmedText(body.prompt);
|
const textPrompt = readOptionalTrimmedText(body.prompt);
|
||||||
|
|
||||||
@ -356,41 +419,73 @@ export async function POST(request: Request) {
|
|||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
||||||
|
assertWorldModeSupportedByCandidate(candidate, mode);
|
||||||
|
let requestUsedExternalTaskApi = false;
|
||||||
if (useExternalTaskApi) {
|
if (useExternalTaskApi) {
|
||||||
const prompt = readOptionalTrimmedText(body.prompt);
|
const payload = buildExternalWorldTaskPayload(
|
||||||
if (!prompt) {
|
mode,
|
||||||
throw new Error("请输入世界描述");
|
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;
|
||||||
}
|
}
|
||||||
const externalResult = await worldLabsRequest<
|
|
||||||
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
|
||||||
>(candidate, {
|
|
||||||
method: "POST",
|
|
||||||
path: "/api/external/task/generate",
|
|
||||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
|
||||||
json: {
|
|
||||||
model: WORLDLABS_EXTERNAL_TASK_MODEL,
|
|
||||||
displayName,
|
|
||||||
prompt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
result = normalizeExternalTaskCreateOperation(externalResult);
|
|
||||||
} else {
|
} else {
|
||||||
|
requestUsedExternalTaskApi = false;
|
||||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/marble/v1/worlds:generate",
|
path: "/marble/v1/worlds:generate",
|
||||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
json: {
|
json: buildOfficialWorldTaskPayload(
|
||||||
display_name: displayName,
|
displayName,
|
||||||
model: officialMarbleModel,
|
worldPrompt,
|
||||||
permission: {
|
officialMarbleModel,
|
||||||
public: body.makePublic === true,
|
body
|
||||||
},
|
),
|
||||||
world_prompt: worldPrompt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result?.operation_id) {
|
||||||
|
throw new Error("创建 World Labs 世界任务返回缺少 operation_id");
|
||||||
|
}
|
||||||
|
|
||||||
usedProvider = candidate;
|
usedProvider = candidate;
|
||||||
usedExternalTaskApi = useExternalTaskApi;
|
usedExternalTaskApi = requestUsedExternalTaskApi;
|
||||||
providerForLog = candidate.providerId || null;
|
providerForLog = candidate.providerId || null;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
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 { auth } from "@/app/(auth)/auth";
|
||||||
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
|
import { executeExtendPOST } from "@/lib/canvas/operations/extend";
|
||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
export const runtime = "nodejs";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
|
||||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
|
||||||
import {
|
|
||||||
getFriendlyRateLimitMessage,
|
|
||||||
getUserFacingRequestErrorMessage,
|
|
||||||
isRateLimitError,
|
|
||||||
} from "@/lib/request-error";
|
|
||||||
|
|
||||||
export const maxDuration = 180;
|
export const maxDuration = 180;
|
||||||
|
export async function POST(request: Request) {
|
||||||
function parseIncludeBase64(value: unknown): boolean {
|
const session = await auth();
|
||||||
if (typeof value === "boolean") return value;
|
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
const normalized = String(value ?? "").trim().toLowerCase();
|
return executeExtendPOST(request, session.user.id);
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> {
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
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;
|
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) {
|
export function normalizeFlowBoardTitle(value: unknown) {
|
||||||
const title = String(value || "").trim();
|
const title = String(value || "").trim();
|
||||||
return title.slice(0, 256) || "未命名流程白板";
|
return title.slice(0, 256) || "未命名流程白板";
|
||||||
@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot(
|
|||||||
value: unknown
|
value: unknown
|
||||||
): FlowBoardSnapshotData {
|
): FlowBoardSnapshotData {
|
||||||
const raw = isRecord(value) ? value : {};
|
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 =
|
const updatedAt =
|
||||||
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||||
? raw.updatedAt
|
? raw.updatedAt
|
||||||
: new Date().toISOString();
|
: new Date().toISOString();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
|
version: 2,
|
||||||
viewport: normalizeRecord(raw.viewport),
|
activeSheetId: activeSheet.id,
|
||||||
theme: normalizeRecord(raw.theme),
|
sheets: nextSheets,
|
||||||
|
children: activeChildren,
|
||||||
|
viewport: activeViewport,
|
||||||
|
theme: activeTheme,
|
||||||
|
canvas: activeCanvas,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage, imageArchiveTask } from "@/lib/db/schema";
|
||||||
import { eq, desc, sql } from "drizzle-orm";
|
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 page = Number.parseInt(searchParams.get("page") || "1");
|
||||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
const visibleImageFilter = and(
|
||||||
|
eq(generatedImage.userId, userId),
|
||||||
|
isNull(imageArchiveTask.id)
|
||||||
|
);
|
||||||
|
|
||||||
// 查询总数
|
// 查询总数
|
||||||
const countResult = await db
|
const countResult = await db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(generatedImage)
|
.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;
|
const total = countResult[0]?.count || 0;
|
||||||
|
|
||||||
@ -42,7 +53,14 @@ export async function GET(request: NextRequest) {
|
|||||||
modelId: generatedImage.modelId, // 添加 modelId 字段
|
modelId: generatedImage.modelId, // 添加 modelId 字段
|
||||||
})
|
})
|
||||||
.from(generatedImage)
|
.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))
|
.orderBy(desc(generatedImage.createdAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset);
|
.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,
|
updateMessageById,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
buildOpenClawArtifactProxyUrl,
|
||||||
|
normalizeOpenClawArtifacts,
|
||||||
|
} from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
getFalImageTaskStatus,
|
getFalImageTaskStatus,
|
||||||
resolveFalImageTaskToMarkdown,
|
resolveFalImageTaskToMarkdown,
|
||||||
@ -32,12 +36,29 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
|||||||
const falTaskLastPolledAt = new Map<string, number>();
|
const falTaskLastPolledAt = new Map<string, number>();
|
||||||
const falTaskSyncInFlight = new Set<string>();
|
const falTaskSyncInFlight = new Set<string>();
|
||||||
|
|
||||||
function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) {
|
function toPublicVideoMetadata(params: {
|
||||||
|
message: DBMessage;
|
||||||
|
requestOrigin: string;
|
||||||
|
}) {
|
||||||
|
const value = params.message.videoMetadata;
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||||
const metadata = value as Record<string, unknown>;
|
const metadata = value as Record<string, unknown>;
|
||||||
if (metadata.taskKind !== "clawd_chat") return value;
|
if (metadata.taskKind !== "clawd_chat") return value;
|
||||||
const publicMetadata = { ...metadata };
|
const publicMetadata = { ...metadata };
|
||||||
delete publicMetadata.upstreamTaskKey;
|
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;
|
return publicMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,7 +307,11 @@ export async function GET(request: NextRequest) {
|
|||||||
createdAt: msg.createdAt,
|
createdAt: msg.createdAt,
|
||||||
parts: parts,
|
parts: parts,
|
||||||
attachments: msg.attachments || [],
|
attachments: msg.attachments || [],
|
||||||
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null,
|
videoMetadata:
|
||||||
|
toPublicVideoMetadata({
|
||||||
|
message: msg,
|
||||||
|
requestOrigin: request.nextUrl.origin,
|
||||||
|
}) || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,138 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
|
||||||
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";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
export async function POST(request: Request) {
|
||||||
export async function POST(request: NextRequest) {
|
const session = await auth();
|
||||||
try {
|
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
const session = await auth();
|
return executeRemoveBackgroundPOST(request, session.user.id);
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -399,7 +399,7 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
|
|||||||
function normalizeLovartComposerPrefix(value: unknown): string {
|
function normalizeLovartComposerPrefix(value: unknown): string {
|
||||||
const normalized = String(value || "").trim();
|
const normalized = String(value || "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return "让「由壹点AI」";
|
return "让「𝗕𝘂𝗬𝗶」";
|
||||||
}
|
}
|
||||||
return normalized.slice(0, 40);
|
return normalized.slice(0, 40);
|
||||||
}
|
}
|
||||||
@ -573,7 +573,7 @@ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||||
siteName: "AI Chatbot",
|
siteName: "𝗕𝘂𝗬𝗶",
|
||||||
siteLogo: null,
|
siteLogo: null,
|
||||||
siteLogoDark: null,
|
siteLogoDark: null,
|
||||||
favicon: null,
|
favicon: null,
|
||||||
@ -583,11 +583,11 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
|||||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
bannerLink: null,
|
bannerLink: null,
|
||||||
bannerActionText: "立即查看",
|
bannerActionText: "立即查看",
|
||||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||||
surpriseCategories: ["全部", "精选"],
|
surpriseCategories: ["全部", "精选"],
|
||||||
canvasDefaultModelId: null,
|
canvasDefaultModelId: null,
|
||||||
lovartComposerPrefix: "让「由壹点AI」",
|
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
|
||||||
lovartComposerPreviewTexts: [],
|
lovartComposerPreviewTexts: [],
|
||||||
lovartPromptTagVisibility: {},
|
lovartPromptTagVisibility: {},
|
||||||
contactConfig: normalizeContactConfig(undefined),
|
contactConfig: normalizeContactConfig(undefined),
|
||||||
@ -664,7 +664,7 @@ async function readScopedSiteSettings(keys: string[]) {
|
|||||||
|
|
||||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||||
return {
|
return {
|
||||||
siteName: String(settings?.siteName || "AI Chatbot"),
|
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||||
siteLogo: settings?.siteLogo || null,
|
siteLogo: settings?.siteLogo || null,
|
||||||
siteLogoDark: settings?.siteLogoDark || null,
|
siteLogoDark: settings?.siteLogoDark || null,
|
||||||
favicon: settings?.favicon || null,
|
favicon: settings?.favicon || null,
|
||||||
@ -822,7 +822,7 @@ async function getScopedPublicSiteSettings(
|
|||||||
usageTimeStart: typedSettings?.usageTimeStart || "08:00",
|
usageTimeStart: typedSettings?.usageTimeStart || "08:00",
|
||||||
usageTimeEnd: typedSettings?.usageTimeEnd || "20:00",
|
usageTimeEnd: typedSettings?.usageTimeEnd || "20:00",
|
||||||
usageTimeMessage:
|
usageTimeMessage:
|
||||||
typedSettings?.usageTimeMessage || "由壹点累了也是需要休息的",
|
typedSettings?.usageTimeMessage || "BuYi 累了也是需要休息的",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -953,9 +953,9 @@ async function getScopedPublicSiteSettings(
|
|||||||
typedSettings?.lovartPromoText ||
|
typedSettings?.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级",
|
lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级",
|
||||||
lovartHeroTitle: typedSettings?.lovartHeroTitle || "由壹点AI 让设计更简单",
|
lovartHeroTitle: typedSettings?.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||||
lovartHeroSubtitle:
|
lovartHeroSubtitle:
|
||||||
typedSettings?.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
typedSettings?.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||||
surpriseCategories: normalizeSurpriseCategories(
|
surpriseCategories: normalizeSurpriseCategories(
|
||||||
valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY)
|
valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY)
|
||||||
),
|
),
|
||||||
@ -1476,7 +1476,7 @@ export async function GET(request: NextRequest) {
|
|||||||
usageTimeEnabled: false,
|
usageTimeEnabled: false,
|
||||||
usageTimeStart: "08:00",
|
usageTimeStart: "08:00",
|
||||||
usageTimeEnd: "20:00",
|
usageTimeEnd: "20:00",
|
||||||
usageTimeMessage: "由壹点累了也是需要休息的",
|
usageTimeMessage: "BuYi 累了也是需要休息的",
|
||||||
bannerEnabled: true,
|
bannerEnabled: true,
|
||||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
bannerLink: null,
|
bannerLink: null,
|
||||||
@ -1525,8 +1525,8 @@ export async function GET(request: NextRequest) {
|
|||||||
canvasDetailAgentModelId: null,
|
canvasDetailAgentModelId: null,
|
||||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
lovartPromoActionText: "立即升级",
|
lovartPromoActionText: "立即升级",
|
||||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||||
surpriseCategories,
|
surpriseCategories,
|
||||||
aiDetailGenerationEnabled,
|
aiDetailGenerationEnabled,
|
||||||
lovartComposerPrefix,
|
lovartComposerPrefix,
|
||||||
@ -1546,8 +1546,8 @@ export async function GET(request: NextRequest) {
|
|||||||
baseSettings.lovartPromoText ||
|
baseSettings.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级",
|
lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级",
|
||||||
lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单",
|
lovartHeroTitle: baseSettings.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||||
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||||
notification,
|
notification,
|
||||||
surpriseCategories,
|
surpriseCategories,
|
||||||
aiDetailGenerationEnabled,
|
aiDetailGenerationEnabled,
|
||||||
@ -3119,7 +3119,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
|
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
|
||||||
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
|
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
|
||||||
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
|
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
|
||||||
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的",
|
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "BuYi 累了也是需要休息的",
|
||||||
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
||||||
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
|
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
bannerLink: siteSettingsPayload.bannerLink,
|
bannerLink: siteSettingsPayload.bannerLink,
|
||||||
@ -3144,8 +3144,8 @@ export async function PUT(request: NextRequest) {
|
|||||||
siteSettingsPayload.lovartPromoText ??
|
siteSettingsPayload.lovartPromoText ??
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级",
|
lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级",
|
||||||
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "由壹点AI 让设计更简单",
|
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||||
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "懂你的设计代理,帮你搞定一切",
|
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "下达指令,即刻呈现",
|
||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,6 +74,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useCanvasImageArchives } from "@/hooks/use-canvas-image-archives";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
// 深色主题组件 V2 (新版布局)
|
// 深色主题组件 V2 (新版布局)
|
||||||
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
|
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
|
||||||
@ -87,6 +88,7 @@ import {
|
|||||||
type CanvasAddImageDialogImage,
|
type CanvasAddImageDialogImage,
|
||||||
} from "@/components/canvas/CanvasAddImageDialog";
|
} from "@/components/canvas/CanvasAddImageDialog";
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
|
import { CanvasSurfaceHeader } from "@/components/canvas/CanvasSurfaceHeader";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -249,7 +251,6 @@ import type {
|
|||||||
AgentWorkflowUiConfig,
|
AgentWorkflowUiConfig,
|
||||||
} from "@/types/agent";
|
} from "@/types/agent";
|
||||||
import type {
|
import type {
|
||||||
AgentSlotNodeData,
|
|
||||||
CanvasEdge,
|
CanvasEdge,
|
||||||
CanvasInitialProject,
|
CanvasInitialProject,
|
||||||
CanvasNode,
|
CanvasNode,
|
||||||
@ -262,7 +263,7 @@ import type {
|
|||||||
|
|
||||||
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
||||||
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
||||||
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5";
|
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
||||||
|
|
||||||
const fetchCanvasSiteSettings = async (url: string) => {
|
const fetchCanvasSiteSettings = async (url: string) => {
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
@ -1906,6 +1907,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
[isVideoMode]
|
[isVideoMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useCanvasImageArchives(id);
|
||||||
|
|
||||||
// Store state
|
// Store state
|
||||||
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
||||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||||
@ -3141,8 +3144,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const updateWidthFromClientX = (clientX: number) => {
|
const updateWidthFromClientX = (clientX: number) => {
|
||||||
const currentViewportWidth = window.innerWidth;
|
const currentViewportWidth = window.innerWidth;
|
||||||
const nextWidth =
|
const nextWidth = currentViewportWidth - DOCKED_PANEL_INSET - clientX;
|
||||||
currentViewportWidth - DOCKED_PANEL_INSET - clientX;
|
|
||||||
|
|
||||||
setFloatingPanelWidth(
|
setFloatingPanelWidth(
|
||||||
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
|
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
|
||||||
@ -7515,7 +7517,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
materialMimeType: image.mimeType || undefined,
|
materialMimeType: image.mimeType || undefined,
|
||||||
originalHeight: dimensions.originalHeight,
|
originalHeight: dimensions.originalHeight,
|
||||||
originalWidth: dimensions.originalWidth,
|
originalWidth: dimensions.originalWidth,
|
||||||
source: "agent-workflow-material-search",
|
source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
connections: [],
|
connections: [],
|
||||||
@ -7527,7 +7529,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
if (createdNodeIds.length > 0) {
|
if (createdNodeIds.length > 0) {
|
||||||
selectNodes(createdNodeIds);
|
selectNodes(createdNodeIds);
|
||||||
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
|
toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
|
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
|
||||||
@ -7543,64 +7545,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setIsAddImageDialogOpen(true);
|
setIsAddImageDialogOpen(true);
|
||||||
}, [handleAddWorkflowNode, isVideoMode]);
|
}, [handleAddWorkflowNode, isVideoMode]);
|
||||||
|
|
||||||
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
|
|
||||||
if (isVideoMode) return;
|
|
||||||
|
|
||||||
const slotDefaults: Record<
|
|
||||||
AgentSlotNodeData["slotType"],
|
|
||||||
{
|
|
||||||
label: string;
|
|
||||||
size: CanvasNode["size"];
|
|
||||||
aspectRatio: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
image: {
|
|
||||||
label: "图片槽位",
|
|
||||||
size: { width: 320, height: 320 },
|
|
||||||
aspectRatio: "1:1",
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
label: "视频槽位",
|
|
||||||
size: { width: 360, height: 220 },
|
|
||||||
aspectRatio: "16:9",
|
|
||||||
},
|
|
||||||
"ui-screen": {
|
|
||||||
label: "UI 槽位",
|
|
||||||
size: { width: 360, height: 260 },
|
|
||||||
aspectRatio: "16:9",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const defaults = slotDefaults[slotType];
|
|
||||||
const origin = getVisibleCanvasOrigin();
|
|
||||||
createCanvasNode({
|
|
||||||
type: "agent-slot",
|
|
||||||
position: {
|
|
||||||
x: origin.x + 40,
|
|
||||||
y: origin.y + 40,
|
|
||||||
},
|
|
||||||
size: defaults.size,
|
|
||||||
data: {
|
|
||||||
slotType,
|
|
||||||
label: defaults.label,
|
|
||||||
status: "empty",
|
|
||||||
aspectRatio: defaults.aspectRatio,
|
|
||||||
fillPolicy: "replace-empty",
|
|
||||||
acceptedSources: ["text", "image", "selection", "annotation"],
|
|
||||||
filledNodeIds: [],
|
|
||||||
metadata: {
|
|
||||||
createdBy: "toolbar",
|
|
||||||
createdSlotType: slotType,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connections: [],
|
|
||||||
});
|
|
||||||
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
|
|
||||||
|
|
||||||
const handleAddAgentImageSlotNode = useCallback(() => {
|
|
||||||
handleAddAgentSlotNode("image");
|
|
||||||
}, [handleAddAgentSlotNode]);
|
|
||||||
|
|
||||||
const handleAddVideoNode = useCallback(() => {
|
const handleAddVideoNode = useCallback(() => {
|
||||||
if (isVideoMode) {
|
if (isVideoMode) {
|
||||||
handleAddWorkflowNode("video-input");
|
handleAddWorkflowNode("video-input");
|
||||||
@ -9374,6 +9318,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
// 键盘快捷键
|
// 键盘快捷键
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (useCanvasStore.getState().canvasImageSelectionSession) return;
|
||||||
// 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素)
|
// 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素)
|
||||||
if (isCanvasTextEntryTarget(e.target)) {
|
if (isCanvasTextEntryTarget(e.target)) {
|
||||||
return;
|
return;
|
||||||
@ -10311,7 +10256,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div
|
||||||
className="relative h-dvh w-full overflow-hidden overscroll-none"
|
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||||
|
className={cn(
|
||||||
|
"h-dvh w-full overflow-hidden overscroll-none",
|
||||||
|
isVideoMode ? "relative" : "lovart-canvas-surface fixed inset-0"
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--canvas-surface-background": canvasSurfaceBackground,
|
"--canvas-surface-background": canvasSurfaceBackground,
|
||||||
@ -10323,6 +10272,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
data-canvas-stage="true"
|
data-canvas-stage="true"
|
||||||
|
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 40 }}
|
||||||
key={`workspace-${id}`}
|
key={`workspace-${id}`}
|
||||||
>
|
>
|
||||||
<CanvasWorkspace
|
<CanvasWorkspace
|
||||||
@ -10333,9 +10283,10 @@ export default function CanvasEditorLegacyPage({
|
|||||||
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
||||||
: '点击顶部"添加"按钮开始创作'
|
: '点击顶部"添加"按钮开始创作'
|
||||||
}
|
}
|
||||||
occlusionRight={panelOcclusionRight}
|
occlusionRight={0}
|
||||||
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
|
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
|
||||||
showEmptyState={!isVideoMode}
|
showEmptyState={false}
|
||||||
|
showZoomControls={isVideoMode}
|
||||||
workspaceLabel={workspaceLabel}
|
workspaceLabel={workspaceLabel}
|
||||||
workspaceVariant={isVideoMode ? "video" : "canvas"}
|
workspaceVariant={isVideoMode ? "video" : "canvas"}
|
||||||
layerSplitEnabled={isLayerSplitEnabled}
|
layerSplitEnabled={isLayerSplitEnabled}
|
||||||
@ -11024,14 +10975,27 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{brandEntry}
|
{isVideoMode ? brandEntry : (
|
||||||
|
<CanvasSurfaceHeader
|
||||||
|
isSaving={isSaving}
|
||||||
|
onAddImage={handleAddImageNode}
|
||||||
|
onHome={goHome}
|
||||||
|
onOpenFiles={() => {
|
||||||
|
setIsPanelOpen(true);
|
||||||
|
setPanelMode("fileList");
|
||||||
|
}}
|
||||||
|
onSave={() => void handleSave()}
|
||||||
|
projectTitle={projectTitle}
|
||||||
|
rightInset={panelOcclusionRight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{agentDisabledNotice}
|
{agentDisabledNotice}
|
||||||
|
|
||||||
{/* 2. Floating Actions (Top Right) */}
|
{/* 2. Floating Actions (Top Right) */}
|
||||||
{!isVideoMode && !isPanelOpen && (
|
{!isVideoMode && !isPanelOpen && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-20"
|
className="absolute z-20"
|
||||||
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}
|
style={{ top: 46, right: 12 }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -11063,7 +11027,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
)}
|
)}
|
||||||
data-canvas-right-panel="true"
|
data-canvas-right-panel="true"
|
||||||
style={{
|
style={{
|
||||||
top: DOCKED_PANEL_INSET,
|
top: 0,
|
||||||
right: DOCKED_PANEL_INSET,
|
right: DOCKED_PANEL_INSET,
|
||||||
bottom: DOCKED_PANEL_INSET,
|
bottom: DOCKED_PANEL_INSET,
|
||||||
width: resolvedPanelWidth,
|
width: resolvedPanelWidth,
|
||||||
@ -11094,7 +11058,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
<div className="flex h-full w-full flex-col overflow-hidden border-l border-[#ededed] bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
{!areSecondaryPanelsReady ? (
|
{!areSecondaryPanelsReady ? (
|
||||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||||
) : panelMode === "chat" ? (
|
) : panelMode === "chat" ? (
|
||||||
@ -11200,11 +11164,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
className={cn(
|
className={cn(
|
||||||
isVideoMode
|
isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_CLASS
|
? VIDEO_CANVAS_TOOLBAR_CLASS
|
||||||
: "border bg-white shadow-2xl dark:bg-zinc-950",
|
: "border border-[#e9e9e9] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.035)] dark:border-white/10 dark:bg-zinc-950",
|
||||||
!isVideoMode &&
|
!isVideoMode &&
|
||||||
(shouldDockFloatingToolbarToSide
|
(shouldDockFloatingToolbarToSide
|
||||||
? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5"
|
? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5"
|
||||||
: "flex items-center gap-1 rounded-[24px] px-2.5 py-1.5")
|
: "flex items-center gap-1 rounded-[14px] px-1.5 py-1.5")
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
shouldDockFloatingToolbarToSide
|
shouldDockFloatingToolbarToSide
|
||||||
@ -11222,7 +11186,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
tool === "select" &&
|
tool === "select" &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||||
)}
|
)}
|
||||||
onClick={() => setTool("select")}
|
onClick={() => setTool("select")}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11232,6 +11196,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
active={tool === "select"}
|
active={tool === "select"}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={MousePointer}
|
fallback={MousePointer}
|
||||||
|
referenceName={isVideoMode ? undefined : "cursor"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"selectTool"
|
"selectTool"
|
||||||
@ -11245,63 +11211,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{!isVideoMode &&
|
|
||||||
isCanvasToolbarItemEnabled(
|
|
||||||
canvasToolbarConfig,
|
|
||||||
"addAgentImageSlot"
|
|
||||||
) ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
|
|
||||||
<CanvasToolbarIcon
|
|
||||||
className="h-4 w-4"
|
|
||||||
fallback={ImagePlus}
|
|
||||||
item={getCanvasToolbarItem(
|
|
||||||
canvasToolbarConfig,
|
|
||||||
"addAgentImageSlot"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side={floatingToolbarTooltipSide}>
|
|
||||||
{
|
|
||||||
getCanvasToolbarItem(
|
|
||||||
canvasToolbarConfig,
|
|
||||||
"addAgentImageSlot"
|
|
||||||
).label
|
|
||||||
}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
side={floatingToolbarDropdownSide}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => handleAddAgentImageSlotNode()}
|
|
||||||
>
|
|
||||||
<ImagePlus className="h-4 w-4" />
|
|
||||||
图片槽位
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => handleAddAgentSlotNode("video")}
|
|
||||||
>
|
|
||||||
<Video className="h-4 w-4" />
|
|
||||||
视频槽位
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => handleAddAgentSlotNode("ui-screen")}
|
|
||||||
>
|
|
||||||
<MonitorSmartphone className="h-4 w-4" />
|
|
||||||
UI 槽位
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -11312,7 +11221,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
tool === "pan" &&
|
tool === "pan" &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||||
)}
|
)}
|
||||||
onClick={() => setTool("pan")}
|
onClick={() => setTool("pan")}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11322,6 +11231,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
active={tool === "pan"}
|
active={tool === "pan"}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Hand}
|
fallback={Hand}
|
||||||
|
referenceName={isVideoMode ? undefined : "pan"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
|
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@ -11442,7 +11353,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
tool === "draw" &&
|
tool === "draw" &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||||
)}
|
)}
|
||||||
onClick={() => setTool("draw")}
|
onClick={() => setTool("draw")}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11483,7 +11394,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
isAgentMode &&
|
isAgentMode &&
|
||||||
(isVideoMode
|
(isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||||
)}
|
)}
|
||||||
onClick={handleStyleModeToolbarClick}
|
onClick={handleStyleModeToolbarClick}
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -11499,6 +11410,14 @@ export default function CanvasEditorLegacyPage({
|
|||||||
fallback={
|
fallback={
|
||||||
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
|
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
|
||||||
}
|
}
|
||||||
|
referenceName={
|
||||||
|
isVideoMode
|
||||||
|
? undefined
|
||||||
|
: shouldUseStyleModeButtonForStylePro
|
||||||
|
? "style-controls"
|
||||||
|
: "creativity"
|
||||||
|
}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"styleMode"
|
"styleMode"
|
||||||
@ -11528,6 +11447,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
|
referenceName={isVideoMode ? undefined : "style-controls"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"stylePro"
|
"stylePro"
|
||||||
@ -11557,6 +11478,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Layers}
|
fallback={Layers}
|
||||||
|
referenceName={isVideoMode ? undefined : "layers"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"batchProcessing"
|
"batchProcessing"
|
||||||
@ -11588,6 +11511,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={ImagePlus}
|
fallback={ImagePlus}
|
||||||
|
referenceName={isVideoMode ? undefined : "image"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
isVideoMode ? "addImageInput" : "addImage"
|
isVideoMode ? "addImageInput" : "addImage"
|
||||||
@ -11620,6 +11545,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Type}
|
fallback={Type}
|
||||||
|
referenceName={isVideoMode ? undefined : "text"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
isVideoMode ? "addScriptNode" : "addText"
|
isVideoMode ? "addScriptNode" : "addText"
|
||||||
@ -11777,6 +11704,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={PenTool}
|
fallback={PenTool}
|
||||||
|
referenceName={isVideoMode ? undefined : "draw"}
|
||||||
|
referenceSize={20}
|
||||||
item={resolveCanvasToolbarItemWithFallback(
|
item={resolveCanvasToolbarItemWithFallback(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"realtimeCanvas",
|
"realtimeCanvas",
|
||||||
@ -11824,6 +11753,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
|
referenceName={isVideoMode ? undefined : "image-enhance"}
|
||||||
|
referenceSize={20}
|
||||||
item={resolveCanvasToolbarItemWithFallback(
|
item={resolveCanvasToolbarItemWithFallback(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"enhanceCanvas",
|
"enhanceCanvas",
|
||||||
@ -11877,6 +11808,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Box}
|
fallback={Box}
|
||||||
|
referenceName={isVideoMode ? undefined : "model-auto"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
|
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@ -11966,6 +11899,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Plus}
|
fallback={Plus}
|
||||||
|
referenceName={isVideoMode ? undefined : "plus"}
|
||||||
|
referenceSize={20}
|
||||||
item={getCanvasToolbarItem(
|
item={getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
isVideoMode ? "newVideoCanvas" : "newCanvas"
|
isVideoMode ? "newVideoCanvas" : "newCanvas"
|
||||||
@ -11993,12 +11928,15 @@ export default function CanvasEditorLegacyPage({
|
|||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 6. Bottom Left Compact Controls */}
|
{/* Compact zoom and history controls. */}
|
||||||
<div
|
<div
|
||||||
|
data-canvas-compact-controls="true"
|
||||||
className="absolute z-20"
|
className="absolute z-20"
|
||||||
style={{
|
style={{
|
||||||
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
|
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
|
||||||
left: `${compactControlsLeft + 8}px`,
|
...(isVideoMode
|
||||||
|
? { left: `${compactControlsLeft + 8}px` }
|
||||||
|
: { right: `${panelOcclusionRight + 16}px` }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -365,6 +365,62 @@ function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTaskPreviewCandidates(task?: TaskRecord | null) {
|
||||||
|
if (!task) return [];
|
||||||
|
const candidates = [
|
||||||
|
task.previewImageUrl,
|
||||||
|
task.assetPayload?.thumbnailUrl,
|
||||||
|
task.assetPayload?.panoUrl,
|
||||||
|
task.assetPayload?.sourcePreviewImageUrl,
|
||||||
|
task.assetPayload?.sourceThumbnailUrl,
|
||||||
|
task.assetPayload?.sourcePanoUrl,
|
||||||
|
];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return candidates
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter((value) => {
|
||||||
|
if (!value || seen.has(value)) return false;
|
||||||
|
seen.add(value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPersistableTaskPreviewUrl(value?: string | null) {
|
||||||
|
return /^https?:\/\//i.test(String(value || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskAssetPreview({ task }: { task: TaskRecord }) {
|
||||||
|
const candidates = getTaskPreviewCandidates(task);
|
||||||
|
const signature = candidates.join("|");
|
||||||
|
const [candidateIndex, setCandidateIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCandidateIndex(0);
|
||||||
|
}, [signature]);
|
||||||
|
|
||||||
|
const previewUrl = candidates[candidateIndex] || null;
|
||||||
|
if (!previewUrl) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
||||||
|
{task.status === "queued" || task.status === "running" ? (
|
||||||
|
<Loader2 className="h-7 w-7 animate-spin text-yellow-400/70" />
|
||||||
|
) : (
|
||||||
|
<Box className="h-8 w-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
alt="3D 资产预览"
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
|
||||||
|
onError={() => setCandidateIndex((index) => index + 1)}
|
||||||
|
src={previewUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function canImportTaskToCanvas(task?: TaskRecord | null) {
|
function canImportTaskToCanvas(task?: TaskRecord | null) {
|
||||||
if (!task || task.status !== "success") return false;
|
if (!task || task.status !== "success") return false;
|
||||||
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
|
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
|
||||||
@ -850,9 +906,21 @@ export default function CanvasModel3DPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pollAttemptsRef = useRef(0);
|
const pollAttemptsRef = useRef(0);
|
||||||
|
const pollErrorCountRef = useRef(0);
|
||||||
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
|
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
|
||||||
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
|
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
|
||||||
|
|
||||||
|
const MAX_RETRYABLE_TASK_POLL_FAILURES = 12;
|
||||||
|
|
||||||
|
const isRetryableTaskPollFailure = (message: string) => {
|
||||||
|
const normalized = String(message || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
/http[^0-9]*5\d\d/.test(normalized) ||
|
||||||
|
normalized.includes("超时") ||
|
||||||
|
normalized.includes("timeout")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch site settings for Logo
|
// Fetch site settings for Logo
|
||||||
const { data: siteSettingsData } = useSWR(
|
const { data: siteSettingsData } = useSWR(
|
||||||
"/api/site-settings?scope=branding",
|
"/api/site-settings?scope=branding",
|
||||||
@ -1114,7 +1182,9 @@ export default function CanvasModel3DPage() {
|
|||||||
modelVersion: task.modelVersion,
|
modelVersion: task.modelVersion,
|
||||||
progress: task.progress ?? null,
|
progress: task.progress ?? null,
|
||||||
modelUrl: task.modelUrl ?? null,
|
modelUrl: task.modelUrl ?? null,
|
||||||
previewImageUrl: task.previewImageUrl ?? null,
|
previewImageUrl: isPersistableTaskPreviewUrl(task.previewImageUrl)
|
||||||
|
? task.previewImageUrl
|
||||||
|
: null,
|
||||||
error: task.error ?? null,
|
error: task.error ?? null,
|
||||||
sourceTaskId: task.sourceTaskId ?? null,
|
sourceTaskId: task.sourceTaskId ?? null,
|
||||||
exportFormat: task.exportFormat ?? null,
|
exportFormat: task.exportFormat ?? null,
|
||||||
@ -1434,6 +1504,33 @@ export default function CanvasModel3DPage() {
|
|||||||
});
|
});
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
const failMessage = result.error || "任务查询失败";
|
const failMessage = result.error || "任务查询失败";
|
||||||
|
|
||||||
|
if (isRetryableTaskPollFailure(failMessage)) {
|
||||||
|
pollErrorCountRef.current += 1;
|
||||||
|
if (pollErrorCountRef.current === 1) {
|
||||||
|
console.warn("[Model3DPage] 任务查询临时异常,继续重试", {
|
||||||
|
taskId: currentTask.taskId,
|
||||||
|
provider: currentTask.provider,
|
||||||
|
error: failMessage,
|
||||||
|
attempt: pollAttemptsRef.current,
|
||||||
|
});
|
||||||
|
toast.info(`任务查询异常,正在重试...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollErrorCountRef.current >= MAX_RETRYABLE_TASK_POLL_FAILURES) {
|
||||||
|
const failedRecord: TaskRecord = {
|
||||||
|
...currentTask,
|
||||||
|
status: "failed",
|
||||||
|
error: `${failMessage}(重试仍异常)`,
|
||||||
|
};
|
||||||
|
setCurrentTask(failedRecord);
|
||||||
|
upsertTaskHistory(failedRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pollErrorCountRef.current = 0;
|
||||||
const failedRecord: TaskRecord = {
|
const failedRecord: TaskRecord = {
|
||||||
...currentTask,
|
...currentTask,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
@ -1479,6 +1576,8 @@ export default function CanvasModel3DPage() {
|
|||||||
error:
|
error:
|
||||||
status === "failed" ? result.data.errorMessage || "任务失败" : null,
|
status === "failed" ? result.data.errorMessage || "任务失败" : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pollErrorCountRef.current = 0;
|
||||||
if (taskRecordChanged(currentTask, nextRecord)) {
|
if (taskRecordChanged(currentTask, nextRecord)) {
|
||||||
setCurrentTask(nextRecord);
|
setCurrentTask(nextRecord);
|
||||||
upsertTaskHistory(nextRecord);
|
upsertTaskHistory(nextRecord);
|
||||||
@ -1652,6 +1751,7 @@ export default function CanvasModel3DPage() {
|
|||||||
sourceTaskId?: string | null;
|
sourceTaskId?: string | null;
|
||||||
exportFormat?: string | null;
|
exportFormat?: string | null;
|
||||||
assetPayload?: Model3DTaskAssetPayload | null;
|
assetPayload?: Model3DTaskAssetPayload | null;
|
||||||
|
previewImageUrl?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const normalizedPayload =
|
const normalizedPayload =
|
||||||
params.provider === "tripo-3d" &&
|
params.provider === "tripo-3d" &&
|
||||||
@ -1706,6 +1806,7 @@ export default function CanvasModel3DPage() {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
sourceTaskId: params.sourceTaskId || null,
|
sourceTaskId: params.sourceTaskId || null,
|
||||||
exportFormat: params.exportFormat || null,
|
exportFormat: params.exportFormat || null,
|
||||||
|
previewImageUrl: params.previewImageUrl || null,
|
||||||
assetPayload:
|
assetPayload:
|
||||||
params.provider === "worldlabs-3d"
|
params.provider === "worldlabs-3d"
|
||||||
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
|
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
|
||||||
@ -1821,6 +1922,7 @@ export default function CanvasModel3DPage() {
|
|||||||
providerId: uploadResult.data.providerId || null,
|
providerId: uploadResult.data.providerId || null,
|
||||||
providerName: uploadResult.data.providerName || null,
|
providerName: uploadResult.data.providerName || null,
|
||||||
},
|
},
|
||||||
|
previewImageUrl: referencePreviewUrl,
|
||||||
});
|
});
|
||||||
toast.success("已提交图生3D任务");
|
toast.success("已提交图生3D任务");
|
||||||
return;
|
return;
|
||||||
@ -1855,6 +1957,8 @@ export default function CanvasModel3DPage() {
|
|||||||
providerId: uploadResult.data.providerId || null,
|
providerId: uploadResult.data.providerId || null,
|
||||||
providerName: uploadResult.data.providerName || null,
|
providerName: uploadResult.data.providerName || null,
|
||||||
},
|
},
|
||||||
|
previewImageUrl:
|
||||||
|
batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null,
|
||||||
});
|
});
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
}
|
}
|
||||||
@ -1910,6 +2014,7 @@ export default function CanvasModel3DPage() {
|
|||||||
providerId: boundTripoProviderId,
|
providerId: boundTripoProviderId,
|
||||||
providerName: boundTripoProviderName,
|
providerName: boundTripoProviderName,
|
||||||
},
|
},
|
||||||
|
previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null,
|
||||||
});
|
});
|
||||||
toast.success("已提交多视图3D任务");
|
toast.success("已提交多视图3D任务");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1992,6 +2097,7 @@ export default function CanvasModel3DPage() {
|
|||||||
mode: "world-image",
|
mode: "world-image",
|
||||||
promptText: normalizedPrompt || "图生世界",
|
promptText: normalizedPrompt || "图生世界",
|
||||||
modelLabel: worldModel,
|
modelLabel: worldModel,
|
||||||
|
previewImageUrl: sourceUrl || worldImagePreviewUrl,
|
||||||
});
|
});
|
||||||
toast.success("已提交图生世界任务");
|
toast.success("已提交图生世界任务");
|
||||||
return;
|
return;
|
||||||
@ -2076,6 +2182,10 @@ export default function CanvasModel3DPage() {
|
|||||||
mode: "world-multi-image",
|
mode: "world-multi-image",
|
||||||
promptText: normalizedPrompt || "多图世界",
|
promptText: normalizedPrompt || "多图世界",
|
||||||
modelLabel: worldModel,
|
modelLabel: worldModel,
|
||||||
|
previewImageUrl:
|
||||||
|
mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl ||
|
||||||
|
selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl ||
|
||||||
|
null,
|
||||||
});
|
});
|
||||||
toast.success("已提交多图世界任务");
|
toast.success("已提交多图世界任务");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -4177,8 +4287,6 @@ export default function CanvasModel3DPage() {
|
|||||||
const canImportWorld =
|
const canImportWorld =
|
||||||
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
|
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
|
||||||
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
|
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
|
||||||
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -4195,17 +4303,7 @@ export default function CanvasModel3DPage() {
|
|||||||
onClick={() => handleSelectTask(task)}
|
onClick={() => handleSelectTask(task)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{previewUrl ? (
|
<TaskAssetPreview task={task} />
|
||||||
<img
|
|
||||||
alt="preview"
|
|
||||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
|
|
||||||
src={previewUrl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
|
||||||
<Box className="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<div className="border-white/10 border-t bg-zinc-950/95 px-1.5 py-2">
|
<div className="border-white/10 border-t bg-zinc-950/95 px-1.5 py-2">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
|||||||
17
app/dev/flow-board-preview/page.tsx
Normal file
17
app/dev/flow-board-preview/page.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
|
||||||
|
|
||||||
|
export default function FlowBoardPreviewPage() {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlowBoardClient
|
||||||
|
previewMode
|
||||||
|
siteLogo={null}
|
||||||
|
siteLogoDark={null}
|
||||||
|
siteName="SnapGen"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
app/globals.css
282
app/globals.css
@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
/* include utility classes in streamdown */
|
/* include utility classes in streamdown */
|
||||||
@source '../node_modules/streamdown/dist/index.js';
|
@source '../node_modules/streamdown/dist/index.js';
|
||||||
|
@source '../components/canvas/**/*.tsx';
|
||||||
|
@source not '../.next*/**';
|
||||||
|
@source not '../.qa/**';
|
||||||
@source not '../services/pascal-editor';
|
@source not '../services/pascal-editor';
|
||||||
|
|
||||||
/* custom variant for setting dark mode programmatically */
|
/* custom variant for setting dark mode programmatically */
|
||||||
@ -405,6 +408,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
|
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
|
||||||
|
.video-canvas-workspace {
|
||||||
|
/* Match the compact typography used by the reference canvas. */
|
||||||
|
--canvas-font-caption: 12px;
|
||||||
|
--canvas-font-body: 13px;
|
||||||
|
--canvas-font-heading: 14px;
|
||||||
|
font-size: var(--canvas-font-body);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-workspace {
|
.settings-workspace {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
@ -507,3 +519,273 @@
|
|||||||
padding: 0 12px 12px;
|
padding: 0 12px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lovart surface typography and controls, scoped to the home and image canvas. */
|
||||||
|
.lovart-home-surface,
|
||||||
|
.lovart-canvas-surface {
|
||||||
|
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lovart-suggestion {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
.lovart-suggestion:hover { background: #f7f7f7; border-color: #d5d5d5; }
|
||||||
|
.lovart-suggestion:focus-visible,
|
||||||
|
.lovart-start-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
|
||||||
|
|
||||||
|
.lovart-start-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 9px;
|
||||||
|
min-width: 172px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||||
|
transition: background-color 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
.lovart-start-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 0; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer { padding: 0 8px 8px; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] {
|
||||||
|
min-height: 120px;
|
||||||
|
border-color: #e5e5e5;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||||
|
}
|
||||||
|
/* The promo backplate continues underneath the composer's rounded corners. */
|
||||||
|
.lovart-chat-promo-connected { padding: 0 8px; }
|
||||||
|
.lovart-chat-promo-surface { padding-bottom: 24px; border-radius: 24px 24px 0 0; background: #ebebeb; }
|
||||||
|
.lovart-chat-promo-row { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 10px 0 16px; }
|
||||||
|
.lovart-chat-promo-message { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0; border: 0; background: transparent; color: #202020; font-size: 14px; font-weight: 400; line-height: 20px; text-align: left; cursor: pointer; }
|
||||||
|
.lovart-chat-promo-message:disabled { cursor: default; opacity: 1; }
|
||||||
|
.lovart-chat-promo-message > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lovart-chat-promo-badge { flex-shrink: 0; }
|
||||||
|
.lovart-chat-promo-close { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: #262626; cursor: pointer; }
|
||||||
|
.lovart-chat-promo-close:hover { background: rgb(0 0 0 / 5%); }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-promo-connected + .lovart-chat-composer { position: relative; z-index: 1; margin-top: -24px; background: transparent; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor][data-canvas-composer-mode="image"] { min-height: 184px; border-radius: 24px; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-upload { margin: 8px 8px 0; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { flex-direction: column; gap: 8px; width: 56px; height: 68px; border: 0; border-radius: 18px; background: #f5f5f5; color: #aaa; font-size: 12px; font-weight: 400; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #ececec; color: #777; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile > svg { width: 14px; height: 14px; stroke-width: 1.5; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-text-region { padding: 12px 16px 0; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"] { min-height: 40px; font-size: 14px; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"]:empty::before { color: #b0b0b0; }
|
||||||
|
.dark .lovart-chat-promo-surface { background: #27272a; }
|
||||||
|
.dark .lovart-chat-promo-message, .dark .lovart-chat-promo-close { color: #eee; }
|
||||||
|
.dark .lovart-chat-promo-close:hover { background: rgb(255 255 255 / 10%); }
|
||||||
|
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { background: #27272a; color: #a1a1aa; }
|
||||||
|
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #3f3f46; color: #eee; }
|
||||||
|
.dark .lovart-suggestion,
|
||||||
|
.dark .lovart-start-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
|
||||||
|
.dark .lovart-suggestion:hover,
|
||||||
|
.dark .lovart-start-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
|
||||||
|
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-color: rgb(255 255 255 / 10%); }
|
||||||
|
.dark [data-canvas-right-panel="true"] > .border-l { border-left-color: #27272a; }
|
||||||
|
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { border-color: rgb(255 255 255 / 12%); }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lovart-home-surface main { padding-top: calc(72px + var(--home-banner-offset, 0px)); }
|
||||||
|
.lovart-hero { margin-top: 16px; }
|
||||||
|
.lovart-home-composer { border-radius: 20px; }
|
||||||
|
.lovart-suggestion { padding: 0 11px; font-size: 12px; }
|
||||||
|
.lovart-start-action { min-width: 160px; }
|
||||||
|
.lovart-chat-welcome h1 { font-size: 15px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.lovart-home-surface *, .lovart-canvas-surface * { scroll-behavior: auto !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lovart-home-surface :is(button, input, textarea, select),
|
||||||
|
.lovart-canvas-surface :is(button, input, textarea, select) { font-family: inherit; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lovart-canvas-surface[data-canvas-panel-open="true"] :is([data-canvas-topbar], [data-canvas-floating-toolbar], [data-canvas-compact-controls]) { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reference icon sizing is independent of the application's root rem scale. */
|
||||||
|
.lovart-suggestion { gap: 8px; padding: 0 12px; }
|
||||||
|
.lovart-start-action { min-width: 172.71875px; gap: 8px; padding: 0 12px; }
|
||||||
|
.lovart-canvas-surface [data-canvas-floating-toolbar] button { min-width: 32px; height: 32px; border-radius: 8px; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector { width: 90px; height: 32px; padding: 0 8px; background: transparent; font-size: 14px; font-weight: 400; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector > span { font-size: 13px; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector:hover,
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params:hover,
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector:hover { background: rgb(0 0 0 / 4%); }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params { width: 105.3828125px; min-width: 105.3828125px; height: 32px; padding: 0 4px 0 12px; background: transparent; font-size: 13px; gap: 4px; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector { flex: 0 0 121px; width: 121px; height: 32px; padding: 0 4px 0 8px; gap: 0; border: 0; background: transparent; box-shadow: none; font-size: 13px; font-weight: 400; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 8px; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > span:not([data-reference-icon]) { max-width: 65px; }
|
||||||
|
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params) { color: #eee; }
|
||||||
|
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params, .lovart-composer-mode-selector):hover { background: rgb(255 255 255 / 8%); }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer { padding: 0 8px 8px; gap: 0; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:first-child { flex: 0 0 90px; padding-right: 0; overflow: visible; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:last-child { flex: 1 1 0; min-width: 0; gap: 0; }
|
||||||
|
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-send { min-width: 32px; width: auto; height: 32px; padding: 0 8px; gap: 2px; font-size: 13px; font-weight: 400; }
|
||||||
|
|
||||||
|
/* The existing image toolbar is scaled to 80%; these dimensions render at
|
||||||
|
the reference's 44px bar, 32px buttons, and 20px icons. */
|
||||||
|
.lovart-image-action-bar {
|
||||||
|
height: 55px;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 7.5px;
|
||||||
|
border: 1.25px solid rgba(0, 0, 0, 0.07);
|
||||||
|
border-radius: 17.5px;
|
||||||
|
background: #fff !important;
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 1.25px 2.5px rgba(0, 0, 0, 0.04);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.lovart-image-action-bar::before { display: none; }
|
||||||
|
.lovart-image-action-bar > button {
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #000;
|
||||||
|
font-size: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.lovart-image-action-bar > button:hover,
|
||||||
|
.lovart-image-action-bar > button[data-state="open"],
|
||||||
|
.lovart-image-action-bar > button[data-reference-active="true"] {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.lovart-image-action-bar > button:disabled {
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.lovart-image-action-bar > button > svg,
|
||||||
|
.lovart-image-action-bar > button > [data-reference-icon],
|
||||||
|
.lovart-image-action-bar > button > span[aria-hidden="true"],
|
||||||
|
.lovart-image-action-bar > button > img {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lovart-image-action-bar > button > img { filter: brightness(0); }
|
||||||
|
.lovart-image-action-bar > div.h-px,
|
||||||
|
.lovart-image-action-bar > div.w-px {
|
||||||
|
width: 1.25px;
|
||||||
|
height: 55px;
|
||||||
|
margin: 0 2.5px;
|
||||||
|
background: rgba(0, 0, 0, 0.07);
|
||||||
|
}
|
||||||
|
.lovart-image-action-menu [role="menuitem"] > svg,
|
||||||
|
.lovart-image-action-menu [role="menuitem"] > [data-reference-icon] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.dark .lovart-image-action-bar {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
background: #18191c !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.dark .lovart-image-action-bar > button { color: #fff; }
|
||||||
|
.dark .lovart-image-action-bar > button:hover,
|
||||||
|
.dark .lovart-image-action-bar > button[data-state="open"],
|
||||||
|
.dark .lovart-image-action-bar > button[data-reference-active="true"] {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.dark .lovart-image-action-bar > button:disabled { color: rgba(255, 255, 255, 0.3); }
|
||||||
|
.dark .lovart-image-action-bar > button > img { filter: brightness(0) invert(1); }
|
||||||
|
.dark .lovart-image-action-bar > div.w-px { background: rgba(255, 255, 255, 0.1); }
|
||||||
|
|
||||||
|
.lovart-image-action-bar[data-show-tool-names="true"] > button[data-tool-key] { width: auto; gap: 7.5px; padding: 0 12.5px; font-size: 16.25px; }
|
||||||
|
.lovart-image-tool-label { white-space: nowrap; }
|
||||||
|
.lovart-image-tool-tooltip { z-index: 10030; border: 0; border-radius: 6px; background: #1b1b1b; padding: 6px 9px; color: #fff; font-size: 13px; line-height: 18px; box-shadow: 0 4px 10px #00000012; --tw-ring-shadow: 0 0 transparent; }
|
||||||
|
.lovart-image-custom-more { min-width: 172px; padding: 6px 12px; border: 1px solid #e8e8e8; border-radius: 12px; background: #fff; }
|
||||||
|
.lovart-image-custom-more [role="menuitem"] { min-height: 32px; gap: 10px; padding: 4px 0; font-size: 14px; font-weight: 400; }
|
||||||
|
.lovart-image-custom-more .lovart-customize-entry { justify-content: center; gap: 5px; }
|
||||||
|
.lovart-toolbar-customizer-overlay { z-index: 10010; background: #0006; }
|
||||||
|
.lovart-toolbar-customizer { z-index: 10020; display: flex; flex-direction: column; width: min(720px, calc(100vw - 32px)); max-width: 720px; max-height: calc(100dvh - 48px); gap: 0; padding: 20px 24px 24px; border: 1px solid #e5e5e5; border-radius: 16px; background: #fff; color: #242424; box-shadow: 0 8px 24px #00000012; }
|
||||||
|
.lovart-customizer-header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||||
|
.lovart-customizer-header h2 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 600; }
|
||||||
|
.lovart-customizer-header p { margin-top: 2px; color: inherit; font-size: 14px; line-height: 22px; }
|
||||||
|
.lovart-customizer-header-actions { display: flex; gap: 4px; }
|
||||||
|
.lovart-customizer-header-actions button { display: flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 7px; }
|
||||||
|
.lovart-customizer-header-actions button:last-child { background: #f1f1f1; }
|
||||||
|
.lovart-customizer-header-actions button:hover { background: #e9e9e9; }
|
||||||
|
.lovart-customizer-body { min-height: 0; overflow-y: auto; margin: 24px 0 20px; }
|
||||||
|
.lovart-customizer-preview { display: flex; height: 180px; align-items: center; overflow: hidden; padding: 16px; border-radius: 10px; background: #e5edef url("/icons/lovart/toolbar-preview.png") center / cover; }
|
||||||
|
.lovart-customizer-preview-bar { display: flex; align-items: center; flex: 1; min-width: 0; gap: 4px; padding: 5px 8px; border: 1px solid #e5e5e5; border-radius: 14px; background: #fff; overflow-x: auto; scrollbar-width: thin; }
|
||||||
|
.lovart-preview-fixed,.lovart-preview-chip { display: flex; align-items: center; flex-shrink: 0; height: 32px; gap: 8px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.lovart-preview-chip { position: relative; margin-top: 5px; margin-bottom: 5px; border-radius: 8px; background: #f4f4f4; cursor: grab; }
|
||||||
|
.lovart-preview-chip:active { cursor: grabbing; }
|
||||||
|
.lovart-preview-chip > button:last-child { position: absolute; top: -5px; right: -3px; display: flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e6e6e6; border-radius: 50%; background: #fff; color: #888; }
|
||||||
|
.lovart-customizer-tools { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; margin-top: 24px; }
|
||||||
|
.lovart-customizer-tools > button { display: flex; align-items: center; gap: 10px; height: 36px; min-width: 0; padding: 0 12px; border: 1px solid #e2e2e2; border-radius: 8px; background: #fff; font-size: 14px; text-align: left; }
|
||||||
|
.lovart-customizer-tools > button > span:not([aria-hidden]) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lovart-customizer-tools > button > svg:last-child { flex-shrink: 0; }
|
||||||
|
.lovart-customizer-tools > button:hover { background: #f7f7f7; }
|
||||||
|
.lovart-customizer-footer { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
.lovart-customizer-footer > label,.lovart-customizer-footer > div { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||||
|
.lovart-customizer-switch { width: 32px; height: 18px; padding: 2px; border-radius: 20px; background: #d5d5d5; }
|
||||||
|
.lovart-customizer-switch > span { display: block; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
||||||
|
.lovart-customizer-switch[aria-checked="true"] { background: #2c2c2c; }
|
||||||
|
.lovart-customizer-switch[aria-checked="true"] > span { transform: translateX(14px); }
|
||||||
|
.lovart-customizer-footer > div > button { width: 80px; height: 36px; border: 1px solid #e3e3e3; border-radius: 8px; font-size: 14px; }
|
||||||
|
.lovart-customizer-footer .lovart-customizer-save { border-color: #2c2c2c; background: #2c2c2c; color: #fff; }
|
||||||
|
.lovart-customizer-footer > div > button:hover { opacity: .8; }
|
||||||
|
.lovart-toolbar-customizer button:focus-visible,.lovart-preview-chip:focus-visible { outline: 2px solid #397dff; outline-offset: 2px; }
|
||||||
|
.dark .lovart-toolbar-customizer,.dark .lovart-image-custom-more { background: #202124; border-color: #3d3e40; color: #f5f5f5; }
|
||||||
|
.dark .lovart-customizer-preview-bar,.dark .lovart-customizer-tools > button { background: #26272a; border-color: #46474a; }
|
||||||
|
.dark .lovart-preview-chip,.dark .lovart-customizer-header-actions button:last-child { background: #35363a; }
|
||||||
|
.dark .lovart-preview-chip > button:last-child { background: #26272a; border-color: #46474a; color: #ccc; }
|
||||||
|
.dark .lovart-customizer-tools > button:hover { background: #35363a; }
|
||||||
|
@media(max-width:600px) { .lovart-toolbar-customizer { padding: 18px; } .lovart-customizer-tools { grid-template-columns: repeat(2,minmax(0,1fr)); } .lovart-customizer-preview { height: 140px; } }
|
||||||
|
|
||||||
|
.lovart-preview-handle { display: flex; align-items: center; gap: 8px; white-space: nowrap; cursor: grab; }
|
||||||
|
|
||||||
|
.lovart-image-tool-tooltip { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.lovart-image-tool-cost { margin-left: auto; padding: 0; background: transparent; color: #999; font-size: 12px; font-weight: 400; box-shadow: none; }
|
||||||
|
.lovart-image-tool-cost > svg { width: 12px; height: 12px; }
|
||||||
|
|
||||||
|
/* Preserve the canvas's own skills as the primary welcome content. */
|
||||||
|
.canvas-welcome-action {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 36px;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
transition: background-color 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
.canvas-welcome-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
|
||||||
|
.canvas-welcome-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
|
||||||
|
.dark .canvas-welcome-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
|
||||||
|
.dark .canvas-welcome-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
|
||||||
|
|||||||
83
app/tvstar/plugin/page.tsx
Normal file
83
app/tvstar/plugin/page.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type KeyItem = { id: string; name: string; tokenPrefix: string; scopes: string[]; revokedAt: string | null; expiresAt: string | null };
|
||||||
|
|
||||||
|
export default function TVstarPluginPage() {
|
||||||
|
const [keys, setKeys] = useState<KeyItem[]>([]);
|
||||||
|
const [createdToken, setCreatedToken] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [origin, setOrigin] = useState("https://ai.yydd.ai");
|
||||||
|
useEffect(() => { setOrigin(window.location.origin); }, []);
|
||||||
|
const loadKeys = useCallback(async () => {
|
||||||
|
const response = await fetch("/api/user/access-keys", { cache: "no-store" });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) throw new Error(data.error || "读取密钥失败");
|
||||||
|
setKeys(Array.isArray(data.data) ? data.data : []);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { void loadKeys().catch(error => toast.error(error.message)); }, [loadKeys]);
|
||||||
|
|
||||||
|
const createKey = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/user/access-keys", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "TVstar MCP", scopes: ["canvas:read", "canvas:write"], expiresInDays: 90 }) });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success || !data.data?.token) throw new Error(data.error || "创建密钥失败");
|
||||||
|
setCreatedToken(data.data.token);
|
||||||
|
await loadKeys();
|
||||||
|
toast.success("密钥已创建;请立即复制,关闭后不会再次显示");
|
||||||
|
} catch (error) { toast.error(error instanceof Error ? error.message : "创建密钥失败"); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
const revokeKey = async (id: string) => {
|
||||||
|
if (!window.confirm("撤销后使用此密钥的 Agent 将失去画布访问权限,确定继续吗?")) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/user/access-keys/${id}`, { method: "DELETE" });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) throw new Error(data.error || "撤销失败");
|
||||||
|
setCreatedToken("");
|
||||||
|
await loadKeys();
|
||||||
|
toast.success("密钥已撤销");
|
||||||
|
} catch (error) { toast.error(error instanceof Error ? error.message : "撤销失败"); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const example = JSON.stringify({ mcpServers: { tvstar: { command: "node", args: ["/替换为解压目录/tvstar-mcp/mcp-server.mjs"],
|
||||||
|
env: { YEDIR_BASE_URL: origin, YEDIR_ACCESS_TOKEN: "粘贴新创建的 Access Key" } } } }, null, 2);
|
||||||
|
|
||||||
|
return <main className="min-h-screen bg-[#151515] px-4 py-10 text-white">
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<p className="text-sm text-cyan-300">TVstar Plugin</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold">让 Agent 操作视频画布</h1>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-white/60">当前插件提供画布列表、读取和有版本保护的节点操作。视频生成与合成仍由 TVstar 画布内的任务系统执行,MCP 暂未提供直接发起生成的工具。</p>
|
||||||
|
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
|
||||||
|
<h2 className="font-medium">1. 下载并安装</h2>
|
||||||
|
<p className="mt-2 text-sm text-white/50">需要 Node.js 20 或更新版本。解压后在 tvstar-mcp 目录运行:</p>
|
||||||
|
<a className="mt-4 inline-block rounded-lg bg-white px-4 py-2 text-sm font-medium text-black" href="/api/tvstar/plugin/download">下载 TVstar MCP 安装包</a>
|
||||||
|
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-3 text-xs">cd tvstar-mcp{"\n"}npm ci</pre>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
|
||||||
|
<h2 className="font-medium">2. 创建画布 Access Key</h2>
|
||||||
|
<p className="mt-2 text-sm text-white/50">读取和修改画布需要对应权限。密钥只在创建时显示一次。</p>
|
||||||
|
<button className="mt-4 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black disabled:opacity-50" disabled={busy} onClick={() => void createKey()}>{busy ? "处理中…" : "创建 Access Key"}</button>
|
||||||
|
{createdToken ? <div className="mt-3 break-all rounded-lg border border-amber-300/30 bg-amber-300/5 p-3 text-xs text-amber-100"><p>{createdToken}</p><button className="mt-2 underline" onClick={() => void navigator.clipboard.writeText(createdToken).then(() => toast.success("已复制"))}>复制密钥</button></div> : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
|
||||||
|
<h2 className="font-medium">3. 配置支持 STDIO MCP 的 Agent</h2>
|
||||||
|
<p className="mt-2 text-sm text-white/50">将安装包的绝对路径和密钥填入客户端配置。不同 Agent 的配置文件位置和外层字段可能不同,请以所用客户端的说明为准。</p>
|
||||||
|
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-4 text-xs leading-6 text-cyan-100">{example}</pre>
|
||||||
|
<button className="mt-3 text-sm text-cyan-300 underline" onClick={() => void navigator.clipboard.writeText(example).then(() => toast.success("配置示例已复制"))}>复制配置示例</button>
|
||||||
|
</section>
|
||||||
|
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
|
||||||
|
<h2 className="font-medium">已创建的 Access Key</h2>
|
||||||
|
<div className="mt-3 space-y-2">{keys.length ? keys.map(key => <div className="flex items-center justify-between gap-4 rounded-lg bg-black/20 p-3 text-sm" key={key.id}><span className="min-w-0 truncate">{key.name} · {key.tokenPrefix} · {key.revokedAt ? "已撤销" : "有效"}</span>{!key.revokedAt ? <button className="shrink-0 text-rose-300 disabled:opacity-40" disabled={busy} onClick={() => void revokeKey(key.id)}>撤销</button> : null}</div>) : <p className="text-sm text-white/40">暂无密钥</p>}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
41
app/tvstar/share/[token]/page.tsx
Normal file
41
app/tvstar/share/[token]/page.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { tvstarSession } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type SharedSnapshot = {
|
||||||
|
title: string;
|
||||||
|
goal: string;
|
||||||
|
createdAt: number;
|
||||||
|
messages: { role: "user" | "assistant"; content: string; createdAt: number }[];
|
||||||
|
plan: { summary: string; deliverables: string[] } | null;
|
||||||
|
shots: { shot: string; duration: string; sceneDescription: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TVstarSharedConversation({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await params;
|
||||||
|
if (!/^[A-Za-z0-9_-]{32}$/.test(token || "")) notFound();
|
||||||
|
const row = (await db.select({ snapshot: tvstarSession.sharedSnapshot }).from(tvstarSession)
|
||||||
|
.where(eq(tvstarSession.shareToken, token)).limit(1))[0];
|
||||||
|
if (!row?.snapshot) notFound();
|
||||||
|
const shared = row.snapshot as SharedSnapshot;
|
||||||
|
return <main className="min-h-screen bg-[#141414] px-4 py-12 text-white">
|
||||||
|
<article className="mx-auto max-w-2xl rounded-2xl border border-white/10 bg-[#1d1d1d] p-6 shadow-2xl">
|
||||||
|
<p className="text-xs tracking-widest text-cyan-300">TVstar · 只读分享</p>
|
||||||
|
<h1 className="mt-3 text-2xl font-semibold">{shared.title}</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/50">{new Date(shared.createdAt).toLocaleString("zh-CN")}</p>
|
||||||
|
<p className="mt-6 whitespace-pre-wrap border-l-2 border-cyan-400 pl-4 text-sm text-white/70">{shared.goal}</p>
|
||||||
|
<section aria-label="分享的对话" className="mt-8 space-y-3">
|
||||||
|
{(shared.messages || []).map((message, index) => <div className={`rounded-xl p-4 text-sm leading-6 ${message.role === "user" ? "ml-8 bg-[#303846]" : "mr-8 bg-[#282828]"}`} key={`${message.createdAt}-${index}`}>
|
||||||
|
<p className="mb-1 text-xs text-white/40">{message.role === "user" ? "用户" : "TVstar"}</p>
|
||||||
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||||
|
</div>)}
|
||||||
|
</section>
|
||||||
|
{shared.plan ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">创作规划</h2><p className="mt-2 whitespace-pre-wrap text-sm text-white/70">{shared.plan.summary}</p></section> : null}
|
||||||
|
{shared.shots?.length ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">分镜</h2><ol className="mt-3 space-y-3">{shared.shots.map((shot, index) => <li className="rounded-lg bg-white/5 p-3 text-sm" key={`${shot.shot}-${index}`}><strong>{shot.shot} · {shot.duration}</strong><p className="mt-1 whitespace-pre-wrap text-white/65">{shot.sceneDescription}</p></li>)}</ol></section> : null}
|
||||||
|
<p className="mt-8 border-t border-white/10 pt-5 text-xs text-white/40">此页面是分享时的对话快照,无法编辑画布;对话正文中的文字和链接会原样显示。</p>
|
||||||
|
</article>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
315
components/agents/AgentAiTemplateDialog.tsx
Normal file
315
components/agents/AgentAiTemplateDialog.tsx
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { LayoutTemplate, Loader2, Upload } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2500;
|
||||||
|
const POLL_LIMIT = 240;
|
||||||
|
|
||||||
|
export type AgentAiTemplateResult = {
|
||||||
|
imageUrl: string;
|
||||||
|
sourceImageUrl: string;
|
||||||
|
referenceImageUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentAiTemplateDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
sourceImageUrl?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
imageSize?: string | null;
|
||||||
|
projectId: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onProcessingChange?: (processing: boolean, sourceImageUrl: string) => void;
|
||||||
|
onResultCreated: (result: AgentAiTemplateResult) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isHttpImageUrl(value?: string | null) {
|
||||||
|
return /^https?:\/\//i.test(String(value || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAiTemplateTask(taskId: string) {
|
||||||
|
for (let attempt = 0; attempt < POLL_LIMIT; attempt += 1) {
|
||||||
|
await sleep(POLL_INTERVAL_MS);
|
||||||
|
const taskResult = await imageGenerationService.getImageTask(taskId);
|
||||||
|
const task = taskResult.data?.task;
|
||||||
|
if (!taskResult.success || !task) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = String(task.status || "").toLowerCase();
|
||||||
|
if (status === "succeeded" || status === "completed") {
|
||||||
|
const image = task.result?.images?.[0];
|
||||||
|
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
|
||||||
|
if (!isHttpImageUrl(imageUrl)) {
|
||||||
|
throw new Error("AI套模版已完成但未返回结果图片");
|
||||||
|
}
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||||||
|
throw new Error(task.error || "AI套模版生成失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("AI套模版任务等待超时,请稍后在结果区查看");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentAiTemplateDialog({
|
||||||
|
open,
|
||||||
|
sourceImageUrl,
|
||||||
|
model,
|
||||||
|
imageSize,
|
||||||
|
projectId,
|
||||||
|
onOpenChange,
|
||||||
|
onProcessingChange,
|
||||||
|
onResultCreated,
|
||||||
|
}: AgentAiTemplateDialogProps) {
|
||||||
|
const referenceUploadId = useId();
|
||||||
|
const [referenceImageUrl, setReferenceImageUrl] = useState("");
|
||||||
|
const [supplementalPrompt, setSupplementalPrompt] = useState("");
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const normalizedSourceUrl = String(sourceImageUrl || "").trim();
|
||||||
|
const onProcessingChangeRef = useRef(onProcessingChange);
|
||||||
|
const onResultCreatedRef = useRef(onResultCreated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onProcessingChangeRef.current = onProcessingChange;
|
||||||
|
onResultCreatedRef.current = onResultCreated;
|
||||||
|
}, [onProcessingChange, onResultCreated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || isSubmitting) return;
|
||||||
|
setReferenceImageUrl("");
|
||||||
|
setSupplementalPrompt("");
|
||||||
|
setIsUploading(false);
|
||||||
|
}, [isSubmitting, open, normalizedSourceUrl]);
|
||||||
|
|
||||||
|
const handleReferenceUpload = async (file?: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
toast.error("请上传图片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
const uploadedUrl = String(payload?.url || "").trim();
|
||||||
|
if (!response.ok || !isHttpImageUrl(uploadedUrl)) {
|
||||||
|
throw new Error(payload?.error || "参考图上传失败");
|
||||||
|
}
|
||||||
|
setReferenceImageUrl(uploadedUrl);
|
||||||
|
toast.success("版式参考图已上传");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "参考图上传失败");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const normalizedReferenceUrl = referenceImageUrl.trim();
|
||||||
|
if (!isHttpImageUrl(normalizedSourceUrl)) {
|
||||||
|
toast.error("当前主图结果地址不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isHttpImageUrl(normalizedReferenceUrl)) {
|
||||||
|
toast.error("请先上传或填写版式参考图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
onProcessingChangeRef.current?.(true, normalizedSourceUrl);
|
||||||
|
try {
|
||||||
|
const submitResult = await imageGenerationService.layoutReplace({
|
||||||
|
sourceImageUrl: normalizedSourceUrl,
|
||||||
|
referenceImageUrl: normalizedReferenceUrl,
|
||||||
|
supplementalPrompt: supplementalPrompt.trim() || undefined,
|
||||||
|
model: model && model !== "auto" ? model : undefined,
|
||||||
|
imageSize: imageSize || "2K",
|
||||||
|
agentProjectId: projectId,
|
||||||
|
referenceLabel: "风格化主图 AI 套模版参考图",
|
||||||
|
debugMeta: {
|
||||||
|
operation: "agent-main-ai-template",
|
||||||
|
source: "agent-main-result-card",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const taskId = String(submitResult.data?.taskId || "").trim();
|
||||||
|
if (!submitResult.success || !taskId) {
|
||||||
|
throw new Error(submitResult.error || "AI套模版提交失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success("AI套模版已进入生图队列");
|
||||||
|
const resultImageUrl = await pollAiTemplateTask(taskId);
|
||||||
|
await onResultCreatedRef.current({
|
||||||
|
imageUrl: resultImageUrl,
|
||||||
|
sourceImageUrl: normalizedSourceUrl,
|
||||||
|
referenceImageUrl: normalizedReferenceUrl,
|
||||||
|
});
|
||||||
|
toast.success("AI套模版结果已加入主图结果区");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "AI套模版生成失败");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
onProcessingChangeRef.current?.(false, normalizedSourceUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!isUploading) {
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[780px] flex-col gap-0 overflow-hidden border-white/10 bg-[#09090b] p-0 text-white sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
|
||||||
|
<DialogHeader className="shrink-0 border-b border-white/10 px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
|
AI套模版
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-white/55">
|
||||||
|
保留参考图的版式、文字和图标,仅将产品主体替换为当前主图。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Label>当前主图</Label>
|
||||||
|
<div className="flex h-[clamp(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-white/10 bg-zinc-900">
|
||||||
|
{isHttpImageUrl(normalizedSourceUrl) ? (
|
||||||
|
<AgentPreviewImage
|
||||||
|
alt="当前主图结果"
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
eager
|
||||||
|
previewQuality={86}
|
||||||
|
previewWidth={720}
|
||||||
|
src={normalizedSourceUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Label htmlFor={referenceUploadId}>版式参考图</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="min-w-0 border-white/10 bg-white/[0.04]"
|
||||||
|
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
|
||||||
|
placeholder="填写图片链接或上传"
|
||||||
|
value={referenceImageUrl}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="shrink-0 border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<label htmlFor={referenceUploadId}>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
上传
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={isUploading || isSubmitting}
|
||||||
|
id={referenceUploadId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
void handleReferenceUpload(file);
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-[clamp(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-dashed border-white/10 bg-zinc-900">
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-white/45" />
|
||||||
|
) : isHttpImageUrl(referenceImageUrl) ? (
|
||||||
|
<AgentPreviewImage
|
||||||
|
alt="AI套模版参考图"
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
eager
|
||||||
|
previewQuality={86}
|
||||||
|
previewWidth={720}
|
||||||
|
src={referenceImageUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LayoutTemplate className="h-7 w-7 text-white/20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Label htmlFor={`${referenceUploadId}-prompt`}>补充要求</Label>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[84px] max-h-[180px] border-white/10 bg-white/[0.04]"
|
||||||
|
id={`${referenceUploadId}-prompt`}
|
||||||
|
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
|
||||||
|
placeholder="可选:补充产品材质、颜色或替换细节"
|
||||||
|
value={supplementalPrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 gap-2 border-t border-white/10 bg-black/25 px-4 py-3 sm:px-6">
|
||||||
|
<Button
|
||||||
|
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.10] hover:text-white"
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting || isUploading || !isHttpImageUrl(referenceImageUrl)}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isSubmitting ? "提交中" : "开始AI套模版"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
|
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
|
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
@ -26,6 +28,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
|
LayoutTemplate,
|
||||||
Loader2,
|
Loader2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@ -37,6 +40,10 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
|
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||||
|
} from "@/lib/image-upload-limits";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
|
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
|
||||||
import {
|
import {
|
||||||
@ -64,6 +71,10 @@ import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-p
|
|||||||
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
||||||
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
|
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
|
||||||
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
|
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
|
||||||
|
import {
|
||||||
|
AgentAiTemplateDialog,
|
||||||
|
type AgentAiTemplateResult,
|
||||||
|
} from "@/components/agents/AgentAiTemplateDialog";
|
||||||
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
|
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
|
||||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||||
import {
|
import {
|
||||||
@ -300,7 +311,7 @@ const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
|
|||||||
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
|
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
|
||||||
const MAX_AGENT_MODE_QUERY_LENGTH = 64;
|
const MAX_AGENT_MODE_QUERY_LENGTH = 64;
|
||||||
const MAX_AGENT_MODE_REFERENCE_COUNT = 40;
|
const MAX_AGENT_MODE_REFERENCE_COUNT = 40;
|
||||||
const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
|
const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
|
||||||
const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4;
|
const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4;
|
||||||
const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025;
|
const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025;
|
||||||
const DETAIL_SCALE_TARGET_MIN_PERCENT = -80;
|
const DETAIL_SCALE_TARGET_MIN_PERCENT = -80;
|
||||||
@ -579,33 +590,7 @@ function areAttachmentValuesEqual(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergePersistedHistoryMessages(
|
const mergePersistedHistoryMessages = mergeGenerationHistory;
|
||||||
currentMessages: ChatMessage[],
|
|
||||||
persistedMessages: ChatMessage[]
|
|
||||||
) {
|
|
||||||
if (persistedMessages.length === 0) {
|
|
||||||
return currentMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentMessages.length === 0) {
|
|
||||||
return persistedMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistedIds = new Set(persistedMessages.map((message) => message.id));
|
|
||||||
const extraLocalMessages = currentMessages.filter(
|
|
||||||
(message) => !persistedIds.has(message.id)
|
|
||||||
);
|
|
||||||
const mergedMessages = [...persistedMessages, ...extraLocalMessages];
|
|
||||||
|
|
||||||
if (
|
|
||||||
mergedMessages.length === currentMessages.length &&
|
|
||||||
mergedMessages.every((message, index) => currentMessages[index]?.id === message.id)
|
|
||||||
) {
|
|
||||||
return currentMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergedMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAttachmentIdentityKey(
|
function getAttachmentIdentityKey(
|
||||||
attachment:
|
attachment:
|
||||||
@ -662,9 +647,10 @@ const AGENT_DEFERRED_SECTION_STYLE = {
|
|||||||
containIntrinsicSize: "1px 760px",
|
containIntrinsicSize: "1px 760px",
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
||||||
const AGENT_DEFERRED_RESULTS_STYLE = {
|
// Dynamic result content must stay painted while scrolling. Deferring this subtree
|
||||||
contentVisibility: "auto",
|
// makes Chromium briefly discard image and animation layers at viewport boundaries.
|
||||||
containIntrinsicSize: "1px 900px",
|
const AGENT_STABLE_RESULTS_STYLE = {
|
||||||
|
contentVisibility: "visible",
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
||||||
const AGENT_DEFERRED_LIST_CARD_STYLE = {
|
const AGENT_DEFERRED_LIST_CARD_STYLE = {
|
||||||
@ -672,6 +658,10 @@ const AGENT_DEFERRED_LIST_CARD_STYLE = {
|
|||||||
containIntrinsicSize: "1px 320px",
|
containIntrinsicSize: "1px 320px",
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
||||||
|
const AGENT_STABLE_RESULT_CARD_STYLE = {
|
||||||
|
contentVisibility: "visible",
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
function handlePreviewImageLoadError(
|
function handlePreviewImageLoadError(
|
||||||
event: SyntheticEvent<HTMLImageElement, Event>,
|
event: SyntheticEvent<HTMLImageElement, Event>,
|
||||||
originalUrl: string
|
originalUrl: string
|
||||||
@ -709,7 +699,7 @@ function normalizeAssistantText(value: string) {
|
|||||||
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
|
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
|
||||||
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
|
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
|
||||||
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
|
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
|
||||||
.replace(/\n*<!-- AGENT_REPLACEMENT_CARD_ID:[^>]*-->\n*/g, "")
|
.replace(/\n*<!-- AGENT_REPLACEMENT_(?:CARD_ID|RUN_ID|STATUS):[^>]*-->\n*/g, "")
|
||||||
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
|
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@ -832,7 +822,7 @@ async function uploadDetailLocalEditReferenceImage(
|
|||||||
throw new Error("请上传图片文件");
|
throw new Error("请上传图片文件");
|
||||||
}
|
}
|
||||||
if (file.size > DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES) {
|
if (file.size > DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES) {
|
||||||
throw new Error("参考图过大,请先压缩到 30MB 以内");
|
throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -2574,10 +2564,24 @@ interface LocalGenerationPendingState {
|
|||||||
modeType: AgentGenerationResultModeType;
|
modeType: AgentGenerationResultModeType;
|
||||||
expectedCount: number;
|
expectedCount: number;
|
||||||
userTurnCountAtStart: number;
|
userTurnCountAtStart: number;
|
||||||
|
requestMessageId?: string | null;
|
||||||
|
backendTaskId?: string | null;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
requestedCardIds?: string[] | null;
|
requestedCardIds?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DetailReplacementBatchTaskView = {
|
||||||
|
id: string;
|
||||||
|
status: "pending" | "running" | "succeeded" | "failed";
|
||||||
|
error: string | null;
|
||||||
|
attempts: number;
|
||||||
|
cards: DetailReplacementBatchCard[];
|
||||||
|
currentCardId: string | null;
|
||||||
|
requestedCardIds: string[];
|
||||||
|
createdAt: string;
|
||||||
|
queueStatus?: { queuePosition?: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
||||||
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
||||||
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
||||||
@ -2628,6 +2632,8 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
|||||||
modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType,
|
modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType,
|
||||||
expectedCount: Number(record.expectedCount),
|
expectedCount: Number(record.expectedCount),
|
||||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||||
|
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
||||||
|
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
|
||||||
timestamp: Number(record.timestamp),
|
timestamp: Number(record.timestamp),
|
||||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||||
? record.requestedCardIds
|
? record.requestedCardIds
|
||||||
@ -2660,16 +2666,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETAIL_RESULT_PROCESSING_STAGES = [
|
|
||||||
"解码图像",
|
|
||||||
"定位选区",
|
|
||||||
"分离主体",
|
|
||||||
"重绘背景",
|
|
||||||
"融合边缘",
|
|
||||||
"检查细节",
|
|
||||||
"输出结果",
|
|
||||||
];
|
|
||||||
|
|
||||||
function DetailResultProcessingScanOverlay({
|
function DetailResultProcessingScanOverlay({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
label,
|
label,
|
||||||
@ -2677,55 +2673,43 @@ function DetailResultProcessingScanOverlay({
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
const [progress, setProgress] = useState(6);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
const durationMs = 240_000;
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
const elapsed = Date.now() - startedAt;
|
|
||||||
const linear = Math.min(1, elapsed / durationMs);
|
|
||||||
const eased = 1 - Math.pow(1 - linear, 1.8);
|
|
||||||
const drift = Math.sin(elapsed / 3200) * 1.2;
|
|
||||||
const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift)));
|
|
||||||
setProgress((current) => Math.max(current, next));
|
|
||||||
}, 720);
|
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [imageUrl, label]);
|
|
||||||
|
|
||||||
const stageIndex = Math.min(
|
|
||||||
DETAIL_RESULT_PROCESSING_STAGES.length - 1,
|
|
||||||
Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length)
|
|
||||||
);
|
|
||||||
const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes detailResultScanBreatheBlur {
|
@keyframes detailResultScanBreathe {
|
||||||
0%, 100% { filter: blur(14px) brightness(0.8) saturate(0.9); transform: scale(1.08); }
|
0%, 100% { opacity: 0.68; transform: translate3d(0, 0, 0) scale(1.06); }
|
||||||
50% { filter: blur(17px) brightness(0.9) saturate(0.98); transform: scale(1.1); }
|
50% { opacity: 0.8; transform: translate3d(0, 0, 0) scale(1.075); }
|
||||||
}
|
}
|
||||||
@keyframes detailResultScanSweep {
|
@keyframes detailResultScanSweep {
|
||||||
0% { top: -7%; opacity: 0; }
|
0% { opacity: 0; transform: translate3d(0, -100%, 0); }
|
||||||
8% { opacity: 1; }
|
8% { opacity: 1; }
|
||||||
92% { opacity: 1; }
|
92% { opacity: 1; }
|
||||||
100% { top: 107%; opacity: 0; }
|
100% { opacity: 0; transform: translate3d(0, 0, 0); }
|
||||||
}
|
}
|
||||||
@keyframes detailResultScanPulseRing {
|
@keyframes detailResultScanPulseRing {
|
||||||
0% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.48); }
|
0% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.48); }
|
||||||
70% { box-shadow: 0 0 0 8px rgba(125, 211, 252, 0); }
|
70% { box-shadow: 0 0 0 8px rgba(125, 211, 252, 0); }
|
||||||
100% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0); }
|
100% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0); }
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-detail-result-scan-animation] { animation: none !important; }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{isHttpImageUrl(imageUrl) ? (
|
{isHttpImageUrl(imageUrl) ? (
|
||||||
<img
|
<AgentPreviewImage
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
data-detail-result-scan-animation
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
style={{ animation: "detailResultScanBreatheBlur 6s ease-in-out infinite" }}
|
previewWidth={720}
|
||||||
|
previewQuality={62}
|
||||||
|
rootMargin="320px 0px"
|
||||||
|
style={{
|
||||||
|
animation: "detailResultScanBreathe 6s ease-in-out infinite",
|
||||||
|
filter: "blur(12px) brightness(0.84) saturate(0.92)",
|
||||||
|
willChange: "opacity, transform",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@ -2747,8 +2731,12 @@ function DetailResultProcessingScanOverlay({
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-x-0 top-0 z-40 h-px pointer-events-none"
|
data-detail-result-scan-animation
|
||||||
style={{ animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite" }}
|
className="pointer-events-none absolute inset-0 z-40"
|
||||||
|
style={{
|
||||||
|
animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite",
|
||||||
|
willChange: "opacity, transform",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 bottom-0 h-[110px]"
|
className="absolute inset-x-0 bottom-0 h-[110px]"
|
||||||
@ -2759,7 +2747,7 @@ function DetailResultProcessingScanOverlay({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 top-0 h-[38px]"
|
className="absolute inset-x-0 -bottom-[38px] h-[38px]"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(0deg, transparent, rgba(255,255,255,0.1))",
|
"linear-gradient(0deg, transparent, rgba(255,255,255,0.1))",
|
||||||
@ -2769,15 +2757,23 @@ function DetailResultProcessingScanOverlay({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-x-0 top-0 z-50 h-px pointer-events-none"
|
data-detail-result-scan-animation
|
||||||
|
className="pointer-events-none absolute inset-0 z-50"
|
||||||
style={{
|
style={{
|
||||||
animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite",
|
animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite",
|
||||||
background:
|
willChange: "opacity, transform",
|
||||||
"linear-gradient(90deg, transparent, rgba(167,139,250,0.7) 18%, rgba(255,255,255,0.98) 50%, rgba(125,211,252,0.7) 82%, transparent)",
|
|
||||||
boxShadow:
|
|
||||||
"0 0 6px rgba(255,255,255,0.7), 0 0 18px rgba(125,211,252,0.55), 0 0 34px rgba(167,139,250,0.35)",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 h-px"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent, rgba(167,139,250,0.7) 18%, rgba(255,255,255,0.98) 50%, rgba(125,211,252,0.7) 82%, transparent)",
|
||||||
|
boxShadow:
|
||||||
|
"0 0 6px rgba(255,255,255,0.7), 0 0 18px rgba(125,211,252,0.55), 0 0 34px rgba(167,139,250,0.35)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="absolute right-3 top-3 z-[70] isolate inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white shadow-[0_12px_28px_rgba(0,0,0,0.34)]"
|
className="absolute right-3 top-3 z-[70] isolate inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white shadow-[0_12px_28px_rgba(0,0,0,0.34)]"
|
||||||
style={{
|
style={{
|
||||||
@ -2790,26 +2786,6 @@ function DetailResultProcessingScanOverlay({
|
|||||||
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span className="min-w-[34px] text-right tabular-nums text-slate-300">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="absolute bottom-3 left-3 z-[70] isolate inline-flex items-center gap-2 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.32)]"
|
|
||||||
style={{
|
|
||||||
WebkitFontSmoothing: "antialiased",
|
|
||||||
textShadow: "0 1px 1px rgba(0,0,0,0.72)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
|
|
||||||
<span>{stageName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
|
|
||||||
style={{
|
|
||||||
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
|
|
||||||
width: `${progress}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -3152,6 +3128,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
||||||
const detailGenerationCancelRef = useRef(false);
|
const detailGenerationCancelRef = useRef(false);
|
||||||
const workspaceStateSignatureRef = useRef<string>("");
|
const workspaceStateSignatureRef = useRef<string>("");
|
||||||
|
const detailBatchSubmittingRef = useRef(false);
|
||||||
|
const reportedDetailBatchFailureRef = useRef<string | null>(null);
|
||||||
const generationRequestSnapshotRef = useRef<{
|
const generationRequestSnapshotRef = useRef<{
|
||||||
hiddenPrompt: string | null;
|
hiddenPrompt: string | null;
|
||||||
replacementExecutionCardIds: string[] | null;
|
replacementExecutionCardIds: string[] | null;
|
||||||
@ -3236,6 +3214,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
|
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
|
||||||
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
|
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
|
||||||
useState<AgentGenerationResultModeType>("scene_generation");
|
useState<AgentGenerationResultModeType>("scene_generation");
|
||||||
|
const [aiTemplateDialogSourceUrl, setAiTemplateDialogSourceUrl] =
|
||||||
|
useState<string | null>(null);
|
||||||
|
const [aiTemplateProcessingSourceUrl, setAiTemplateProcessingSourceUrl] =
|
||||||
|
useState<string | null>(null);
|
||||||
const [batchDownloadState, setBatchDownloadState] =
|
const [batchDownloadState, setBatchDownloadState] =
|
||||||
useState<null | BatchDownloadScope>(null);
|
useState<null | BatchDownloadScope>(null);
|
||||||
const [batchDownloadDialog, setBatchDownloadDialog] =
|
const [batchDownloadDialog, setBatchDownloadDialog] =
|
||||||
@ -3388,7 +3370,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}
|
}
|
||||||
}, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]);
|
}, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]);
|
||||||
|
|
||||||
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
|
const { data: historyData, mutate: refreshHistory } = useSWR<{ messages: ChatMessage[] }>(
|
||||||
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
|
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
|
||||||
fetcher,
|
fetcher,
|
||||||
{
|
{
|
||||||
@ -3482,6 +3464,75 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||||
const isReplacementPreset =
|
const isReplacementPreset =
|
||||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
||||||
|
const { data: detailBatchData, mutate: refreshDetailBatch } = useSWR<{
|
||||||
|
task: DetailReplacementBatchTaskView | null;
|
||||||
|
}>(
|
||||||
|
projectId && currentResultModeType === "detail_replacement"
|
||||||
|
? `/api/agents/detail-replacement/batches?projectId=${encodeURIComponent(projectId)}`
|
||||||
|
: null,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 3000, revalidateOnFocus: true },
|
||||||
|
);
|
||||||
|
const detailBatchTask = detailBatchData?.task || null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailBatchData || currentResultModeType !== "detail_replacement") return;
|
||||||
|
const task = detailBatchData.task;
|
||||||
|
if (task && (task.status === "pending" || task.status === "running")) {
|
||||||
|
setPendingGeneration((current) => {
|
||||||
|
if (current?.backendTaskId === task.id) return current;
|
||||||
|
return {
|
||||||
|
chatId,
|
||||||
|
requestMessageId: task.id,
|
||||||
|
backendTaskId: task.id,
|
||||||
|
modeType: "detail_replacement",
|
||||||
|
expectedCount: task.requestedCardIds.length,
|
||||||
|
requestedCardIds: task.requestedCardIds,
|
||||||
|
userTurnCountAtStart: 0,
|
||||||
|
timestamp: Date.parse(task.createdAt) || Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPendingGeneration((current) =>
|
||||||
|
current?.modeType === "detail_replacement" &&
|
||||||
|
(!current.backendTaskId || current.backendTaskId === task?.id)
|
||||||
|
? null
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [chatId, currentResultModeType, detailBatchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cards = detailBatchTask?.cards || [];
|
||||||
|
if (cards.length === 0) return;
|
||||||
|
setReplacementResultState((current) => {
|
||||||
|
const next = { ...current.detail_replacement };
|
||||||
|
let changed = false;
|
||||||
|
for (const card of cards) {
|
||||||
|
const reference = detailReplacementReferenceCards.find((item) => item.id === card.cardId);
|
||||||
|
if (!reference || !card.imageUrl || next[card.cardId]?.url === card.imageUrl) continue;
|
||||||
|
next[card.cardId] = {
|
||||||
|
url: card.imageUrl,
|
||||||
|
text: card.text,
|
||||||
|
status: card.status,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
compareSourceUrl: reference.imageUrl,
|
||||||
|
referenceImageUrl: reference.imageUrl,
|
||||||
|
label: reference.label,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? { ...current, detail_replacement: next } : current;
|
||||||
|
});
|
||||||
|
}, [detailBatchTask?.cards, detailReplacementReferenceCards]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
detailBatchTask?.status !== "failed" ||
|
||||||
|
detailBatchTask.attempts < 3 ||
|
||||||
|
reportedDetailBatchFailureRef.current === detailBatchTask.id
|
||||||
|
) return;
|
||||||
|
reportedDetailBatchFailureRef.current = detailBatchTask.id;
|
||||||
|
toast.error(detailBatchTask.error || "详情替换批次中断,已保留生成成功的图片,可继续处理剩余图片");
|
||||||
|
}, [detailBatchTask]);
|
||||||
const isProductOnlyDetailReplacementMode =
|
const isProductOnlyDetailReplacementMode =
|
||||||
currentResultModeType === "detail_replacement" &&
|
currentResultModeType === "detail_replacement" &&
|
||||||
detailReplacementStrategy === "product_only";
|
detailReplacementStrategy === "product_only";
|
||||||
@ -4129,6 +4180,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
} = useChat<ChatMessage>({
|
} = useChat<ChatMessage>({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: historyMessages,
|
messages: historyMessages,
|
||||||
|
onData(part) {
|
||||||
|
if (part.type !== "data-appendMessage") return;
|
||||||
|
try {
|
||||||
|
const appended = parseAppendedGenerationMessage(part.data);
|
||||||
|
if (appended) setMessages((current) => appendGenerationMessage(current, appended as ChatMessage));
|
||||||
|
} catch {
|
||||||
|
void refreshHistory();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinish() { void refreshHistory(); },
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
@ -4262,6 +4323,20 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reconcile = () => {
|
||||||
|
if (document.visibilityState === "visible") void refreshHistory();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", reconcile);
|
||||||
|
window.addEventListener("online", reconcile);
|
||||||
|
window.addEventListener("pageshow", reconcile);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", reconcile);
|
||||||
|
window.removeEventListener("online", reconcile);
|
||||||
|
window.removeEventListener("pageshow", reconcile);
|
||||||
|
};
|
||||||
|
}, [refreshHistory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyMessages.length) {
|
if (!historyMessages.length) {
|
||||||
return;
|
return;
|
||||||
@ -4482,8 +4557,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
if (!hasLikelyGenerationErrorText(text)) {
|
if (!hasLikelyGenerationErrorText(text)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const imageUrls = extractImageUrlsFromText(text);
|
return true;
|
||||||
return imageUrls.length === 0;
|
|
||||||
}),
|
}),
|
||||||
[assistantMessagesAfterLastUser]
|
[assistantMessagesAfterLastUser]
|
||||||
);
|
);
|
||||||
@ -4791,6 +4865,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
: currentResultModeType === "sku_replacement"
|
: currentResultModeType === "sku_replacement"
|
||||||
? skuReplacementResultUrls
|
? skuReplacementResultUrls
|
||||||
: currentSceneGeneratedUrls;
|
: currentSceneGeneratedUrls;
|
||||||
|
const replacementRunRecovery = useMemo(() => {
|
||||||
|
if (!pendingGeneration || !["detail_replacement", "sku_replacement"].includes(pendingGeneration.modeType)) {
|
||||||
|
return { completedCardIds: [] as string[], failedCardIds: [] as string[], done: false };
|
||||||
|
}
|
||||||
|
return recoverReplacementRun({ messages, ...pendingGeneration });
|
||||||
|
}, [messages, pendingGeneration]);
|
||||||
const transportIsGenerating = status === "streaming" || status === "submitted";
|
const transportIsGenerating = status === "streaming" || status === "submitted";
|
||||||
const pendingGenerationProgressCount = useMemo(() => {
|
const pendingGenerationProgressCount = useMemo(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration) {
|
||||||
@ -4812,13 +4892,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return detailGenerationRunResults.length;
|
return detailGenerationRunResults.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return latestReplacementResults.length;
|
return replacementRunRecovery.completedCardIds.length;
|
||||||
}, [
|
}, [
|
||||||
currentSceneGeneratedUrls.length,
|
currentSceneGeneratedUrls.length,
|
||||||
detailGenerationRunResults.length,
|
detailGenerationRunResults.length,
|
||||||
latestReplacementResults.length,
|
latestReplacementResults.length,
|
||||||
pendingGeneration,
|
pendingGeneration,
|
||||||
userMessageCount,
|
userMessageCount,
|
||||||
|
replacementRunRecovery.completedCardIds.length,
|
||||||
]);
|
]);
|
||||||
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
|
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
|
||||||
const isSceneResultModeGenerating =
|
const isSceneResultModeGenerating =
|
||||||
@ -4948,21 +5029,33 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
Array.isArray(pendingGeneration.requestedCardIds)
|
Array.isArray(pendingGeneration.requestedCardIds)
|
||||||
? pendingGeneration.requestedCardIds.length
|
? pendingGeneration.requestedCardIds.length
|
||||||
: null,
|
: null,
|
||||||
status: getReplacementCardStatus({
|
status: detailBatchTask?.requestedCardIds.includes(card.id)
|
||||||
|
? resultMeta?.url
|
||||||
|
? resultMeta.status
|
||||||
|
: detailBatchTask.status === "running"
|
||||||
|
? detailBatchTask.currentCardId === card.id ? "generating" : "queued"
|
||||||
|
: detailBatchTask.status === "pending"
|
||||||
|
? "queued"
|
||||||
|
: detailBatchTask.status === "failed"
|
||||||
|
? "missing"
|
||||||
|
: "waiting"
|
||||||
|
: getReplacementCardStatus({
|
||||||
index,
|
index,
|
||||||
resultUrl,
|
resultUrl,
|
||||||
resultText,
|
resultText,
|
||||||
generatedCount: pendingGeneration?.modeType === "detail_replacement"
|
generatedCount: pendingGeneration?.modeType === "detail_replacement"
|
||||||
? latestReplacementResults.length
|
? latestReplacementResults.length
|
||||||
: 0,
|
: 0,
|
||||||
isGenerating: isDetailResultModeGenerating,
|
isGenerating: isDetailResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
|
||||||
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
detailReplacementReferenceCards,
|
detailReplacementReferenceCards,
|
||||||
|
detailBatchTask,
|
||||||
isDetailResultModeGenerating,
|
isDetailResultModeGenerating,
|
||||||
|
replacementRunRecovery.completedCardIds,
|
||||||
latestReplacementResults,
|
latestReplacementResults,
|
||||||
normalizedDeletedResultCollections.detail_replacement,
|
normalizedDeletedResultCollections.detail_replacement,
|
||||||
pendingGeneration?.modeType,
|
pendingGeneration?.modeType,
|
||||||
@ -5014,13 +5107,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
generatedCount: pendingGeneration?.modeType === "sku_replacement"
|
generatedCount: pendingGeneration?.modeType === "sku_replacement"
|
||||||
? latestReplacementResults.length
|
? latestReplacementResults.length
|
||||||
: 0,
|
: 0,
|
||||||
isGenerating: isSkuResultModeGenerating,
|
isGenerating: isSkuResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
|
||||||
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isSkuResultModeGenerating,
|
isSkuResultModeGenerating,
|
||||||
|
replacementRunRecovery.completedCardIds,
|
||||||
latestReplacementResults,
|
latestReplacementResults,
|
||||||
normalizedDeletedResultCollections.sku_replacement,
|
normalizedDeletedResultCollections.sku_replacement,
|
||||||
pendingGeneration?.modeType,
|
pendingGeneration?.modeType,
|
||||||
@ -5114,7 +5208,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const detailRemainingCount = useMemo(
|
const detailRemainingCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detailResultCards.filter(
|
detailResultCards.filter(
|
||||||
(card) => !["completed", "passthrough", "fallback"].includes(card.status)
|
(card) => !["completed", "passthrough"].includes(card.status)
|
||||||
).length,
|
).length,
|
||||||
[detailResultCards]
|
[detailResultCards]
|
||||||
);
|
);
|
||||||
@ -5389,7 +5483,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||||
|
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||||
|
stop();
|
||||||
|
transportGenerationStartedAtRef.current = null;
|
||||||
|
setPendingGeneration(null);
|
||||||
|
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5399,7 +5501,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || !chatError) {
|
if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5414,7 +5516,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [chatError, clearError, pendingGeneration]);
|
}, [chatError, clearError, pendingGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -6140,7 +6242,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const processedStatuses = new Set<ReplacementCardStatus>([
|
const processedStatuses = new Set<ReplacementCardStatus>([
|
||||||
"completed",
|
"completed",
|
||||||
"passthrough",
|
"passthrough",
|
||||||
"fallback",
|
|
||||||
]);
|
]);
|
||||||
const remainingIds = resultCards
|
const remainingIds = resultCards
|
||||||
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
||||||
@ -6246,6 +6347,86 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (replacementModeType === "detail_replacement") {
|
||||||
|
if (detailBatchSubmittingRef.current) return;
|
||||||
|
if (!isValidChatUuid(projectId)) {
|
||||||
|
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const productImageUrls = Array.from(new Set(
|
||||||
|
(detailReplacementProductSourceImages.length > 0
|
||||||
|
? detailReplacementProductSourceImages.map((item) => item.url)
|
||||||
|
: selectedProductAttachments.map((item) => item.url))
|
||||||
|
.filter((url) => isValidChatHttpUrl(url)),
|
||||||
|
));
|
||||||
|
if (productImageUrls.length === 0 && !replacementExecutionCards.every((card) => card.passthrough)) {
|
||||||
|
toast.error("请先在输入图区选择至少一张产品图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailBatchSubmittingRef.current = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const saved = await onPersistProject?.();
|
||||||
|
if (saved === false) throw new Error("项目尚未保存,请重试");
|
||||||
|
const requestId = generateUUID();
|
||||||
|
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId,
|
||||||
|
requestId,
|
||||||
|
model: resolvedModelId,
|
||||||
|
imageSize: imageParams.imageSize,
|
||||||
|
replacementStrategy: detailReplacementStrategy,
|
||||||
|
prompt: detailReplacementStrategy === "product_only"
|
||||||
|
? "仅替换参考详情图中的产品主体"
|
||||||
|
: String(resolvedWorkflowContext?.promptText || "").trim(),
|
||||||
|
userPrompt: String(options?.promptText || input || "").trim(),
|
||||||
|
productImageUrls,
|
||||||
|
modelImageUrl: detailReplacementModelSourceImage?.url || selectedModelAttachment?.url || null,
|
||||||
|
references: replacementExecutionCards.map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
label: card.label,
|
||||||
|
imageUrl: card.imageUrl,
|
||||||
|
candidateId: card.candidateId,
|
||||||
|
passthrough: card.passthrough,
|
||||||
|
rerunReferenceImages:
|
||||||
|
options?.replacementRerunReferenceImages?.cardId === card.id
|
||||||
|
? options.replacementRerunReferenceImages.images
|
||||||
|
: [],
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || !payload?.task?.id) {
|
||||||
|
throw new Error(payload?.error || "详情替换任务提交失败");
|
||||||
|
}
|
||||||
|
setPendingGeneration({
|
||||||
|
chatId,
|
||||||
|
requestMessageId: requestId,
|
||||||
|
backendTaskId: payload.task.id,
|
||||||
|
modeType: "detail_replacement",
|
||||||
|
expectedCount: replacementExecutionCards.length,
|
||||||
|
userTurnCountAtStart: userMessageCount,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
requestedCardIds: replacementExecutionCards.map((card) => card.id),
|
||||||
|
});
|
||||||
|
setReplacementExecutionContext({
|
||||||
|
modeType: "detail_replacement",
|
||||||
|
cardIds: replacementExecutionCards.map((card) => card.id),
|
||||||
|
});
|
||||||
|
setActiveResultModeType("detail_replacement");
|
||||||
|
setSelectedResultUrl(null);
|
||||||
|
await refreshDetailBatch();
|
||||||
|
toast.success(`已提交 ${replacementExecutionCards.length} 张详情图,离开页面后仍会继续处理`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "详情替换任务提交失败");
|
||||||
|
} finally {
|
||||||
|
detailBatchSubmittingRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
||||||
const sanitizedAttachments = sceneMessageAttachments;
|
const sanitizedAttachments = sceneMessageAttachments;
|
||||||
if (
|
if (
|
||||||
@ -6344,8 +6525,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
);
|
);
|
||||||
setActiveResultModeType(currentResultModeType);
|
setActiveResultModeType(currentResultModeType);
|
||||||
setSelectedResultUrl(null);
|
setSelectedResultUrl(null);
|
||||||
|
const requestMessageId = generateUUID();
|
||||||
setPendingGeneration({
|
setPendingGeneration({
|
||||||
chatId,
|
chatId,
|
||||||
|
requestMessageId,
|
||||||
modeType: currentResultModeType,
|
modeType: currentResultModeType,
|
||||||
expectedCount:
|
expectedCount:
|
||||||
currentResultModeType === "scene_generation"
|
currentResultModeType === "scene_generation"
|
||||||
@ -6366,7 +6549,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
|
void Promise.resolve(sendMessage({ id: requestMessageId, role: "user", parts })).catch((error) => {
|
||||||
generationRequestSnapshotRef.current = {
|
generationRequestSnapshotRef.current = {
|
||||||
hiddenPrompt: null,
|
hiddenPrompt: null,
|
||||||
replacementExecutionCardIds: null,
|
replacementExecutionCardIds: null,
|
||||||
@ -6393,6 +6576,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
detailResultCards,
|
detailResultCards,
|
||||||
detailReplacementReferenceCards.length,
|
detailReplacementReferenceCards.length,
|
||||||
detailReplacementStrategy,
|
detailReplacementStrategy,
|
||||||
|
detailReplacementProductSourceImages,
|
||||||
|
detailReplacementModelSourceImage,
|
||||||
handleRunAiDetailGeneration,
|
handleRunAiDetailGeneration,
|
||||||
currentResultModeType,
|
currentResultModeType,
|
||||||
getReplacementExecutionCards,
|
getReplacementExecutionCards,
|
||||||
@ -6423,6 +6608,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
scenePrimaryReferenceCount,
|
scenePrimaryReferenceCount,
|
||||||
sceneSecondaryReferenceCount,
|
sceneSecondaryReferenceCount,
|
||||||
selectedModelAttachment,
|
selectedModelAttachment,
|
||||||
|
selectedProductAttachments,
|
||||||
|
onPersistProject,
|
||||||
|
projectId,
|
||||||
|
refreshDetailBatch,
|
||||||
]);
|
]);
|
||||||
const handleStopGeneration = useCallback(() => {
|
const handleStopGeneration = useCallback(() => {
|
||||||
detailGenerationCancelRef.current = true;
|
detailGenerationCancelRef.current = true;
|
||||||
@ -6818,6 +7007,23 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, 2800);
|
}, 2800);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAiTemplateResultCreated = useCallback(
|
||||||
|
(result: AgentAiTemplateResult) => {
|
||||||
|
const appended = appendResultUrlToCollection("scene_generation", result.imageUrl, {
|
||||||
|
compareSourceUrl: result.sourceImageUrl,
|
||||||
|
});
|
||||||
|
if (!appended) {
|
||||||
|
toast.error("当前项目未接入结果保存能力");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveResultModeType("scene_generation");
|
||||||
|
setSelectedResultUrl(result.imageUrl);
|
||||||
|
showProcessedResultHint(result.imageUrl, "AI套模版完成");
|
||||||
|
},
|
||||||
|
[appendResultUrlToCollection, showProcessedResultHint]
|
||||||
|
);
|
||||||
|
|
||||||
const updateDetailGenerationProcessedResult = useCallback(
|
const updateDetailGenerationProcessedResult = useCallback(
|
||||||
(params: {
|
(params: {
|
||||||
cardId?: string | null;
|
cardId?: string | null;
|
||||||
@ -9262,7 +9468,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{isAnyGenerationRunning ? (
|
{isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -9640,7 +9846,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
initial={{ opacity: 0, scale: 0.98 }}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ delay: 0.12 }}
|
transition={{ delay: 0.12 }}
|
||||||
style={AGENT_DEFERRED_RESULTS_STYLE}
|
style={AGENT_STABLE_RESULTS_STYLE}
|
||||||
data-agent-style-section="results"
|
data-agent-style-section="results"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
|
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
|
||||||
@ -9826,7 +10032,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
selectedProductAttachments.map((attachment, index) => (
|
selectedProductAttachments.map((attachment, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${attachment?.url || "input"}-${index}`}
|
key={`${attachment?.url || "input"}-${index}`}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
|
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
|
||||||
>
|
>
|
||||||
<div className="aspect-square overflow-hidden bg-black/30">
|
<div className="aspect-square overflow-hidden bg-black/30">
|
||||||
@ -9942,7 +10148,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
||||||
isActive
|
isActive
|
||||||
@ -10107,7 +10313,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${card.id}-result`}
|
key={`${card.id}-result`}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
||||||
hasResult && isActive
|
hasResult && isActive
|
||||||
@ -10378,7 +10584,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
selectedProductAttachments.map((attachment, index) => (
|
selectedProductAttachments.map((attachment, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${attachment?.url || "sku-input"}-${index}`}
|
key={`${attachment?.url || "sku-input"}-${index}`}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
|
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
|
||||||
>
|
>
|
||||||
<div className="aspect-square overflow-hidden bg-black/30">
|
<div className="aspect-square overflow-hidden bg-black/30">
|
||||||
@ -10550,7 +10756,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectSkuReplacementCandidate(candidate.id)}
|
onClick={() => handleSelectSkuReplacementCandidate(candidate.id)}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors",
|
"min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
@ -10617,7 +10823,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementReferenceCards.map((card, index) => (
|
skuReplacementReferenceCards.map((card, index) => (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
||||||
activeSkuReferenceCardId === card.id
|
activeSkuReferenceCardId === card.id
|
||||||
@ -10746,7 +10952,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${card.id}-result`}
|
key={`${card.id}-result`}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
|
||||||
hasResult && activeSkuReferenceCardId === card.id
|
hasResult && activeSkuReferenceCardId === card.id
|
||||||
@ -10885,12 +11091,21 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
className="flex flex-1 flex-col px-6 pb-6 pt-5 md:px-8"
|
className="flex flex-1 flex-col px-6 pb-6 pt-5 md:px-8"
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-[1200px]">
|
<div className="mx-auto w-full max-w-[1200px]">
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-4",
|
||||||
|
activeResultModeType === "scene_generation"
|
||||||
|
? "grid-cols-1 md:grid-cols-2"
|
||||||
|
: "grid-cols-2 md:grid-cols-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{gridResultCards.map((card) => {
|
{gridResultCards.map((card) => {
|
||||||
const isActive = card.url && displayedResultUrl === card.url;
|
const isActive = card.url && displayedResultUrl === card.url;
|
||||||
const isSceneResultCard = activeResultModeType === "scene_generation";
|
const isSceneResultCard = activeResultModeType === "scene_generation";
|
||||||
const isDetailGenerationCard =
|
const isDetailGenerationCard =
|
||||||
activeResultModeType === "detail_generation";
|
activeResultModeType === "detail_generation";
|
||||||
|
const isAiTemplateProcessing =
|
||||||
|
aiTemplateProcessingSourceUrl === card.url;
|
||||||
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
|
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
|
||||||
const isDetailLocalEditBusy =
|
const isDetailLocalEditBusy =
|
||||||
detailLocalEditSubmittingId === card.id;
|
detailLocalEditSubmittingId === card.id;
|
||||||
@ -10913,7 +11128,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
key={card.id}
|
key={card.id}
|
||||||
role={card.url ? "button" : undefined}
|
role={card.url ? "button" : undefined}
|
||||||
tabIndex={card.url ? 0 : -1}
|
tabIndex={card.url ? 0 : -1}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_STABLE_RESULT_CARD_STYLE}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!card.url) return;
|
if (!card.url) return;
|
||||||
setSelectedResultUrl(card.url);
|
setSelectedResultUrl(card.url);
|
||||||
@ -10953,36 +11168,70 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
/>
|
/>
|
||||||
{isSceneResultCard ? (
|
{isSceneResultCard ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<div className="absolute left-3 right-14 top-3 flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
setSelectedResultUrl(card.url!);
|
|
||||||
setSceneResizeEditorTargetUrl(card.url!);
|
|
||||||
}}
|
|
||||||
className={cn("absolute left-3 top-3 flex h-9 w-9 items-center justify-center", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS, "px-0 py-0")}
|
|
||||||
aria-label="调整尺寸"
|
|
||||||
>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
{isPsdTemplateEnabled ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setSelectedResultUrl(card.url!);
|
setSelectedResultUrl(card.url!);
|
||||||
openPsdTemplateDialog({
|
setSceneResizeEditorTargetUrl(card.url!);
|
||||||
sourceImageUrl: card.url!,
|
|
||||||
mode: getPsdTemplateModeByResultMode(activeResultModeType),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
className={cn("absolute left-14 top-3 flex items-center gap-1", IMAGE_OVERLAY_ACTION_CLASS)}
|
className={cn(
|
||||||
aria-label="套用 PSD 模板"
|
"flex h-9 w-9 items-center justify-center px-0 py-0",
|
||||||
|
IMAGE_OVERLAY_ACTIVE_BADGE_CLASS
|
||||||
|
)}
|
||||||
|
aria-label="调整尺寸"
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-3.5 w-3.5" />
|
<Maximize2 className="h-4 w-4" />
|
||||||
<span>套PSD</span>
|
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
{isPsdTemplateEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedResultUrl(card.url!);
|
||||||
|
openPsdTemplateDialog({
|
||||||
|
sourceImageUrl: card.url!,
|
||||||
|
mode: getPsdTemplateModeByResultMode(
|
||||||
|
activeResultModeType
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1",
|
||||||
|
IMAGE_OVERLAY_ACTION_CLASS
|
||||||
|
)}
|
||||||
|
aria-label="套用 PSD 模板"
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-3.5 w-3.5" />
|
||||||
|
<span>套PSD</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedResultUrl(card.url!);
|
||||||
|
setAiTemplateDialogSourceUrl(card.url!);
|
||||||
|
}}
|
||||||
|
disabled={Boolean(aiTemplateProcessingSourceUrl)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 disabled:cursor-not-allowed disabled:opacity-55",
|
||||||
|
IMAGE_OVERLAY_ACTION_CLASS
|
||||||
|
)}
|
||||||
|
aria-label="AI套模版"
|
||||||
|
aria-busy={isAiTemplateProcessing}
|
||||||
|
>
|
||||||
|
{isAiTemplateProcessing ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LayoutTemplate className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{isAiTemplateProcessing ? "AI套版中" : "AI套模版"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/25 bg-zinc-950/82 px-3 py-2 font-semibold text-[11px] text-white shadow-[0_12px_34px_rgba(0,0,0,0.48)] backdrop-blur-md">
|
<div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/25 bg-zinc-950/82 px-3 py-2 font-semibold text-[11px] text-white shadow-[0_12px_34px_rgba(0,0,0,0.48)] backdrop-blur-md">
|
||||||
双击查看大图
|
双击查看大图
|
||||||
</div>
|
</div>
|
||||||
@ -11230,6 +11479,29 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AgentAiTemplateDialog
|
||||||
|
imageSize={imageParams.imageSize}
|
||||||
|
model={selectedModelId !== "auto" ? selectedModelId : effectiveDefaultModelId}
|
||||||
|
projectId={projectId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setAiTemplateDialogSourceUrl(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onProcessingChange={(processing, sourceImageUrl) => {
|
||||||
|
setAiTemplateProcessingSourceUrl((current) =>
|
||||||
|
processing
|
||||||
|
? sourceImageUrl
|
||||||
|
: current === sourceImageUrl
|
||||||
|
? null
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onResultCreated={handleAiTemplateResultCreated}
|
||||||
|
open={Boolean(aiTemplateDialogSourceUrl)}
|
||||||
|
sourceImageUrl={aiTemplateDialogSourceUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{sceneResizeEditorTargetUrl ? (
|
{sceneResizeEditorTargetUrl ? (
|
||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`}
|
key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`}
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
|
import {
|
||||||
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
|
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||||
|
} from "@/lib/image-upload-limits";
|
||||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||||
|
|
||||||
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES;
|
||||||
|
|
||||||
export interface UploadedDetailReferenceImage {
|
export interface UploadedDetailReferenceImage {
|
||||||
url: string;
|
url: string;
|
||||||
@ -64,7 +68,7 @@ export async function uploadDetailReferenceImageFile(
|
|||||||
throw new Error("请上传图片文件");
|
throw new Error("请上传图片文件");
|
||||||
}
|
}
|
||||||
if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) {
|
if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) {
|
||||||
throw new Error("目标详情图过大,请压缩到 50MB 以内");
|
throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensionsPromise = readLocalImageDimensions(file);
|
const dimensionsPromise = readLocalImageDimensions(file);
|
||||||
|
|||||||
259
components/bloub-avatar.tsx
Normal file
259
components/bloub-avatar.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { defaultCycle } from "@/lib/bloub-official/cycles";
|
||||||
|
import { NOTIF_BLUE } from "@/lib/bloub-official/decor";
|
||||||
|
import { type BotFrame, BotEngine } from "@/lib/bloub-official/engine";
|
||||||
|
import {
|
||||||
|
DEFAULT_EXPRESSION,
|
||||||
|
EXPRESSION_BY_ID,
|
||||||
|
} from "@/lib/bloub-official/expressions";
|
||||||
|
import { DEMI_VIEWBOX, RAYON } from "@/lib/bloub-official/repere";
|
||||||
|
import { SHAPE_BY_ID } from "@/lib/bloub-official/skins";
|
||||||
|
import type { StateId } from "@/lib/bloub-official/states";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type BloubAvatarProps = {
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
label?: string;
|
||||||
|
playSequence?: boolean;
|
||||||
|
size?: number;
|
||||||
|
state?: StateId;
|
||||||
|
surface?: "chat" | "page";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BloubAvatar({
|
||||||
|
animated = true,
|
||||||
|
className,
|
||||||
|
label = "AI 助手",
|
||||||
|
playSequence = false,
|
||||||
|
size = 32,
|
||||||
|
state = "idle",
|
||||||
|
surface = "chat",
|
||||||
|
}: BloubAvatarProps) {
|
||||||
|
const rawId = useId();
|
||||||
|
const uid = rawId.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
const maskId = `bloub-mask-${uid}`;
|
||||||
|
const engineRef = useRef<BotEngine | null>(null);
|
||||||
|
const clockRef = useRef(0);
|
||||||
|
|
||||||
|
if (!engineRef.current) {
|
||||||
|
engineRef.current = new BotEngine(
|
||||||
|
RAYON,
|
||||||
|
state,
|
||||||
|
SHAPE_BY_ID.get("squircle")?.radii ?? null,
|
||||||
|
EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [frame, setFrame] = useState<BotFrame>(() =>
|
||||||
|
engineRef.current!.sample(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playSequence) return;
|
||||||
|
const engine = engineRef.current;
|
||||||
|
if (!engine || engine.state === state) return;
|
||||||
|
engine.setState(state, clockRef.current);
|
||||||
|
setFrame(engine.sample(clockRef.current));
|
||||||
|
}, [playSequence, state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const engine = engineRef.current;
|
||||||
|
if (!engine) return;
|
||||||
|
|
||||||
|
const cycle = defaultCycle().blocks;
|
||||||
|
const firstState = playSequence
|
||||||
|
? cycle[0]?.state ?? "idle"
|
||||||
|
: engine.state;
|
||||||
|
engine.reset(firstState, 0);
|
||||||
|
clockRef.current = 0;
|
||||||
|
setFrame(engine.sample(0));
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia(
|
||||||
|
"(prefers-reduced-motion: reduce)"
|
||||||
|
).matches;
|
||||||
|
if (!animated || reducedMotion) return;
|
||||||
|
|
||||||
|
let animationFrame = 0;
|
||||||
|
let lastFrameAt = 0;
|
||||||
|
let blockIndex = 0;
|
||||||
|
let nextBlockAt = cycle[0]?.duration ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
const tick = (timestamp: number) => {
|
||||||
|
const delta = lastFrameAt
|
||||||
|
? Math.min((timestamp - lastFrameAt) / 1000, 0.064)
|
||||||
|
: 0;
|
||||||
|
lastFrameAt = timestamp;
|
||||||
|
clockRef.current += delta;
|
||||||
|
|
||||||
|
if (playSequence && cycle.length > 0) {
|
||||||
|
while (clockRef.current >= nextBlockAt) {
|
||||||
|
blockIndex = (blockIndex + 1) % cycle.length;
|
||||||
|
const nextBlock = cycle[blockIndex]!;
|
||||||
|
engine.setState(nextBlock.state, nextBlockAt);
|
||||||
|
nextBlockAt += nextBlock.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFrame(engine.sample(clockRef.current));
|
||||||
|
animationFrame = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrame = window.requestAnimationFrame(tick);
|
||||||
|
return () => window.cancelAnimationFrame(animationFrame);
|
||||||
|
}, [animated, playSequence]);
|
||||||
|
|
||||||
|
const renderDot = (
|
||||||
|
dot: BotFrame["dots"][number],
|
||||||
|
index: number,
|
||||||
|
layer: "back" | "front"
|
||||||
|
) => {
|
||||||
|
const fill = dot.color ?? "currentColor";
|
||||||
|
const common = {
|
||||||
|
fill,
|
||||||
|
opacity:
|
||||||
|
dot.color || dot.depth === undefined
|
||||||
|
? dot.opacity
|
||||||
|
: dot.opacity * dot.depth,
|
||||||
|
};
|
||||||
|
const key = `${layer}-${index}`;
|
||||||
|
|
||||||
|
if (dot.d) {
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
{...common}
|
||||||
|
d={dot.d}
|
||||||
|
key={key}
|
||||||
|
transform={`translate(${dot.x} ${dot.y}) rotate(${dot.rot ?? 0}) scale(${RAYON})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle {...common} cx={dot.x} cy={dot.y} key={key} r={dot.r} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"block shrink-0 text-[#0a0a0c] dark:text-[#3ecf8e]",
|
||||||
|
surface === "page"
|
||||||
|
? "[--bloub-paper:#f4f4f5] dark:[--bloub-paper:#08090b]"
|
||||||
|
: "[--bloub-paper:#ffffff] dark:[--bloub-paper:#09090b]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
height={size}
|
||||||
|
role="img"
|
||||||
|
viewBox={`${-DEMI_VIEWBOX} ${-DEMI_VIEWBOX} ${DEMI_VIEWBOX * 2} ${DEMI_VIEWBOX * 2}`}
|
||||||
|
width={size}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask
|
||||||
|
height={DEMI_VIEWBOX * 2}
|
||||||
|
id={maskId}
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
width={DEMI_VIEWBOX * 2}
|
||||||
|
x={-DEMI_VIEWBOX}
|
||||||
|
y={-DEMI_VIEWBOX}
|
||||||
|
>
|
||||||
|
<path d={frame.bodyPath} fill="#fff" />
|
||||||
|
{frame.eyes.map((eye, index) => (
|
||||||
|
<path
|
||||||
|
d={eye.d}
|
||||||
|
fill="#000"
|
||||||
|
key={`eye-${index}`}
|
||||||
|
opacity={eye.alpha}
|
||||||
|
transform={eye.matrix}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{frame.notch ? (
|
||||||
|
<circle
|
||||||
|
cx={frame.notch.x}
|
||||||
|
cy={frame.notch.y}
|
||||||
|
fill="#000"
|
||||||
|
r={frame.notch.r}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</mask>
|
||||||
|
|
||||||
|
{frame.arcs.map((arc) => (
|
||||||
|
<linearGradient
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id={`${uid}-${arc.id}`}
|
||||||
|
key={`gradient-${arc.id}`}
|
||||||
|
x1={arc.grad.x1}
|
||||||
|
x2={arc.grad.x2}
|
||||||
|
y1={arc.grad.y1}
|
||||||
|
y2={arc.grad.y2}
|
||||||
|
>
|
||||||
|
{arc.grad.stops.map((color, index) => (
|
||||||
|
<stop
|
||||||
|
key={`${arc.id}-${color}-${index}`}
|
||||||
|
offset={index / (arc.grad.stops.length - 1)}
|
||||||
|
stopColor={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g fill="none" strokeLinecap="round">
|
||||||
|
{frame.arcs.map((arc) => (
|
||||||
|
<path
|
||||||
|
d={arc.back}
|
||||||
|
key={`arc-back-${arc.id}`}
|
||||||
|
opacity={arc.opacity}
|
||||||
|
stroke={`url(#${uid}-${arc.id})`}
|
||||||
|
strokeWidth={arc.width}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{frame.dotsBehind ? (
|
||||||
|
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "back"))}</g>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<g opacity={frame.bodyAlpha}>
|
||||||
|
<path d={frame.bodyPath} fill="var(--bloub-paper)" />
|
||||||
|
<g mask={`url(#${maskId})`}>
|
||||||
|
<rect
|
||||||
|
fill="currentColor"
|
||||||
|
height={DEMI_VIEWBOX * 2}
|
||||||
|
width={DEMI_VIEWBOX * 2}
|
||||||
|
x={-DEMI_VIEWBOX}
|
||||||
|
y={-DEMI_VIEWBOX}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{!frame.dotsBehind ? (
|
||||||
|
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "front"))}</g>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{frame.notif ? (
|
||||||
|
<circle
|
||||||
|
cx={frame.notif.x}
|
||||||
|
cy={frame.notif.y}
|
||||||
|
fill={NOTIF_BLUE}
|
||||||
|
r={frame.notif.r}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<g fill="none" strokeLinecap="round">
|
||||||
|
{frame.arcs.map((arc) => (
|
||||||
|
<path
|
||||||
|
d={arc.front}
|
||||||
|
key={`arc-front-${arc.id}`}
|
||||||
|
opacity={arc.opacity}
|
||||||
|
stroke={`url(#${uid}-${arc.id})`}
|
||||||
|
strokeWidth={arc.width}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -46,7 +46,10 @@ import {
|
|||||||
type AiWashControlLevel,
|
type AiWashControlLevel,
|
||||||
type AiWashPreset,
|
type AiWashPreset,
|
||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
|
import {
|
||||||
|
downloadCanvasImageAsPng,
|
||||||
|
getCanvasImageDownloadErrorMessage,
|
||||||
|
} from "@/lib/client/canvas-image-download";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
@ -1019,7 +1022,7 @@ export function AiWashWorkspace({
|
|||||||
});
|
});
|
||||||
toast.success("图片已开始下载");
|
toast.success("图片已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingResultId((current) =>
|
setDownloadingResultId((current) =>
|
||||||
current === downloadId ? null : current
|
current === downloadId ? null : current
|
||||||
|
|||||||
311
components/canvas/AudioGenerationComposer.tsx
Normal file
311
components/canvas/AudioGenerationComposer.tsx
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
audioContract,
|
||||||
|
audioRequestSchema,
|
||||||
|
buildAudioRequest,
|
||||||
|
type AudioRequest,
|
||||||
|
} from "@/lib/video-canvas/audio-contract";
|
||||||
|
type Model = { id: string; modelId: string; name: string; kind: string };
|
||||||
|
export function AudioGenerationComposer({
|
||||||
|
initial,
|
||||||
|
connectedText = "",
|
||||||
|
busy,
|
||||||
|
onSubmit,
|
||||||
|
onUpload,
|
||||||
|
onDraft,
|
||||||
|
}: {
|
||||||
|
initial?: Record<string, unknown>;
|
||||||
|
connectedText?: string;
|
||||||
|
busy: boolean;
|
||||||
|
onSubmit: (params: AudioRequest, draft: AudioRequest) => Promise<unknown>;
|
||||||
|
onUpload: (file: File) => Promise<void>;
|
||||||
|
onDraft: (params: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const [models, setModels] = useState<Model[]>([]),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[loading, setLoading] = useState(true),
|
||||||
|
[sending, setSending] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<Record<string, any>>(() => ({
|
||||||
|
mode: "tts",
|
||||||
|
text: "",
|
||||||
|
voice: "Cherry",
|
||||||
|
format: "wav",
|
||||||
|
language: "Chinese",
|
||||||
|
instrumental: true,
|
||||||
|
gender: "female",
|
||||||
|
rate: 1,
|
||||||
|
pitch: 1,
|
||||||
|
volume: 50,
|
||||||
|
...initial,
|
||||||
|
}));
|
||||||
|
const patch = (value: Record<string, unknown>) => {
|
||||||
|
const next = { ...draft, ...value };
|
||||||
|
setDraft(next);
|
||||||
|
onDraft(next);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch("/api/canvas/audio/models", { signal: controller.signal })
|
||||||
|
.then(async (r) => {
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) throw Error(data.error || "音频模型读取失败");
|
||||||
|
setModels(data.models || []);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!controller.signal.aborted) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
const options = models.filter((m) => m.kind === draft.mode),
|
||||||
|
model = options.find((m) => m.id === draft.modelRef) || options[0],
|
||||||
|
contract = model ? audioContract(model.modelId) : undefined;
|
||||||
|
const voice = contract?.voices.some((v) => v.id === draft.voice)
|
||||||
|
? draft.voice
|
||||||
|
: contract?.voices[0]?.id;
|
||||||
|
const format = contract?.formats.includes(draft.format) ? draft.format : contract?.formats[0];
|
||||||
|
const input = "mt-1 w-full rounded-lg border border-white/15 bg-[#303030] p-2 text-sm";
|
||||||
|
const run = async () => {
|
||||||
|
setError("");
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
if (!model) throw Error("当前模式没有已启用的官方模型");
|
||||||
|
const params = audioRequestSchema.parse({
|
||||||
|
...draft,
|
||||||
|
text: [draft.text, connectedText].filter(Boolean).join("\n\n"),
|
||||||
|
modelRef: model.id,
|
||||||
|
voice,
|
||||||
|
format,
|
||||||
|
...(!contract?.controls ? { rate: 1, pitch: 1, volume: 50 } : {}),
|
||||||
|
...(!contract?.instructions ? { instructions: "" } : {}),
|
||||||
|
});
|
||||||
|
buildAudioRequest(params, model.modelId);
|
||||||
|
const savedDraft = { ...params, text: String(draft.text || "") };
|
||||||
|
patch(savedDraft);
|
||||||
|
await onSubmit(params, savedDraft);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "提交失败");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="fixed bottom-8 left-1/2 z-[100] max-h-[75vh] w-[min(620px,94vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-white/10 bg-[#222] p-5 text-white shadow-xl"
|
||||||
|
data-canvas-overlay="true"
|
||||||
|
aria-label="阿里云音频生成"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold">语音与音乐 · 阿里云官方</h3>
|
||||||
|
<fieldset disabled={busy || sending} className="mt-3 space-y-3 disabled:opacity-60">
|
||||||
|
{connectedText && <p className="mb-3 whitespace-pre-wrap rounded bg-white/5 p-2 text-xs" aria-label="已连接文本">已连接文本:{connectedText}</p>}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label>
|
||||||
|
生成类型
|
||||||
|
<select
|
||||||
|
aria-label="音频生成类型"
|
||||||
|
className={input}
|
||||||
|
value={draft.mode}
|
||||||
|
onChange={(e) => patch({ mode: e.target.value, modelRef: "" })}
|
||||||
|
>
|
||||||
|
<option value="tts">文字配音</option>
|
||||||
|
<option value="music">音乐 / 歌曲</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
模型
|
||||||
|
<select
|
||||||
|
aria-label="音频模型"
|
||||||
|
className={input}
|
||||||
|
value={model?.id || ""}
|
||||||
|
onChange={(e) => patch({ modelRef: e.target.value })}
|
||||||
|
>
|
||||||
|
{!options.length ? (
|
||||||
|
<option value="">{loading ? "读取模型中…" : "暂无已开通模型"}</option>
|
||||||
|
) : (
|
||||||
|
options.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
{draft.mode === "tts" ? "配音文本(最多 600 字)" : "音乐描述"}
|
||||||
|
<textarea
|
||||||
|
aria-label="音频内容"
|
||||||
|
className={input}
|
||||||
|
rows={4}
|
||||||
|
maxLength={draft.mode === "tts" ? 600 : 2000}
|
||||||
|
value={draft.text}
|
||||||
|
onChange={(e) => patch({ text: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{draft.mode === "tts" ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label>
|
||||||
|
音色
|
||||||
|
<select
|
||||||
|
aria-label="配音音色"
|
||||||
|
className={input}
|
||||||
|
value={voice || ""}
|
||||||
|
onChange={(e) => patch({ voice: e.target.value })}
|
||||||
|
>
|
||||||
|
{contract?.voices.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
语言
|
||||||
|
<select
|
||||||
|
aria-label="配音语言"
|
||||||
|
className={input}
|
||||||
|
value={draft.language}
|
||||||
|
onChange={(e) => patch({ language: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="Chinese">中文</option>
|
||||||
|
<option value="English">英文</option>
|
||||||
|
<option value="Auto">自动 / 中英混合</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{contract?.instructions ? (
|
||||||
|
<label className="block">
|
||||||
|
声音风格指令
|
||||||
|
<input
|
||||||
|
className={input}
|
||||||
|
aria-label="声音风格指令"
|
||||||
|
placeholder="例如:温暖自然,语速略慢"
|
||||||
|
maxLength={1200}
|
||||||
|
value={draft.instructions || ""}
|
||||||
|
onChange={(e) => patch({ instructions: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{contract?.controls ? (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(["rate", "pitch", "volume"] as const).map((key, i) => (
|
||||||
|
<label key={key}>
|
||||||
|
{["语速", "音调", "音量"][i]}
|
||||||
|
<input
|
||||||
|
aria-label={["语速", "音调", "音量"][i]}
|
||||||
|
className={input}
|
||||||
|
type="number"
|
||||||
|
min={key === "volume" ? 0 : 0.5}
|
||||||
|
max={key === "volume" ? 100 : 2}
|
||||||
|
step={key === "volume" ? 1 : 0.1}
|
||||||
|
value={draft[key]}
|
||||||
|
onChange={(e) => patch({ [key]: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.instrumental}
|
||||||
|
onChange={(e) => patch({ instrumental: e.target.checked })}
|
||||||
|
/>
|
||||||
|
纯音乐(无人声)
|
||||||
|
</label>
|
||||||
|
{!draft.instrumental ? (
|
||||||
|
<>
|
||||||
|
<label className="block">
|
||||||
|
自定义歌词(选填,填写后优先于描述)
|
||||||
|
<textarea
|
||||||
|
aria-label="音乐歌词"
|
||||||
|
className={input}
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
value={draft.lyrics || ""}
|
||||||
|
onChange={(e) => patch({ lyrics: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
演唱声音
|
||||||
|
<select
|
||||||
|
className={input}
|
||||||
|
aria-label="演唱声音"
|
||||||
|
value={draft.gender}
|
||||||
|
onChange={(e) => patch({ gender: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="female">女声</option>
|
||||||
|
<option value="male">男声</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{!options.length ? (
|
||||||
|
<p className="text-sm text-amber-200">
|
||||||
|
Fun-Music 需要阿里云邀测权限,开通并启用模型后即可生成。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<label>
|
||||||
|
格式
|
||||||
|
<select
|
||||||
|
aria-label="音频格式"
|
||||||
|
className={input}
|
||||||
|
value={format || "wav"}
|
||||||
|
onChange={(e) => patch({ format: e.target.value })}
|
||||||
|
>
|
||||||
|
{(contract?.formats || ["wav"]).map((f) => (
|
||||||
|
<option key={f} value={f}>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!model || loading}
|
||||||
|
className="rounded-lg bg-white px-4 py-2 text-black disabled:opacity-40"
|
||||||
|
onClick={() => void run()}
|
||||||
|
>
|
||||||
|
{busy || sending ? "处理中…" : "生成音频"}
|
||||||
|
</button>
|
||||||
|
<label className="cursor-pointer rounded-lg border border-white/20 px-3 py-2">
|
||||||
|
上传音频
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await onUpload(file);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "上传失败");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-red-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
|
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -21,6 +21,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
|
||||||
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
import type {
|
import type {
|
||||||
AgentWorkflowMaterialImage,
|
AgentWorkflowMaterialImage,
|
||||||
AgentWorkflowMaterialQueryType,
|
AgentWorkflowMaterialQueryType,
|
||||||
@ -163,6 +165,8 @@ export function CanvasAddImageDialog({
|
|||||||
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const localUploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [errorText, setErrorText] = useState<string | null>(null);
|
const [errorText, setErrorText] = useState<string | null>(null);
|
||||||
|
|
||||||
const images = useMemo(() => normalizeImages(result), [result]);
|
const images = useMemo(() => normalizeImages(result), [result]);
|
||||||
@ -182,12 +186,13 @@ export function CanvasAddImageDialog({
|
|||||||
|
|
||||||
const handleDialogOpenChange = useCallback(
|
const handleDialogOpenChange = useCallback(
|
||||||
(nextOpen: boolean) => {
|
(nextOpen: boolean) => {
|
||||||
|
if (!nextOpen && (isUploading || isAdding)) return;
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
resetDialogState();
|
resetDialogState();
|
||||||
}
|
}
|
||||||
onOpenChange(nextOpen);
|
onOpenChange(nextOpen);
|
||||||
},
|
},
|
||||||
[onOpenChange, resetDialogState]
|
[isAdding, isUploading, onOpenChange, resetDialogState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeDialog = useCallback(() => {
|
const closeDialog = useCallback(() => {
|
||||||
@ -263,6 +268,51 @@ export function CanvasAddImageDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLocalUpload = async (files: File[]) => {
|
||||||
|
if (!files.length || isUploading || isAdding) return;
|
||||||
|
if (files.some((file) => !file.type.startsWith("image/"))) {
|
||||||
|
setErrorText("请选择图片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
|
||||||
|
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setErrorText(null);
|
||||||
|
try {
|
||||||
|
const uploadedImages: CanvasAddImageDialogImage[] = [];
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const [index, file] of files.entries()) {
|
||||||
|
const upload = await imageGenerationService.uploadImage(file);
|
||||||
|
if (!upload.success || !upload.data?.url) {
|
||||||
|
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uploadedImages.push({
|
||||||
|
id: upload.data.assetId || crypto.randomUUID(),
|
||||||
|
key: upload.data.pathname || file.name,
|
||||||
|
label: file.name.replace(/\.[^.]+$/, "") || file.name,
|
||||||
|
fileName: file.name,
|
||||||
|
imageUrl: upload.data.url,
|
||||||
|
assetId: upload.data.assetId,
|
||||||
|
mimeType: file.type,
|
||||||
|
sourceType: "upload",
|
||||||
|
group: "other",
|
||||||
|
sortOrder: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (uploadedImages.length) await onAddImages(uploadedImages);
|
||||||
|
if (failures.length) setErrorText(failures.join(";"));
|
||||||
|
else closeDialog();
|
||||||
|
} catch (error) {
|
||||||
|
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
||||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||||
@ -272,15 +322,38 @@ export function CanvasAddImageDialog({
|
|||||||
添加图片
|
添加图片
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
|
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
||||||
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
||||||
<div className="flex flex-col gap-2.5 sm:flex-row">
|
<div className="flex flex-col gap-2.5 sm:flex-row">
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = "";
|
||||||
|
void handleLocalUpload(files);
|
||||||
|
}}
|
||||||
|
ref={localUploadInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="h-9 justify-start gap-2"
|
className="h-9 shrink-0 justify-start gap-2"
|
||||||
|
disabled={isUploading || isAdding}
|
||||||
|
onClick={() => localUploadInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{isUploading ? "上传中…" : "本地上传"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-9 shrink-0 justify-start gap-2"
|
||||||
|
disabled={isUploading || isAdding}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCreateBlankImage();
|
onCreateBlankImage();
|
||||||
closeDialog();
|
closeDialog();
|
||||||
@ -355,13 +428,13 @@ export function CanvasAddImageDialog({
|
|||||||
</div>
|
</div>
|
||||||
{!materialSearchEnabled ? (
|
{!materialSearchEnabled ? (
|
||||||
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
||||||
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
|
风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorText ? (
|
{errorText ? (
|
||||||
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
<div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||||
{errorText}
|
{errorText}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -440,6 +513,7 @@ export function CanvasAddImageDialog({
|
|||||||
|
|
||||||
<DialogFooter className="border-t px-5 py-3">
|
<DialogFooter className="border-t px-5 py-3">
|
||||||
<Button
|
<Button
|
||||||
|
disabled={isUploading || isAdding}
|
||||||
onClick={closeDialog}
|
onClick={closeDialog}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -448,7 +522,7 @@ export function CanvasAddImageDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
disabled={!selectedImages.length || isAdding}
|
disabled={!selectedImages.length || isAdding || isUploading}
|
||||||
onClick={() => void handleAddSelected()}
|
onClick={() => void handleAddSelected()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
115
components/canvas/CanvasBlankContextMenu.tsx
Normal file
115
components/canvas/CanvasBlankContextMenu.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import type { Position } from "@/types/canvas";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
position: Position | null;
|
||||||
|
isMac: boolean;
|
||||||
|
videoUploading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUploadImage: () => void;
|
||||||
|
onUploadVideo: () => void;
|
||||||
|
onPaste: () => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onFitAll: () => void;
|
||||||
|
onResetZoom: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasBlankContextMenu({
|
||||||
|
position,
|
||||||
|
isMac,
|
||||||
|
videoUploading,
|
||||||
|
onClose,
|
||||||
|
onUploadImage,
|
||||||
|
onUploadVideo,
|
||||||
|
onPaste,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onFitAll,
|
||||||
|
onResetZoom,
|
||||||
|
}: Props) {
|
||||||
|
const modifier = isMac ? "⌘" : "Ctrl+";
|
||||||
|
const itemClass =
|
||||||
|
"h-8 cursor-pointer justify-between whitespace-nowrap rounded-[5px] px-0 text-[14px] font-normal focus:bg-[#f5f5f5] dark:focus:bg-white/10";
|
||||||
|
const shortcut = (label: string) => (
|
||||||
|
<span className="ml-2 whitespace-nowrap text-[13px] text-[#aaa]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
modal={false}
|
||||||
|
open={position !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
data-canvas-no-drag="true"
|
||||||
|
className="pointer-events-none fixed h-px w-px opacity-0"
|
||||||
|
style={{ left: position?.x ?? 0, top: position?.y ?? 0 }}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
aria-label="画布右键菜单"
|
||||||
|
align="start"
|
||||||
|
sideOffset={0}
|
||||||
|
collisionPadding={8}
|
||||||
|
data-canvas-blank-menu="true"
|
||||||
|
data-canvas-no-drag="true"
|
||||||
|
className="z-[10005] w-[200px] rounded-[12px] border-[#ededed] bg-white px-3 py-[5px] text-[#242424] shadow-[0_8px_20px_rgba(0,0,0,0.06)] ring-0 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
'Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
||||||
|
}}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem className={itemClass} onSelect={onUploadImage}>
|
||||||
|
上传图片
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={itemClass}
|
||||||
|
disabled={videoUploading}
|
||||||
|
onSelect={onUploadVideo}
|
||||||
|
>
|
||||||
|
上传视频
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
||||||
|
<DropdownMenuItem className={itemClass} onSelect={onPaste}>
|
||||||
|
粘贴{shortcut(`${modifier}V`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
||||||
|
<DropdownMenuItem className={itemClass} onSelect={onZoomIn}>
|
||||||
|
放大{shortcut(`${modifier}+`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={itemClass} onSelect={onZoomOut}>
|
||||||
|
缩小{shortcut(`${modifier}-`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={itemClass} onSelect={onFitAll}>
|
||||||
|
显示画布所有元素{shortcut(isMac ? "⇧1" : "Shift+1")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className={itemClass} onSelect={onResetZoom}>
|
||||||
|
缩放至100%{shortcut(`${modifier}0`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
54
components/canvas/CanvasComposerPromo.tsx
Normal file
54
components/canvas/CanvasComposerPromo.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
|
|
||||||
|
type CanvasComposerPromoProps = {
|
||||||
|
text: string;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onActivate?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasComposerPromo({
|
||||||
|
text,
|
||||||
|
onDismiss,
|
||||||
|
onActivate,
|
||||||
|
}: CanvasComposerPromoProps) {
|
||||||
|
return (
|
||||||
|
<div className="lovart-chat-promo lovart-chat-promo-connected">
|
||||||
|
<div className="lovart-chat-promo-surface">
|
||||||
|
<div className="lovart-chat-promo-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lovart-chat-promo-message"
|
||||||
|
onClick={onActivate}
|
||||||
|
disabled={!onActivate}
|
||||||
|
title={text}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
width="16"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 28"
|
||||||
|
className="lovart-chat-promo-badge"
|
||||||
|
>
|
||||||
|
<path d="M10 1.15a4 4 0 0 1 4 0l8 4.6a4 4 0 0 1 2 3.46v9.58a4 4 0 0 1-2 3.46l-8 4.6a4 4 0 0 1-4 0l-8-4.6A4 4 0 0 1 0 18.79V9.21a4 4 0 0 1 2-3.46Z" fill="#ddef65" />
|
||||||
|
<path
|
||||||
|
d="M12 5.5c1.2 5.3 2.2 6.3 7.5 7.5-5.3 1.2-6.3 2.2-7.5 7.5C10.8 15.2 9.8 14.2 4.5 13c5.3-1.2 6.3-2.2 7.5-7.5Z"
|
||||||
|
fill="#111"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{text}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lovart-chat-promo-close"
|
||||||
|
aria-label="关闭优惠广告"
|
||||||
|
onClick={onDismiss}
|
||||||
|
>
|
||||||
|
<ReferenceIcon name="close" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
components/canvas/CanvasGroupToolbar.tsx
Normal file
125
components/canvas/CanvasGroupToolbar.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Play,
|
||||||
|
Ungroup,
|
||||||
|
Share2,
|
||||||
|
Grid3X3,
|
||||||
|
ScanLine,
|
||||||
|
} from "lucide-react";
|
||||||
|
export function CanvasGroupToolbar({
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
status,
|
||||||
|
busy,
|
||||||
|
storyboard,
|
||||||
|
canStoryboard,
|
||||||
|
onRename,
|
||||||
|
onColor,
|
||||||
|
onLayout,
|
||||||
|
onRun,
|
||||||
|
onUngroup,
|
||||||
|
onSave,
|
||||||
|
onDownload,
|
||||||
|
onStoryboard,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
status: string;
|
||||||
|
busy: boolean;
|
||||||
|
storyboard: boolean;
|
||||||
|
canStoryboard: boolean;
|
||||||
|
onRename: (s: string) => void;
|
||||||
|
onColor: (s: string) => void;
|
||||||
|
onLayout: (s: "grid" | "horizontal" | "vertical") => void;
|
||||||
|
onRun: () => void;
|
||||||
|
onUngroup: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
onStoryboard: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-canvas-overlay="true"
|
||||||
|
data-group-toolbar="true"
|
||||||
|
className="absolute left-1/2 top-[80px] z-[70] -translate-x-1/2 rounded-[22px] border border-white/10 bg-[#252525] p-4 text-white shadow-xl"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-5 whitespace-nowrap text-[16px]">
|
||||||
|
<input
|
||||||
|
aria-label="编组颜色"
|
||||||
|
type="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => onColor(e.target.value)}
|
||||||
|
className="h-7 w-7 cursor-pointer rounded-full border-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<label title="编组布局" className="flex items-center gap-1">
|
||||||
|
<Grid3X3 size={20} />
|
||||||
|
<select
|
||||||
|
aria-label="编组布局"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
onLayout(e.target.value as any);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
className="w-5 bg-[#252525]"
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
⌄
|
||||||
|
</option>
|
||||||
|
<option value="grid">网格布局</option>
|
||||||
|
<option value="horizontal">横向布局</option>
|
||||||
|
<option value="vertical">纵向布局</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="h-8 w-px bg-white/10" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onRun}
|
||||||
|
className="flex items-center gap-2 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Play size={22} />
|
||||||
|
整组执行
|
||||||
|
</button>
|
||||||
|
<button onClick={onSave} className="flex items-center gap-2">
|
||||||
|
<Share2 size={22} />
|
||||||
|
添加到工具箱
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canStoryboard}
|
||||||
|
title={
|
||||||
|
canStoryboard ? "" : "分镜组需要全部成员都是已就绪的图片或视频"
|
||||||
|
}
|
||||||
|
onClick={onStoryboard}
|
||||||
|
className="flex items-center gap-2 disabled:opacity-35"
|
||||||
|
>
|
||||||
|
<ScanLine size={22} />
|
||||||
|
{storyboard ? "预览分镜" : "转分镜组"}
|
||||||
|
</button>
|
||||||
|
<button onClick={onUngroup} className="flex items-center gap-2">
|
||||||
|
<Ungroup size={22} />
|
||||||
|
解组
|
||||||
|
</button>
|
||||||
|
<span className="h-8 w-px bg-white/10" />
|
||||||
|
<button title="下载编组模板" onClick={onDownload}>
|
||||||
|
<Download size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-4 text-xs text-white/55">
|
||||||
|
<input
|
||||||
|
aria-label="编组名称"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onRename(e.target.value)}
|
||||||
|
maxLength={120}
|
||||||
|
className="w-48 border-b border-white/10 bg-transparent py-1"
|
||||||
|
/>
|
||||||
|
<span>{status || "Shift 点击多选 · ⌘/Ctrl+G 编组"}</span>
|
||||||
|
{busy && <button onClick={onCancel}>取消未开始任务</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
953
components/canvas/CanvasLayoutReplaceBatchDialog.tsx
Normal file
953
components/canvas/CanvasLayoutReplaceBatchDialog.tsx
Normal file
@ -0,0 +1,953 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleAlert,
|
||||||
|
ImagePlus,
|
||||||
|
Images,
|
||||||
|
LayoutTemplate,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
getAvailableCanvasLayoutReplaceSources,
|
||||||
|
getLayoutReplaceSourceIdentity,
|
||||||
|
getReadyCanvasLayoutReplaceSource,
|
||||||
|
type LayoutReplaceSource,
|
||||||
|
mergeUniqueLayoutReplaceSources,
|
||||||
|
} from "@/lib/canvas/layout-replace-source-selection";
|
||||||
|
import {
|
||||||
|
imageGenerationService,
|
||||||
|
type CanvasLayoutReplaceBatchItemSnapshot,
|
||||||
|
type CanvasLayoutReplaceBatchSnapshot,
|
||||||
|
} from "@/services/canvas";
|
||||||
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
|
import type { CanvasNode, Size } from "@/types/canvas";
|
||||||
|
|
||||||
|
const MAX_BATCH_SOURCES = 5;
|
||||||
|
const ACTIVE_BATCH_STATUSES = new Set(["pending", "running"]);
|
||||||
|
|
||||||
|
type CanvasLayoutReplaceBatchDialogProps = {
|
||||||
|
costLabel?: string | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
open: boolean;
|
||||||
|
primaryNode: CanvasNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isHttpImageUrl(value: unknown) {
|
||||||
|
return /^https?:\/\//i.test(String(value || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialSources(
|
||||||
|
primaryNode: CanvasNode,
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
selectedNodeIds: string[]
|
||||||
|
) {
|
||||||
|
const selectedById = new Map(nodes.map((node) => [node.id, node]));
|
||||||
|
const orderedIds = [primaryNode.id, ...selectedNodeIds.filter((id) => id !== primaryNode.id)];
|
||||||
|
const sources: LayoutReplaceSource[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const id of orderedIds) {
|
||||||
|
const node = id === primaryNode.id ? primaryNode : selectedById.get(id);
|
||||||
|
if (!node) continue;
|
||||||
|
const source = getReadyCanvasLayoutReplaceSource(node);
|
||||||
|
if (!source) continue;
|
||||||
|
const identity = getLayoutReplaceSourceIdentity(source);
|
||||||
|
if (seen.has(identity)) continue;
|
||||||
|
seen.add(identity);
|
||||||
|
sources.push(source);
|
||||||
|
if (sources.length >= MAX_BATCH_SOURCES) break;
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourcesFromBatch(batch: CanvasLayoutReplaceBatchSnapshot) {
|
||||||
|
return batch.items.map((item) => ({
|
||||||
|
id: item.sourceNodeId ? `canvas:${item.sourceNodeId}` : `upload:${item.id}`,
|
||||||
|
imageUrl: item.sourceImageUrl,
|
||||||
|
kind: item.sourceKind === "upload" ? ("upload" as const) : ("canvas" as const),
|
||||||
|
nodeId: item.sourceNodeId || undefined,
|
||||||
|
title: item.sourceTitle || `图片 ${item.position}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatchStatusLabel(status: string) {
|
||||||
|
if (status === "succeeded") return "全部完成";
|
||||||
|
if (status === "partial_failed") return "部分失败";
|
||||||
|
if (status === "failed") return "处理失败";
|
||||||
|
if (status === "running") return "处理中";
|
||||||
|
return "排队中";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemStatusLabel(item: CanvasLayoutReplaceBatchItemSnapshot) {
|
||||||
|
if (item.status === "succeeded") return item.addedToCanvas ? "已加入画布" : "生成完成";
|
||||||
|
if (item.status === "failed") return "生成失败";
|
||||||
|
if (item.status === "running") return "正在生成";
|
||||||
|
return "排队中";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImageSize(url: string, fallback: Size) {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
return new Promise<Size>((resolve) => {
|
||||||
|
const image = new window.Image();
|
||||||
|
const timeout = window.setTimeout(() => resolve(fallback), 6000);
|
||||||
|
image.onload = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
const naturalWidth = image.naturalWidth || fallback.width;
|
||||||
|
const naturalHeight = image.naturalHeight || fallback.height;
|
||||||
|
const width = Math.max(180, Math.min(720, fallback.width || 360));
|
||||||
|
resolve({
|
||||||
|
width,
|
||||||
|
height: Math.max(120, Math.min(1000, Math.round((width * naturalHeight) / naturalWidth))),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
resolve(fallback);
|
||||||
|
};
|
||||||
|
image.src = buildImageProxyUrl(url, { delivery: "proxy" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasLayoutReplaceBatchDialog({
|
||||||
|
costLabel,
|
||||||
|
onOpenChange,
|
||||||
|
open,
|
||||||
|
primaryNode,
|
||||||
|
}: CanvasLayoutReplaceBatchDialogProps) {
|
||||||
|
const allNodes = useCanvasStore((state) => state.nodes);
|
||||||
|
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
|
||||||
|
const projectId = useCanvasStore((state) => state.projectId);
|
||||||
|
const addNode = useCanvasStore((state) => state.addNode);
|
||||||
|
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||||
|
const canvasImageSelectionSession = useCanvasStore(
|
||||||
|
(state) => state.canvasImageSelectionSession
|
||||||
|
);
|
||||||
|
const startCanvasImageSelection = useCanvasStore(
|
||||||
|
(state) => state.startCanvasImageSelection
|
||||||
|
);
|
||||||
|
const endCanvasImageSelection = useCanvasStore(
|
||||||
|
(state) => state.endCanvasImageSelection
|
||||||
|
);
|
||||||
|
const sourceUploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const referenceUploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const seenSucceededItemIdsRef = useRef(new Set<string>());
|
||||||
|
const [sources, setSources] = useState<LayoutReplaceSource[]>(() =>
|
||||||
|
buildInitialSources(primaryNode, allNodes, selectedNodeIds)
|
||||||
|
);
|
||||||
|
const [referenceImageUrl, setReferenceImageUrl] = useState("");
|
||||||
|
const [supplementalPrompt, setSupplementalPrompt] = useState("");
|
||||||
|
const [batch, setBatch] = useState<CanvasLayoutReplaceBatchSnapshot | null>(null);
|
||||||
|
const [selectedResultIds, setSelectedResultIds] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoadingBatch, setIsLoadingBatch] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isUploadingSources, setIsUploadingSources] = useState(false);
|
||||||
|
const [isUploadingReference, setIsUploadingReference] = useState(false);
|
||||||
|
const [isAddingResults, setIsAddingResults] = useState(false);
|
||||||
|
const [retryingItemId, setRetryingItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const readyCanvasSources = useMemo(
|
||||||
|
() =>
|
||||||
|
allNodes
|
||||||
|
.map(getReadyCanvasLayoutReplaceSource)
|
||||||
|
.filter(Boolean) as LayoutReplaceSource[],
|
||||||
|
[allNodes]
|
||||||
|
);
|
||||||
|
const activeCanvasImageSelectionSession =
|
||||||
|
canvasImageSelectionSession?.ownerNodeId === primaryNode.id
|
||||||
|
? canvasImageSelectionSession
|
||||||
|
: null;
|
||||||
|
const successfulItems = useMemo(
|
||||||
|
() => batch?.items.filter((item) => item.status === "succeeded" && item.resultImageUrl) || [],
|
||||||
|
[batch]
|
||||||
|
);
|
||||||
|
const batchIsActive = Boolean(batch && ACTIVE_BATCH_STATUSES.has(batch.status));
|
||||||
|
const terminalItemCount = batch
|
||||||
|
? batch.items.filter((item) => item.status === "succeeded" || item.status === "failed").length
|
||||||
|
: 0;
|
||||||
|
const batchProgress = batch
|
||||||
|
? Math.round((terminalItemCount / Math.max(1, batch.totalCount)) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const applyBatchSnapshot = useCallback((nextBatch: CanvasLayoutReplaceBatchSnapshot) => {
|
||||||
|
setBatch(nextBatch);
|
||||||
|
setSources(sourcesFromBatch(nextBatch));
|
||||||
|
setReferenceImageUrl(nextBatch.referenceImageUrl);
|
||||||
|
setSupplementalPrompt(nextBatch.supplementalPrompt || "");
|
||||||
|
const newlySucceeded = nextBatch.items
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.status === "succeeded" &&
|
||||||
|
!item.addedToCanvas &&
|
||||||
|
!seenSucceededItemIdsRef.current.has(item.id)
|
||||||
|
)
|
||||||
|
.map((item) => item.id);
|
||||||
|
if (newlySucceeded.length > 0) {
|
||||||
|
setSelectedResultIds((current) => new Set([...current, ...newlySucceeded]));
|
||||||
|
}
|
||||||
|
nextBatch.items
|
||||||
|
.filter((item) => item.status === "succeeded")
|
||||||
|
.forEach((item) => seenSucceededItemIdsRef.current.add(item.id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadBatch = useCallback(
|
||||||
|
async (batchId: string) => {
|
||||||
|
const result = await imageGenerationService.getLayoutReplaceBatch(batchId);
|
||||||
|
if (result.success && result.data?.batch) {
|
||||||
|
applyBatchSnapshot(result.data.batch);
|
||||||
|
return result.data.batch;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[applyBatchSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const currentCanvas = useCanvasStore.getState();
|
||||||
|
const currentPrimaryNode =
|
||||||
|
currentCanvas.nodes.find((node) => node.id === primaryNode.id) || primaryNode;
|
||||||
|
const initialSources = buildInitialSources(
|
||||||
|
currentPrimaryNode,
|
||||||
|
currentCanvas.nodes,
|
||||||
|
currentCanvas.selectedNodeIds
|
||||||
|
);
|
||||||
|
setSources(initialSources);
|
||||||
|
setSelectedResultIds(new Set());
|
||||||
|
seenSucceededItemIdsRef.current = new Set();
|
||||||
|
if (!projectId) {
|
||||||
|
setBatch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoadingBatch(true);
|
||||||
|
void imageGenerationService
|
||||||
|
.getLatestLayoutReplaceBatch({ projectId, sourceNodeId: primaryNode.id })
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.success && result.data?.batch) {
|
||||||
|
applyBatchSnapshot(result.data.batch);
|
||||||
|
} else {
|
||||||
|
setBatch(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setIsLoadingBatch(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [applyBatchSnapshot, open, primaryNode.id, projectId]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
endCanvasImageSelection(primaryNode.id);
|
||||||
|
},
|
||||||
|
[endCanvasImageSelection, primaryNode.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeCanvasImageSelectionSession) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
event.preventDefault();
|
||||||
|
endCanvasImageSelection(primaryNode.id);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
}, [activeCanvasImageSelectionSession, endCanvasImageSelection, primaryNode.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !batch?.id || !batchIsActive) return;
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void loadBatch(batch.id);
|
||||||
|
}, 2500);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [batch?.id, batchIsActive, loadBatch, open]);
|
||||||
|
|
||||||
|
const startNewBatchDraft = useCallback(() => {
|
||||||
|
setBatch(null);
|
||||||
|
setSelectedResultIds(new Set());
|
||||||
|
seenSucceededItemIdsRef.current = new Set();
|
||||||
|
setSources(buildInitialSources(primaryNode, allNodes, selectedNodeIds));
|
||||||
|
}, [allNodes, primaryNode, selectedNodeIds]);
|
||||||
|
|
||||||
|
const removeSource = useCallback((sourceId: string) => {
|
||||||
|
setSources((current) => current.filter((source) => source.id !== sourceId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startCanvasSourceSelection = useCallback(() => {
|
||||||
|
if (canvasImageSelectionSession) return;
|
||||||
|
const remainingCount = MAX_BATCH_SOURCES - sources.length;
|
||||||
|
if (remainingCount <= 0) {
|
||||||
|
toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const availableSources = getAvailableCanvasLayoutReplaceSources(
|
||||||
|
readyCanvasSources,
|
||||||
|
sources
|
||||||
|
);
|
||||||
|
if (availableSources.length === 0) {
|
||||||
|
toast.info("画布中暂无其他可添加的图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startCanvasImageSelection({
|
||||||
|
ownerNodeId: primaryNode.id,
|
||||||
|
allowedNodeIds: availableSources.flatMap((source) =>
|
||||||
|
source.nodeId ? [source.nodeId] : []
|
||||||
|
),
|
||||||
|
maxCount: remainingCount,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
canvasImageSelectionSession,
|
||||||
|
primaryNode.id,
|
||||||
|
readyCanvasSources,
|
||||||
|
sources,
|
||||||
|
startCanvasImageSelection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cancelCanvasSourceSelection = useCallback(() => {
|
||||||
|
endCanvasImageSelection(primaryNode.id);
|
||||||
|
}, [endCanvasImageSelection, primaryNode.id]);
|
||||||
|
|
||||||
|
const confirmCanvasSourceSelection = useCallback(() => {
|
||||||
|
if (!activeCanvasImageSelectionSession) return;
|
||||||
|
const selectedIds = new Set(activeCanvasImageSelectionSession.selectedNodeIds);
|
||||||
|
const selectedSources = readyCanvasSources.filter(
|
||||||
|
(source) => source.nodeId && selectedIds.has(source.nodeId)
|
||||||
|
);
|
||||||
|
if (selectedSources.length === 0) return;
|
||||||
|
setSources((current) => {
|
||||||
|
return mergeUniqueLayoutReplaceSources(
|
||||||
|
current,
|
||||||
|
selectedSources,
|
||||||
|
MAX_BATCH_SOURCES
|
||||||
|
);
|
||||||
|
});
|
||||||
|
endCanvasImageSelection(primaryNode.id);
|
||||||
|
toast.success(`已从画布添加 ${selectedSources.length} 张图片`);
|
||||||
|
}, [
|
||||||
|
activeCanvasImageSelectionSession,
|
||||||
|
endCanvasImageSelection,
|
||||||
|
primaryNode.id,
|
||||||
|
readyCanvasSources,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleSourceUpload = useCallback(
|
||||||
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.currentTarget.files || []).filter((file) =>
|
||||||
|
file.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
const remaining = Math.max(0, MAX_BATCH_SOURCES - sources.length);
|
||||||
|
if (remaining === 0 || files.length === 0) return;
|
||||||
|
const selectedFiles = files.slice(0, remaining);
|
||||||
|
if (selectedFiles.length < files.length) {
|
||||||
|
toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingSources(true);
|
||||||
|
try {
|
||||||
|
const uploaded: LayoutReplaceSource[] = [];
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
const result = await imageGenerationService.uploadImage(file);
|
||||||
|
if (!result.success || !result.data?.url) {
|
||||||
|
throw new Error(result.error || `${file.name} 上传失败`);
|
||||||
|
}
|
||||||
|
uploaded.push({
|
||||||
|
id: `upload:${crypto.randomUUID()}`,
|
||||||
|
imageUrl: result.data.url,
|
||||||
|
kind: "upload",
|
||||||
|
title: file.name.replace(/\.[^.]+$/, "") || "上传图片",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSources((current) => {
|
||||||
|
return mergeUniqueLayoutReplaceSources(
|
||||||
|
current,
|
||||||
|
uploaded,
|
||||||
|
MAX_BATCH_SOURCES
|
||||||
|
);
|
||||||
|
});
|
||||||
|
toast.success(`已上传 ${uploaded.length} 张图片`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "图片上传失败");
|
||||||
|
} finally {
|
||||||
|
setIsUploadingSources(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sources.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReferenceUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
if (!file || !file.type.startsWith("image/")) return;
|
||||||
|
setIsUploadingReference(true);
|
||||||
|
try {
|
||||||
|
const result = await imageGenerationService.uploadImage(file);
|
||||||
|
if (!result.success || !result.data?.url) {
|
||||||
|
throw new Error(result.error || "目标模版图上传失败");
|
||||||
|
}
|
||||||
|
setReferenceImageUrl(result.data.url);
|
||||||
|
toast.success("目标模版图已上传");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "目标模版图上传失败");
|
||||||
|
} finally {
|
||||||
|
setIsUploadingReference(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!projectId) {
|
||||||
|
toast.error("当前画布尚未保存,无法创建批次");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sources.length === 0) {
|
||||||
|
toast.error("请至少添加一张待处理图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isHttpImageUrl(referenceImageUrl)) {
|
||||||
|
toast.error("请上传目标模版图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSubmitting || isUploadingSources || isUploadingReference) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await imageGenerationService.createLayoutReplaceBatch({
|
||||||
|
projectId,
|
||||||
|
sources: sources.map((source) => ({
|
||||||
|
sourceImageUrl: source.imageUrl,
|
||||||
|
sourceKind: source.kind,
|
||||||
|
sourceNodeId: source.nodeId,
|
||||||
|
sourceTitle: source.title,
|
||||||
|
})),
|
||||||
|
referenceImageUrl,
|
||||||
|
supplementalPrompt,
|
||||||
|
imageSize: "2K",
|
||||||
|
});
|
||||||
|
if (!result.success || !result.data?.batchId) {
|
||||||
|
throw new Error(result.error || "创建批次失败");
|
||||||
|
}
|
||||||
|
const nextBatch = await loadBatch(result.data.batchId);
|
||||||
|
if (!nextBatch) throw new Error("批次已创建,但状态读取失败");
|
||||||
|
toast.success(`已提交 ${sources.length} 张图片,正在顺序处理`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "创建批次失败");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSubmitting,
|
||||||
|
isUploadingReference,
|
||||||
|
isUploadingSources,
|
||||||
|
loadBatch,
|
||||||
|
projectId,
|
||||||
|
referenceImageUrl,
|
||||||
|
sources,
|
||||||
|
supplementalPrompt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const addItemsToCanvas = useCallback(
|
||||||
|
async (items: CanvasLayoutReplaceBatchItemSnapshot[]) => {
|
||||||
|
if (!batch || items.length === 0 || isAddingResults) return;
|
||||||
|
setIsAddingResults(true);
|
||||||
|
try {
|
||||||
|
const created: Array<{ itemId: string; canvasNodeId: string }> = [];
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
if (!item.resultImageUrl || item.addedToCanvas) continue;
|
||||||
|
const sourceNode = allNodes.find((candidate) => candidate.id === item.sourceNodeId);
|
||||||
|
const anchor = sourceNode || primaryNode;
|
||||||
|
const resultSize = await resolveImageSize(item.resultImageUrl, anchor.size);
|
||||||
|
const canvasNodeId = addNode({
|
||||||
|
type: "image",
|
||||||
|
position: {
|
||||||
|
x: anchor.position.x + anchor.size.width + 20,
|
||||||
|
y: anchor.position.y + index * 24,
|
||||||
|
},
|
||||||
|
size: resultSize,
|
||||||
|
data: {
|
||||||
|
src: item.resultImageUrl,
|
||||||
|
thumbnailUrl: item.resultThumbnailUrl || item.resultImageUrl,
|
||||||
|
status: "completed",
|
||||||
|
prompt: "批量排版替换结果",
|
||||||
|
title: `${item.sourceTitle || `图片 ${item.position}`}-排版替换`,
|
||||||
|
originalSrc: item.sourceImageUrl,
|
||||||
|
model: item.resultModelId || undefined,
|
||||||
|
metadata: {
|
||||||
|
editMode: "layout-replace",
|
||||||
|
batchId: batch.id,
|
||||||
|
batchItemId: item.id,
|
||||||
|
taskId: item.taskId,
|
||||||
|
sourceNodeId: item.sourceNodeId,
|
||||||
|
layoutReferenceUrl: batch.referenceImageUrl,
|
||||||
|
layoutReplaceCompletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: item.sourceNodeId ? [item.sourceNodeId] : [],
|
||||||
|
});
|
||||||
|
created.push({ itemId: item.id, canvasNodeId });
|
||||||
|
}
|
||||||
|
if (created.length === 0) return;
|
||||||
|
selectNodes(created.map((item) => item.canvasNodeId));
|
||||||
|
const marked = await imageGenerationService.markLayoutReplaceBatchItemsAdded({
|
||||||
|
batchId: batch.id,
|
||||||
|
items: created,
|
||||||
|
});
|
||||||
|
if (!marked.success) {
|
||||||
|
toast.warning("结果已加入画布,但同步批次状态失败");
|
||||||
|
}
|
||||||
|
await loadBatch(batch.id);
|
||||||
|
setSelectedResultIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
created.forEach((item) => next.delete(item.itemId));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
toast.success(`已将 ${created.length} 张结果加入画布`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "添加到画布失败");
|
||||||
|
} finally {
|
||||||
|
setIsAddingResults(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addNode, allNodes, batch, isAddingResults, loadBatch, primaryNode, selectNodes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedSuccessfulItems = successfulItems.filter(
|
||||||
|
(item) => selectedResultIds.has(item.id) && !item.addedToCanvas
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryItem = useCallback(
|
||||||
|
async (item: CanvasLayoutReplaceBatchItemSnapshot) => {
|
||||||
|
if (!batch || batchIsActive || retryingItemId) return;
|
||||||
|
setRetryingItemId(item.id);
|
||||||
|
try {
|
||||||
|
const result = await imageGenerationService.retryLayoutReplaceBatchItem({
|
||||||
|
batchId: batch.id,
|
||||||
|
itemId: item.id,
|
||||||
|
});
|
||||||
|
if (!result.success) throw new Error(result.error || "重试失败");
|
||||||
|
await loadBatch(batch.id);
|
||||||
|
toast.success("已重新加入生成队列");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "重试失败");
|
||||||
|
} finally {
|
||||||
|
setRetryingItemId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batch, batchIsActive, loadBatch, retryingItemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!activeCanvasImageSelectionSession) onOpenChange(nextOpen);
|
||||||
|
}}
|
||||||
|
open={open && !activeCanvasImageSelectionSession}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
|
||||||
|
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
|
AI 模版替换
|
||||||
|
</DialogTitle>
|
||||||
|
{batch ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{getBatchStatusLabel(batch.status)}</span>
|
||||||
|
<span>{terminalItemCount}/{batch.totalCount}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
最多选择五张图片,按照目标模版顺序生成并添加到画布
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
|
||||||
|
{isLoadingBatch ? (
|
||||||
|
<div className="flex min-h-[360px] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<section className="min-w-0 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label>待处理图片 {sources.length}/{MAX_BATCH_SOURCES}</Label>
|
||||||
|
{!batch ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
sources.length >= MAX_BATCH_SOURCES ||
|
||||||
|
Boolean(canvasImageSelectionSession)
|
||||||
|
}
|
||||||
|
onClick={startCanvasSourceSelection}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Images className="mr-1.5 h-4 w-4" />
|
||||||
|
从画布中添加
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => void handleSourceUpload(event)}
|
||||||
|
ref={sourceUploadInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={isUploadingSources || sources.length >= MAX_BATCH_SOURCES}
|
||||||
|
onClick={() => sourceUploadInputRef.current?.click()}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isUploadingSources ? (
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-1.5 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
上传图片
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{Array.from({ length: MAX_BATCH_SOURCES }, (_, index) => {
|
||||||
|
const source = sources[index];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative aspect-square min-w-0 overflow-hidden rounded-md border bg-muted/25"
|
||||||
|
key={source?.id || `empty-${index}`}
|
||||||
|
>
|
||||||
|
<span className="absolute left-1.5 top-1.5 z-10 flex h-5 min-w-5 items-center justify-center rounded bg-black/65 px-1 text-[10px] text-white">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
{source ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
alt={source.title}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
src={buildImageProxyUrl(source.imageUrl, { width: 240 })}
|
||||||
|
/>
|
||||||
|
{!batch && index > 0 ? (
|
||||||
|
<button
|
||||||
|
aria-label={`移除第 ${index + 1} 张图片`}
|
||||||
|
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/65 text-white hover:bg-black/80"
|
||||||
|
onClick={() => removeSource(source.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
|
||||||
|
{source.title}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
aria-label={`添加第 ${index + 1} 张图片`}
|
||||||
|
className="flex h-full w-full items-center justify-center text-muted-foreground hover:bg-muted/50"
|
||||||
|
disabled={Boolean(batch)}
|
||||||
|
onClick={() => sourceUploadInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`layout-batch-prompt-${primaryNode.id}`}>补充要求</Label>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[88px] max-h-[180px]"
|
||||||
|
disabled={Boolean(batch)}
|
||||||
|
id={`layout-batch-prompt-${primaryNode.id}`}
|
||||||
|
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
|
||||||
|
placeholder="可选,只写产品替换细节"
|
||||||
|
value={supplementalPrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-w-0 space-y-2">
|
||||||
|
<Label htmlFor={`layout-batch-reference-${primaryNode.id}`}>目标模版图</Label>
|
||||||
|
<Input
|
||||||
|
disabled={Boolean(batch)}
|
||||||
|
id={`layout-batch-reference-${primaryNode.id}`}
|
||||||
|
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
value={referenceImageUrl}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => void handleReferenceUpload(event)}
|
||||||
|
ref={referenceUploadInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="flex h-[clamp(180px,34dvh,360px)] w-full items-center justify-center overflow-hidden rounded-md border border-dashed bg-muted/20"
|
||||||
|
disabled={Boolean(batch) || isUploadingReference}
|
||||||
|
onClick={() => referenceUploadInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isUploadingReference ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
) : isHttpImageUrl(referenceImageUrl) ? (
|
||||||
|
<img
|
||||||
|
alt="目标模版图"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
src={buildImageProxyUrl(referenceImageUrl, { width: 720 })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<ImagePlus className="h-6 w-6" />
|
||||||
|
<span className="text-xs">上传目标模版图</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{batch ? (
|
||||||
|
<section className="space-y-3 border-t pt-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>生成结果</Label>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{getBatchStatusLabel(batch.status)} · {terminalItemCount}/{batch.totalCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!batchIsActive ? (
|
||||||
|
<Button onClick={startNewBatchDraft} size="sm" type="button" variant="outline">
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
新建批次
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Progress className="h-1.5" value={batchProgress} />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{batch.items.map((item) => {
|
||||||
|
const canAdd = item.status === "succeeded" && Boolean(item.resultImageUrl) && !item.addedToCanvas;
|
||||||
|
const checked = selectedResultIds.has(item.id);
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-md border" key={item.id}>
|
||||||
|
<div className="grid grid-cols-2 gap-px bg-border">
|
||||||
|
<div className="aspect-square bg-muted">
|
||||||
|
<img
|
||||||
|
alt={item.sourceTitle || `图片 ${item.position}`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
src={buildImageProxyUrl(item.sourceImageUrl, { width: 280 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex aspect-square items-center justify-center bg-muted/40">
|
||||||
|
{item.resultImageUrl ? (
|
||||||
|
<img
|
||||||
|
alt={`结果 ${item.position}`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
src={buildImageProxyUrl(item.resultThumbnailUrl || item.resultImageUrl, { width: 280 })}
|
||||||
|
/>
|
||||||
|
) : item.status === "failed" ? (
|
||||||
|
<CircleAlert className="h-6 w-6 text-destructive" />
|
||||||
|
) : item.status === "running" ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{canAdd ? (
|
||||||
|
<label className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded bg-background/90 shadow-sm">
|
||||||
|
<input
|
||||||
|
checked={checked}
|
||||||
|
className="h-4 w-4"
|
||||||
|
onChange={(event) => {
|
||||||
|
setSelectedResultIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (event.currentTarget.checked) next.add(item.id);
|
||||||
|
else next.delete(item.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-12 items-center justify-between gap-2 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-xs font-medium">
|
||||||
|
{item.sourceTitle || `图片 ${item.position}`}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] text-muted-foreground",
|
||||||
|
item.status === "failed" && "text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getItemStatusLabel(item)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canAdd ? (
|
||||||
|
<Button
|
||||||
|
disabled={isAddingResults}
|
||||||
|
onClick={() => void addItemsToCanvas([item])}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
加入画布
|
||||||
|
</Button>
|
||||||
|
) : item.status === "failed" && !batchIsActive ? (
|
||||||
|
<Button
|
||||||
|
disabled={Boolean(retryingItemId)}
|
||||||
|
onClick={() => void retryItem(item)}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{retryingItemId === item.id ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.error ? (
|
||||||
|
<div className="border-t px-3 py-2 text-xs text-destructive">
|
||||||
|
{item.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 gap-2 border-t bg-background px-4 py-3 sm:px-6">
|
||||||
|
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
{batch ? (
|
||||||
|
<Button
|
||||||
|
disabled={selectedSuccessfulItems.length === 0 || isAddingResults}
|
||||||
|
onClick={() => void addItemsToCanvas(selectedSuccessfulItems)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isAddingResults ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Images className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
添加所选到画布 ({selectedSuccessfulItems.length})
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
sources.length === 0 ||
|
||||||
|
!isHttpImageUrl(referenceImageUrl) ||
|
||||||
|
isSubmitting ||
|
||||||
|
isUploadingReference ||
|
||||||
|
isUploadingSources
|
||||||
|
}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
批量处理 {sources.length} 张
|
||||||
|
{costLabel ? (
|
||||||
|
<span className="ml-1 rounded-full bg-white/18 px-1.5 py-0.5 text-[10px] text-white">
|
||||||
|
{sources.length} × {costLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{activeCanvasImageSelectionSession && typeof document !== "undefined"
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed inset-x-0 bottom-4 z-[10020] flex justify-center px-3 sm:bottom-6"
|
||||||
|
data-canvas-layout-replace-picker="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto flex w-full max-w-[720px] flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2.5 shadow-2xl sm:flex-nowrap sm:px-4"
|
||||||
|
data-canvas-overlay="true"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[#168cff] text-white">
|
||||||
|
<Images className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">选择画布图片</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
已选 {activeCanvasImageSelectionSession.selectedNodeIds.length}/
|
||||||
|
{activeCanvasImageSelectionSession.maxCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={cancelCanvasSourceSelection}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={activeCanvasImageSelectionSession.selectedNodeIds.length === 0}
|
||||||
|
onClick={confirmCanvasSourceSelection}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
确认添加到图片卡槽
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
components/canvas/CanvasSurfaceHeader.tsx
Normal file
95
components/canvas/CanvasSurfaceHeader.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
|
|
||||||
|
import { Loader2, Save } from "lucide-react";
|
||||||
|
|
||||||
|
type CanvasSurfaceHeaderProps = {
|
||||||
|
isSaving: boolean;
|
||||||
|
onAddImage: () => void;
|
||||||
|
onHome: () => void;
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
projectTitle: string;
|
||||||
|
rightInset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconButtonClass =
|
||||||
|
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
||||||
|
|
||||||
|
export function CanvasSurfaceHeader({
|
||||||
|
isSaving,
|
||||||
|
onAddImage,
|
||||||
|
onHome,
|
||||||
|
onOpenFiles,
|
||||||
|
onSave,
|
||||||
|
projectTitle,
|
||||||
|
rightInset,
|
||||||
|
}: CanvasSurfaceHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
|
||||||
|
data-canvas-topbar="true"
|
||||||
|
style={{ right: rightInset }}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
aria-label="返回首页"
|
||||||
|
className={iconButtonClass}
|
||||||
|
onClick={onHome}
|
||||||
|
title="返回首页"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReferenceIcon name="branding" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="添加参考素材"
|
||||||
|
className={iconButtonClass}
|
||||||
|
onClick={onAddImage}
|
||||||
|
title="添加参考素材"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReferenceIcon name="reference" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="查看项目文件"
|
||||||
|
className={iconButtonClass}
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
title="查看项目文件"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReferenceIcon name="brand" size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
|
||||||
|
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
|
||||||
|
<ReferenceIcon name="page" size={16} />
|
||||||
|
<span className="max-w-[160px] truncate">
|
||||||
|
{projectTitle || "页面 1"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-label="添加到画布"
|
||||||
|
className={iconButtonClass}
|
||||||
|
onClick={onAddImage}
|
||||||
|
title="添加到画布"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReferenceIcon name="plus" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onSave}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import {
|
||||||
|
ReferenceIcon,
|
||||||
|
type ReferenceIconName,
|
||||||
|
} from "@/components/lovart/reference-icon";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
|
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -12,13 +16,12 @@ type CanvasToolbarIconProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
referenceName?: ReferenceIconName;
|
||||||
|
referenceSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isSvgIconUrl(url: string) {
|
function isSvgIconUrl(url: string) {
|
||||||
return (
|
return /^data:image\/svg\+xml/i.test(url) || /\.svg(?:[?#].*)?$/i.test(url);
|
||||||
/^data:image\/svg\+xml/i.test(url) ||
|
|
||||||
/\.svg(?:[?#].*)?$/i.test(url)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripImageFilterClasses(className?: string) {
|
function stripImageFilterClasses(className?: string) {
|
||||||
@ -42,6 +45,8 @@ export function CanvasToolbarIcon({
|
|||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
active = false,
|
active = false,
|
||||||
|
referenceName,
|
||||||
|
referenceSize = 18,
|
||||||
}: CanvasToolbarIconProps) {
|
}: CanvasToolbarIconProps) {
|
||||||
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
|
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
|
||||||
|
|
||||||
@ -75,7 +80,9 @@ export function CanvasToolbarIcon({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-contain",
|
"object-contain",
|
||||||
active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert",
|
active
|
||||||
|
? "brightness-0 invert opacity-95"
|
||||||
|
: "brightness-0 opacity-80 dark:invert",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
src={resolvedIconUrl}
|
src={resolvedIconUrl}
|
||||||
@ -84,5 +91,15 @@ export function CanvasToolbarIcon({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (referenceName) {
|
||||||
|
return (
|
||||||
|
<ReferenceIcon
|
||||||
|
name={referenceName}
|
||||||
|
size={referenceSize}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <FallbackIcon className={className} style={style} />;
|
return <FallbackIcon className={className} style={style} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,12 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
|
|||||||
import { VideoNode } from "./nodes/VideoNode";
|
import { VideoNode } from "./nodes/VideoNode";
|
||||||
import { WorkflowNode } from "./nodes/WorkflowNode";
|
import { WorkflowNode } from "./nodes/WorkflowNode";
|
||||||
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
||||||
|
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
|
||||||
|
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
|
||||||
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
import {
|
import {
|
||||||
@ -25,8 +29,10 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
|
LayoutTemplate,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
|
Loader2,
|
||||||
Minus,
|
Minus,
|
||||||
Pin,
|
Pin,
|
||||||
Plus,
|
Plus,
|
||||||
@ -65,6 +71,10 @@ import {
|
|||||||
type CanvasAgentPendingRequestWindow,
|
type CanvasAgentPendingRequestWindow,
|
||||||
} from "@/lib/canvas/agent-pending-requests";
|
} from "@/lib/canvas/agent-pending-requests";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import {
|
||||||
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
|
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||||
|
} from "@/lib/image-upload-limits";
|
||||||
|
|
||||||
type ImageNodeModule = typeof import("./nodes/ImageNode");
|
type ImageNodeModule = typeof import("./nodes/ImageNode");
|
||||||
type Model3DNodeModule = typeof import("./nodes/Model3DNode");
|
type Model3DNodeModule = typeof import("./nodes/Model3DNode");
|
||||||
@ -385,6 +395,11 @@ export function CanvasWorkspace({
|
|||||||
} | null;
|
} | null;
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const contentLayerRef = useRef<HTMLDivElement>(null);
|
const contentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contextImageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const contextVideoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
|
||||||
|
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
|
||||||
|
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||||
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||||
@ -416,6 +431,9 @@ export function CanvasWorkspace({
|
|||||||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||||||
const edges = useCanvasStore(canvasSelectors.edges);
|
const edges = useCanvasStore(canvasSelectors.edges);
|
||||||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||||||
|
const canvasImageSelectionSession = useCanvasStore(
|
||||||
|
(state) => state.canvasImageSelectionSession
|
||||||
|
);
|
||||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||||
const tool = useCanvasStore(canvasSelectors.tool);
|
const tool = useCanvasStore(canvasSelectors.tool);
|
||||||
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
|
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
|
||||||
@ -715,12 +733,17 @@ export function CanvasWorkspace({
|
|||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLocalEdit"
|
"imageLocalEdit"
|
||||||
);
|
);
|
||||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
const selectedImageLayoutReplaceItem = getCanvasToolbarItem(
|
||||||
const selectedImageLocalEditEnabled = true;
|
canvasToolbarConfig,
|
||||||
|
"imageLayoutReplace"
|
||||||
|
);
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||||
|
const toggleCanvasImageSelection = useCanvasStore(
|
||||||
|
(state) => state.toggleCanvasImageSelection
|
||||||
|
);
|
||||||
const clearSelection = useCanvasStore((state) => state.clearSelection);
|
const clearSelection = useCanvasStore((state) => state.clearSelection);
|
||||||
const addNode = useCanvasStore((state) => state.addNode);
|
const addNode = useCanvasStore((state) => state.addNode);
|
||||||
const addEdge = useCanvasStore((state) => state.addEdge);
|
const addEdge = useCanvasStore((state) => state.addEdge);
|
||||||
@ -1089,42 +1112,61 @@ export function CanvasWorkspace({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fitAllImageNodesIntoViewport = useCallback(() => {
|
const fitAllImageNodesIntoViewport = useCallback(() => {
|
||||||
const imageNodes = nodes.filter(
|
const fit = getCanvasFitViewport(
|
||||||
(node) => node.type === "image" && Boolean((node.data as any)?.src)
|
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
|
||||||
|
containerSize, occlusionRight
|
||||||
);
|
);
|
||||||
if (imageNodes.length === 0) return;
|
if (fit) setViewport(fit);
|
||||||
if (containerSize.width <= 0 || containerSize.height <= 0) return;
|
}, [containerSize, nodes, occlusionRight, setViewport]);
|
||||||
|
|
||||||
const padding = 80;
|
const fitAllCanvasNodesIntoViewport = useCallback(() => {
|
||||||
const minX = Math.min(...imageNodes.map((node) => node.position.x));
|
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
|
||||||
const minY = Math.min(...imageNodes.map((node) => node.position.y));
|
if (fit) setViewport(fit);
|
||||||
const maxX = Math.max(
|
}, [containerSize, nodes, occlusionRight, setViewport]);
|
||||||
...imageNodes.map((node) => node.position.x + node.size.width)
|
|
||||||
);
|
|
||||||
const maxY = Math.max(
|
|
||||||
...imageNodes.map((node) => node.position.y + node.size.height)
|
|
||||||
);
|
|
||||||
|
|
||||||
const contentWidth = Math.max(1, maxX - minX + padding * 2);
|
const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => {
|
||||||
const contentHeight = Math.max(1, maxY - minY + padding * 2);
|
if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return;
|
||||||
|
if (!(event.target instanceof Element) || event.target.closest(
|
||||||
|
'[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]'
|
||||||
|
)) return;
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
contextCanvasPointRef.current = {
|
||||||
|
x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom,
|
||||||
|
y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom,
|
||||||
|
};
|
||||||
|
setBlankContextPosition({ x: event.clientX, y: event.clientY });
|
||||||
|
setAgentSlotContextMenu(null);
|
||||||
|
}, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]);
|
||||||
|
|
||||||
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
|
const zoomCanvasFromContextMenu = useCallback((factor: number) => {
|
||||||
const fitZoomX = visibleWidth / contentWidth;
|
const current = useCanvasStore.getState().viewport;
|
||||||
const fitZoomY = containerSize.height / contentHeight;
|
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
|
||||||
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
|
const centerX = Math.max(1, containerSize.width - occlusionRight) / 2;
|
||||||
|
const centerY = containerSize.height / 2;
|
||||||
|
const ratio = zoom / current.zoom;
|
||||||
|
setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom });
|
||||||
|
}, [containerSize, occlusionRight, setViewport]);
|
||||||
|
|
||||||
const centerX = (minX + maxX) / 2;
|
React.useEffect(() => {
|
||||||
const centerY = (minY + maxY) / 2;
|
if (isWorkflowCanvas) return;
|
||||||
const visibleCenterX = visibleWidth / 2;
|
const handleFitShortcut = (event: KeyboardEvent) => {
|
||||||
const nextViewportX = visibleCenterX - centerX * targetZoom;
|
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
|
||||||
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
|
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
|
||||||
|
'input, textarea, [contenteditable="true"], [role="textbox"]'
|
||||||
setViewport({
|
))) return;
|
||||||
x: nextViewportX,
|
const active = document.activeElement;
|
||||||
y: nextViewportY,
|
if (active && active !== document.body && !containerRef.current?.contains(active)
|
||||||
zoom: targetZoom,
|
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
|
||||||
});
|
event.preventDefault();
|
||||||
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
|
fitAllCanvasNodesIntoViewport();
|
||||||
|
setBlankContextPosition(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleFitShortcut);
|
||||||
|
return () => window.removeEventListener("keydown", handleFitShortcut);
|
||||||
|
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
|
||||||
|
|
||||||
const getNodeInputAnchor = useCallback(
|
const getNodeInputAnchor = useCallback(
|
||||||
(node: CanvasNode): Position => ({
|
(node: CanvasNode): Position => ({
|
||||||
@ -1238,8 +1280,10 @@ export function CanvasWorkspace({
|
|||||||
|
|
||||||
// 处理画布点击(取消选择)
|
// 处理画布点击(取消选择)
|
||||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (canvasImageSelectionSession) return;
|
||||||
if (hasBlockingDialogOpen()) return;
|
if (hasBlockingDialogOpen()) return;
|
||||||
setAgentSlotContextMenu(null);
|
setAgentSlotContextMenu(null);
|
||||||
|
setBlankContextPosition(null);
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
if (suppressCanvasClickRef.current) {
|
if (suppressCanvasClickRef.current) {
|
||||||
suppressCanvasClickRef.current = false;
|
suppressCanvasClickRef.current = false;
|
||||||
@ -1256,10 +1300,11 @@ export function CanvasWorkspace({
|
|||||||
fitAllImageNodesIntoViewport();
|
fitAllImageNodesIntoViewport();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
|
}, [canvasImageSelectionSession, clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
|
||||||
|
|
||||||
// 处理画布平移开始
|
// 处理画布平移开始
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (canvasImageSelectionSession) return;
|
||||||
if (hasBlockingDialogOpen()) return;
|
if (hasBlockingDialogOpen()) return;
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
if (connectionDraft) {
|
if (connectionDraft) {
|
||||||
@ -1275,7 +1320,7 @@ export function CanvasWorkspace({
|
|||||||
!isPointOnNodeElement(e.target);
|
!isPointOnNodeElement(e.target);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
tool === 'pan' ||
|
(tool === 'pan' && e.button === 0) ||
|
||||||
e.button === 1 ||
|
e.button === 1 ||
|
||||||
(e.button === 0 && e.altKey) ||
|
(e.button === 0 && e.altKey) ||
|
||||||
shouldPanBlankWorkflowCanvas
|
shouldPanBlankWorkflowCanvas
|
||||||
@ -1305,7 +1350,7 @@ export function CanvasWorkspace({
|
|||||||
setMarqueeStart(start);
|
setMarqueeStart(start);
|
||||||
setMarqueeEnd(start);
|
setMarqueeEnd(start);
|
||||||
}
|
}
|
||||||
}, [cancelPanPreview, clearBrowserTextSelection, clearLatestGeneratedImageHighlights, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]);
|
}, [cancelPanPreview, canvasImageSelectionSession, clearBrowserTextSelection, clearLatestGeneratedImageHighlights, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]);
|
||||||
|
|
||||||
// 处理画布平移
|
// 处理画布平移
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
@ -1554,8 +1599,8 @@ export function CanvasWorkspace({
|
|||||||
for (let index = 0; index < files.length; index += 1) {
|
for (let index = 0; index < files.length; index += 1) {
|
||||||
const file = files[index];
|
const file = files[index];
|
||||||
if (!isImageFile(file)) continue;
|
if (!isImageFile(file)) continue;
|
||||||
if (file.size > 50 * 1024 * 1024) {
|
if (file.size > IMAGE_UPLOAD_MAX_BYTES) {
|
||||||
toast.error("图片大小不能超过 50MB");
|
toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1638,6 +1683,105 @@ export function CanvasWorkspace({
|
|||||||
}
|
}
|
||||||
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
||||||
|
|
||||||
|
const getContextUploadClientPoint = useCallback(() => {
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
const current = viewportRef.current;
|
||||||
|
return {
|
||||||
|
x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom,
|
||||||
|
y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pasteCanvasText = useCallback((text: string, point: Position) => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
addNode({
|
||||||
|
type: "text", position: point, size: { width: 320, height: 160 },
|
||||||
|
data: { content: text, color: "#111827" }, connections: [],
|
||||||
|
});
|
||||||
|
}, [addNode]);
|
||||||
|
|
||||||
|
const uploadContextVideos = useCallback(async (files: File[]) => {
|
||||||
|
if (!files.length || isContextVideoUploading) return;
|
||||||
|
const position = { ...contextCanvasPointRef.current };
|
||||||
|
setIsContextVideoUploading(true);
|
||||||
|
try {
|
||||||
|
for (const [index, file] of files.entries()) {
|
||||||
|
if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) {
|
||||||
|
toast.error("请选择 MP4、MOV 或 WebM 视频");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
toast.error("视频文件不能超过 50MB");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const loadingToast = toast.loading(`正在上传 ${file.name}`);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("purpose", "canvas-video-process");
|
||||||
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败");
|
||||||
|
addNode({
|
||||||
|
type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 },
|
||||||
|
size: { width: 320, height: 180 },
|
||||||
|
data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name,
|
||||||
|
metadata: { source: "local-upload", fileName: file.name, contentType: file.type } },
|
||||||
|
connections: [],
|
||||||
|
});
|
||||||
|
toast.success("视频已添加到画布");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "视频上传失败,请重试");
|
||||||
|
} finally {
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsContextVideoUploading(false);
|
||||||
|
}
|
||||||
|
}, [addNode, isContextVideoUploading]);
|
||||||
|
|
||||||
|
const pasteFromContextMenu = useCallback(async () => {
|
||||||
|
const pastePoint = { ...contextCanvasPointRef.current };
|
||||||
|
const clientPoint = getContextUploadClientPoint();
|
||||||
|
try {
|
||||||
|
const files: File[] = [];
|
||||||
|
let text = "";
|
||||||
|
if (typeof navigator.clipboard?.read === "function") {
|
||||||
|
const items = await navigator.clipboard.read();
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
const imageType = item.types.find((type) => type.startsWith("image/"));
|
||||||
|
if (imageType) {
|
||||||
|
const blob = await item.getType(imageType);
|
||||||
|
files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType }));
|
||||||
|
} else if (item.types.includes("text/plain")) {
|
||||||
|
text += await (await item.getType("text/plain")).text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof navigator.clipboard?.readText === "function") {
|
||||||
|
text = await navigator.clipboard.readText();
|
||||||
|
} else {
|
||||||
|
throw new Error("Clipboard unavailable");
|
||||||
|
}
|
||||||
|
if (files.length) {
|
||||||
|
await processDroppedFiles(files, clientPoint.x, clientPoint.y);
|
||||||
|
} else if (text.trim()) {
|
||||||
|
pasteCanvasText(text, pastePoint);
|
||||||
|
} else {
|
||||||
|
toast.info("剪贴板中没有可粘贴的图片或文字");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`);
|
||||||
|
}
|
||||||
|
}, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!blankContextPosition) return;
|
||||||
|
const close = () => setBlankContextPosition(null);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
return () => window.removeEventListener("resize", close);
|
||||||
|
}, [blankContextPosition]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleWindowDrop = async (e: DragEvent) => {
|
const handleWindowDrop = async (e: DragEvent) => {
|
||||||
if (!isFileDrag(e.dataTransfer)) return;
|
if (!isFileDrag(e.dataTransfer)) return;
|
||||||
@ -1700,24 +1844,44 @@ export function CanvasWorkspace({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return;
|
const activeElement = document.activeElement;
|
||||||
|
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
|
||||||
|
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const activeElement = document.activeElement;
|
|
||||||
if (
|
if (
|
||||||
activeElement instanceof HTMLElement &&
|
activeElement instanceof HTMLElement &&
|
||||||
activeElement !== document.body &&
|
activeElement !== document.body &&
|
||||||
!container.contains(activeElement)
|
!container.contains(activeElement) && !fromContextMenu
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = extractImageFilesFromDataTransfer(e.clipboardData);
|
const files = extractImageFilesFromDataTransfer(e.clipboardData);
|
||||||
if (files.length === 0) return;
|
const text = e.clipboardData?.getData("text/plain") || "";
|
||||||
|
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
|
if (fromContextMenu) {
|
||||||
|
setBlankContextPosition(null);
|
||||||
|
if (files.length) {
|
||||||
|
const point = getContextUploadClientPoint();
|
||||||
|
await processDroppedFiles(files, point.x, point.y);
|
||||||
|
} else {
|
||||||
|
pasteCanvasText(text, { ...contextCanvasPointRef.current });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
const current = viewportRef.current;
|
||||||
|
pasteCanvasText(text, {
|
||||||
|
x: (rect.width / 2 - current.x) / current.zoom,
|
||||||
|
y: (rect.height / 2 - current.y) / current.zoom,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await processDroppedFiles(
|
await processDroppedFiles(
|
||||||
files,
|
files,
|
||||||
rect.left + rect.width / 2,
|
rect.left + rect.width / 2,
|
||||||
@ -1727,7 +1891,7 @@ export function CanvasWorkspace({
|
|||||||
|
|
||||||
window.addEventListener("paste", handlePaste);
|
window.addEventListener("paste", handlePaste);
|
||||||
return () => window.removeEventListener("paste", handlePaste);
|
return () => window.removeEventListener("paste", handlePaste);
|
||||||
}, [hasBlockingDialogOpen, processDroppedFiles]);
|
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
|
||||||
|
|
||||||
const handleDownloadSelectedImages = useCallback(async () => {
|
const handleDownloadSelectedImages = useCallback(async () => {
|
||||||
if (selectedImageNodes.length === 0) {
|
if (selectedImageNodes.length === 0) {
|
||||||
@ -1824,7 +1988,14 @@ export function CanvasWorkspace({
|
|||||||
|
|
||||||
// 渲染节点
|
// 渲染节点
|
||||||
const renderNode = (node: CanvasNode) => {
|
const renderNode = (node: CanvasNode) => {
|
||||||
const isSelected = selectedNodeIds.includes(node.id);
|
const isCanvasImageSelectionCandidate = Boolean(
|
||||||
|
canvasImageSelectionSession?.allowedNodeIds.includes(node.id)
|
||||||
|
);
|
||||||
|
const isCanvasImageSelectionSelected = Boolean(
|
||||||
|
canvasImageSelectionSession?.selectedNodeIds.includes(node.id)
|
||||||
|
);
|
||||||
|
const isSelected =
|
||||||
|
!canvasImageSelectionSession && selectedNodeIds.includes(node.id);
|
||||||
const showWorkflowHandles = isWorkflowCanvas && (tool === "draw" || isSelected);
|
const showWorkflowHandles = isWorkflowCanvas && (tool === "draw" || isSelected);
|
||||||
const isImageNode = node.type === "image";
|
const isImageNode = node.type === "image";
|
||||||
const enableImageResizeHandles =
|
const enableImageResizeHandles =
|
||||||
@ -2021,13 +2192,17 @@ export function CanvasWorkspace({
|
|||||||
pushHistory();
|
pushHistory();
|
||||||
}}
|
}}
|
||||||
bounds="parent"
|
bounds="parent"
|
||||||
enableResizing={enableImageResizeHandles}
|
enableResizing={canvasImageSelectionSession ? false : enableImageResizeHandles}
|
||||||
resizeHandleStyles={imageResizeHandleStyles}
|
resizeHandleStyles={imageResizeHandleStyles}
|
||||||
disableDragging={tool !== 'select'}
|
disableDragging={tool !== 'select' || Boolean(canvasImageSelectionSession)}
|
||||||
cancel={CANVAS_DRAG_CANCEL_SELECTOR}
|
cancel={CANVAS_DRAG_CANCEL_SELECTOR}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute",
|
"absolute",
|
||||||
tool === "select" && "cursor-pointer"
|
canvasImageSelectionSession
|
||||||
|
? isCanvasImageSelectionCandidate
|
||||||
|
? "cursor-crosshair"
|
||||||
|
: "cursor-default"
|
||||||
|
: tool === "select" && "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
data-canvas-node-id={node.id}
|
data-canvas-node-id={node.id}
|
||||||
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
|
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
|
||||||
@ -2038,6 +2213,12 @@ export function CanvasWorkspace({
|
|||||||
}}
|
}}
|
||||||
onClick={(e: React.MouseEvent) => {
|
onClick={(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (canvasImageSelectionSession) {
|
||||||
|
if (isCanvasImageSelectionCandidate) {
|
||||||
|
toggleCanvasImageSelection(node.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAgentSlotContextMenu(null);
|
setAgentSlotContextMenu(null);
|
||||||
clearLatestGeneratedImageHighlights();
|
clearLatestGeneratedImageHighlights();
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
@ -2172,6 +2353,29 @@ export function CanvasWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
{isCanvasImageSelectionCandidate ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-0 z-[70] border-2",
|
||||||
|
isCanvasImageSelectionSelected
|
||||||
|
? "border-[#168cff] bg-[#168cff]/10"
|
||||||
|
: "border-dashed border-[#168cff]/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full border-2 shadow-sm",
|
||||||
|
isCanvasImageSelectionSelected
|
||||||
|
? "border-[#168cff] bg-[#168cff] text-white"
|
||||||
|
: "border-white bg-white/95 text-[#168cff]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCanvasImageSelectionSelected ? (
|
||||||
|
<Check className="h-4 w-4" strokeWidth={3} />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Rnd>
|
</Rnd>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -2196,7 +2400,8 @@ export function CanvasWorkspace({
|
|||||||
isWorkflowCanvas ? "bg-[#050607] text-white" : "bg-background",
|
isWorkflowCanvas ? "bg-[#050607] text-white" : "bg-background",
|
||||||
tool === 'pan' && "cursor-grab",
|
tool === 'pan' && "cursor-grab",
|
||||||
tool === 'draw' && "cursor-crosshair",
|
tool === 'draw' && "cursor-crosshair",
|
||||||
isPanning && "cursor-grabbing"
|
isPanning && "cursor-grabbing",
|
||||||
|
canvasImageSelectionSession && "cursor-crosshair"
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
isWorkflowCanvas
|
isWorkflowCanvas
|
||||||
@ -2209,12 +2414,47 @@ export function CanvasWorkspace({
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onDoubleClick={handleBlankCanvasDoubleClick}
|
onDoubleClick={handleBlankCanvasDoubleClick}
|
||||||
|
onContextMenu={handleBlankCanvasContextMenu}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
|
{!isWorkflowCanvas && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
|
||||||
|
aria-label="从右键菜单上传图片"
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = "";
|
||||||
|
const point = getContextUploadClientPoint();
|
||||||
|
void processDroppedFiles(files, point.x, point.y);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
|
||||||
|
aria-label="从右键菜单上传视频"
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = "";
|
||||||
|
void uploadContextVideos(files);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CanvasBlankContextMenu
|
||||||
|
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
|
||||||
|
onClose={() => setBlankContextPosition(null)}
|
||||||
|
onUploadImage={() => contextImageInputRef.current?.click()}
|
||||||
|
onUploadVideo={() => contextVideoInputRef.current?.click()}
|
||||||
|
onPaste={() => { void pasteFromContextMenu(); }}
|
||||||
|
onZoomIn={() => zoomCanvasFromContextMenu(1.2)}
|
||||||
|
onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)}
|
||||||
|
onFitAll={fitAllCanvasNodesIntoViewport}
|
||||||
|
onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isWorkflowCanvas ? (
|
{isWorkflowCanvas ? (
|
||||||
<>
|
<>
|
||||||
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
|
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
|
||||||
@ -2494,7 +2734,9 @@ export function CanvasWorkspace({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 选中图片快捷操作 */}
|
{/* 选中图片快捷操作 */}
|
||||||
{selectedPendingImageUploadCount > 0 && selectedImageNodes.length === 0 && (
|
{!canvasImageSelectionSession &&
|
||||||
|
selectedPendingImageUploadCount > 0 &&
|
||||||
|
selectedImageNodes.length === 0 && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-16 left-4 z-30 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 shadow-sm"
|
className="absolute bottom-16 left-4 z-30 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 shadow-sm"
|
||||||
data-canvas-overlay
|
data-canvas-overlay
|
||||||
@ -2502,12 +2744,16 @@ export function CanvasWorkspace({
|
|||||||
图片仍在本地,上传完成后可进行 AI 处理
|
图片仍在本地,上传完成后可进行 AI 处理
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedImageNodes.length > 0 && (
|
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
|
className={cn(
|
||||||
|
"absolute left-4 z-30 flex items-center gap-2",
|
||||||
|
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
|
||||||
|
)}
|
||||||
data-canvas-overlay
|
data-canvas-overlay
|
||||||
|
data-canvas-selection-actions="true"
|
||||||
>
|
>
|
||||||
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
|
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -2529,27 +2775,77 @@ export function CanvasWorkspace({
|
|||||||
{selectedImageLocalEditItem.label}
|
{selectedImageLocalEditItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
{isWorkflowCanvas &&
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
selectedImageNodes.length >= 1 ? (
|
||||||
size="sm"
|
<Button
|
||||||
className={cn(
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
|
size="sm"
|
||||||
isWorkflowCanvas &&
|
className={cn(
|
||||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
|
||||||
)}
|
isWorkflowCanvas &&
|
||||||
onClick={handleDownloadSelectedImages}
|
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
disabled={isDownloadSubmitting}
|
)}
|
||||||
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
|
onClick={() =>
|
||||||
>
|
requestImageNodeAction(selectedImageNodes[0].id, "layout-replace", {
|
||||||
<CanvasToolbarIcon
|
args: {
|
||||||
className="h-3.5 w-3.5"
|
sourceNodeIds: selectedImageNodes
|
||||||
fallback={Download}
|
.slice(0, 5)
|
||||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
.map((selectedNode) => selectedNode.id),
|
||||||
/>
|
},
|
||||||
{isDownloadSubmitting
|
})
|
||||||
? "打包中..."
|
}
|
||||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
title={selectedImageLayoutReplaceItem.label}
|
||||||
</Button>
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fallback={LayoutTemplate}
|
||||||
|
item={selectedImageLayoutReplaceItem}
|
||||||
|
/>
|
||||||
|
{selectedImageLayoutReplaceItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
|
size={isWorkflowCanvas ? "sm" : "icon"}
|
||||||
|
className={cn(
|
||||||
|
isWorkflowCanvas
|
||||||
|
? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
|
: "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10"
|
||||||
|
)}
|
||||||
|
onClick={handleDownloadSelectedImages}
|
||||||
|
disabled={isDownloadSubmitting}
|
||||||
|
aria-label={
|
||||||
|
isDownloadSubmitting
|
||||||
|
? "打包中..."
|
||||||
|
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isWorkflowCanvas && isDownloadSubmitting ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
|
||||||
|
fallback={Download}
|
||||||
|
referenceName={isWorkflowCanvas ? undefined : "image-download"}
|
||||||
|
referenceSize={20}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isWorkflowCanvas
|
||||||
|
? isDownloadSubmitting
|
||||||
|
? "打包中..."
|
||||||
|
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
||||||
|
: null}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{isDownloadSubmitting
|
||||||
|
? "打包中..."
|
||||||
|
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
122
components/canvas/GroupStoryboardPreview.tsx
Normal file
122
components/canvas/GroupStoryboardPreview.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight, X } from "lucide-react";
|
||||||
|
export type GroupShot = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
type: "image" | "video";
|
||||||
|
};
|
||||||
|
export function GroupStoryboardPreview({
|
||||||
|
shots,
|
||||||
|
onOrder,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
shots: GroupShot[];
|
||||||
|
onOrder: (ids: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [index, setIndex] = useState(0),
|
||||||
|
shot = shots[Math.min(index, shots.length - 1)];
|
||||||
|
useEffect(() => {
|
||||||
|
const key = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", key);
|
||||||
|
return () => window.removeEventListener("keydown", key);
|
||||||
|
}, [onClose]);
|
||||||
|
if (!shot) return null;
|
||||||
|
const move = (delta: number) => {
|
||||||
|
const next = index + delta;
|
||||||
|
if (next < 0 || next >= shots.length) return;
|
||||||
|
const ids = shots.map((s) => s.id);
|
||||||
|
[ids[index], ids[next]] = [ids[next], ids[index]];
|
||||||
|
onOrder(ids);
|
||||||
|
setIndex(next);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-canvas-overlay="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="编组分镜预览"
|
||||||
|
className="fixed inset-0 z-[120] flex flex-col bg-black/95 p-8 text-white"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="mb-4 flex items-center justify-between">
|
||||||
|
<h2>
|
||||||
|
分镜 {index + 1}/{shots.length} · {shot.title}
|
||||||
|
</h2>
|
||||||
|
<button aria-label="关闭分镜预览" onClick={onClose}>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
{shot.type === "video" ? (
|
||||||
|
<video
|
||||||
|
key={shot.id}
|
||||||
|
src={shot.url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="h-full max-w-full object-contain"
|
||||||
|
onEnded={() => setIndex((i) => Math.min(i + 1, shots.length - 1))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={shot.url}
|
||||||
|
alt={shot.title}
|
||||||
|
className="h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => setIndex((i) => i - 1)}
|
||||||
|
className="disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{index + 1} / {shots.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === shots.length - 1}
|
||||||
|
onClick={() => setIndex((i) => i + 1)}
|
||||||
|
className="disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => move(-1)}
|
||||||
|
className="disabled:opacity-30"
|
||||||
|
>
|
||||||
|
镜头前移
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === shots.length - 1}
|
||||||
|
onClick={() => move(1)}
|
||||||
|
className="disabled:opacity-30"
|
||||||
|
>
|
||||||
|
镜头后移
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-center gap-3 overflow-x-auto">
|
||||||
|
{shots.map((s, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
className={`shrink-0 rounded-lg border px-4 py-2 text-sm ${i === index ? "border-white bg-white/20" : "border-white/20"}`}
|
||||||
|
>
|
||||||
|
{i + 1} · {s.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
78
components/canvas/PromptTranslateButton.tsx
Normal file
78
components/canvas/PromptTranslateButton.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
export function PromptTranslateButton({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
prompt: string;
|
||||||
|
model?: string;
|
||||||
|
onApply: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false),
|
||||||
|
[result, setResult] = useState("");
|
||||||
|
const controller = useRef<AbortController | null>(null);
|
||||||
|
useEffect(() => () => controller.current?.abort(), []);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !prompt.trim()}
|
||||||
|
className="rounded px-2 py-1 text-sm disabled:opacity-40"
|
||||||
|
onClick={async () => {
|
||||||
|
const request = new AbortController();
|
||||||
|
controller.current = request;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/canvas/prompt-tools", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: "translate", prompt, model }),
|
||||||
|
signal: AbortSignal.any([request.signal, AbortSignal.timeout(100000)]),
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok || !body.success || !body.data?.text)
|
||||||
|
throw Error(body.error || "翻译没有返回结果");
|
||||||
|
if (!request.signal.aborted) setResult(body.data.text);
|
||||||
|
} catch (error) {
|
||||||
|
if (!request.signal.aborted)
|
||||||
|
toast.error(error instanceof Error ? error.message : "翻译失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "翻译中…" : "翻译"}
|
||||||
|
</button>
|
||||||
|
{result ? (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="翻译结果"
|
||||||
|
className="fixed inset-4 z-[700] flex flex-col gap-3 rounded-xl border border-white/20 bg-[#222] p-5 text-white"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
aria-label="翻译内容"
|
||||||
|
className="min-h-0 flex-1 resize-none bg-transparent"
|
||||||
|
value={result}
|
||||||
|
onChange={(event) => setResult(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" onClick={() => setResult("")}>
|
||||||
|
放弃
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onApply(result);
|
||||||
|
setResult("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
应用翻译
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
components/canvas/ReferenceImageToolbar.tsx
Normal file
452
components/canvas/ReferenceImageToolbar.tsx
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
|
forwardRef,
|
||||||
|
isValidElement,
|
||||||
|
useState,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||||
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
saveImageToolbarPreferences,
|
||||||
|
useImageToolbarPreferences,
|
||||||
|
type ImageToolbarPreferences,
|
||||||
|
} from "@/lib/canvas-image-toolbar-preferences";
|
||||||
|
|
||||||
|
type ToolProps = HTMLAttributes<HTMLButtonElement> & {
|
||||||
|
"data-tool-key"?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
type Tool = ReactElement<ToolProps>;
|
||||||
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
referencePresentation: boolean;
|
||||||
|
defaultPinnedKeys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function toolLabel(tool: Tool) {
|
||||||
|
return tool.props.title || "";
|
||||||
|
}
|
||||||
|
function toolIcon(tool: Tool, size: number) {
|
||||||
|
const icon = Children.toArray(tool.props.children).find(isValidElement);
|
||||||
|
if (
|
||||||
|
!isValidElement<{
|
||||||
|
className?: string;
|
||||||
|
style?: object;
|
||||||
|
referenceSize?: number;
|
||||||
|
}>(icon)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return cloneElement(icon, {
|
||||||
|
className: "shrink-0",
|
||||||
|
style: { width: size, height: size },
|
||||||
|
...(icon.type === CanvasToolbarIcon ? { referenceSize: size } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toolCredit(tool: Tool) {
|
||||||
|
return Children.toArray(tool.props.children).find(
|
||||||
|
(child) =>
|
||||||
|
isValidElement<{ compact?: boolean; label?: string }>(child) &&
|
||||||
|
child.props.compact &&
|
||||||
|
child.props.label
|
||||||
|
) as
|
||||||
|
| ReactElement<{ compact?: boolean; label?: string; className?: string }>
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
function tooltipButton(tool: Tool, showNames: boolean) {
|
||||||
|
const name = toolLabel(tool);
|
||||||
|
// Clone the existing action so its handler, busy state and administrator icon override remain intact.
|
||||||
|
const button = cloneElement(tool, {
|
||||||
|
title: undefined,
|
||||||
|
"aria-label": name,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{toolIcon(tool, 25)}
|
||||||
|
{showNames && <span className="lovart-image-tool-label">{name}</span>}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Tooltip key={tool.props["data-tool-key"] || name}>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="lovart-image-tool-tooltip"
|
||||||
|
side="top"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
{toolCredit(tool) &&
|
||||||
|
cloneElement(toolCredit(tool)!, {
|
||||||
|
compact: false,
|
||||||
|
className: "lovart-image-tool-cost",
|
||||||
|
})}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReferenceImageToolbar = forwardRef<HTMLDivElement, Props>(
|
||||||
|
function ReferenceImageToolbar(
|
||||||
|
{ referencePresentation, defaultPinnedKeys, children, ...rootProps },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const saved = useImageToolbarPreferences();
|
||||||
|
const [draft, setDraft] = useState<ImageToolbarPreferences | null>(null);
|
||||||
|
const [draggedKey, setDraggedKey] = useState<string | null>(null);
|
||||||
|
const elements = Children.toArray(children).filter(
|
||||||
|
isValidElement
|
||||||
|
) as ReactElement<ToolProps>[];
|
||||||
|
const tools = elements.filter(
|
||||||
|
(element) =>
|
||||||
|
element.props["data-tool-key"] &&
|
||||||
|
!["addToChat", "download"].includes(element.props["data-tool-key"]!)
|
||||||
|
);
|
||||||
|
const toolMap = new Map(
|
||||||
|
tools.map((tool) => [tool.props["data-tool-key"]!, tool])
|
||||||
|
);
|
||||||
|
const preferences = saved || {
|
||||||
|
pinnedKeys: defaultPinnedKeys,
|
||||||
|
showNames: false,
|
||||||
|
};
|
||||||
|
const pinned = preferences.pinnedKeys.filter((key) => toolMap.has(key));
|
||||||
|
const moreTools = tools.filter(
|
||||||
|
(tool) => !pinned.includes(tool.props["data-tool-key"]!)
|
||||||
|
);
|
||||||
|
const addToChat = elements.find(
|
||||||
|
(element) => element.props["data-tool-key"] === "addToChat"
|
||||||
|
);
|
||||||
|
const download = elements.find(
|
||||||
|
(element) => element.props["data-tool-key"] === "download"
|
||||||
|
);
|
||||||
|
const originalMenu = elements.find(
|
||||||
|
(element) => element.type === DropdownMenu
|
||||||
|
);
|
||||||
|
const menuChildren = originalMenu
|
||||||
|
? (Children.toArray(originalMenu.props.children).filter(
|
||||||
|
isValidElement
|
||||||
|
) as ReactElement<{ children?: ReactNode }>[])
|
||||||
|
: [];
|
||||||
|
const menuTrigger = menuChildren.find(
|
||||||
|
(element) => element.type === DropdownMenuTrigger
|
||||||
|
);
|
||||||
|
const moreButton =
|
||||||
|
menuTrigger &&
|
||||||
|
(Children.toArray(menuTrigger.props.children).find(isValidElement) as
|
||||||
|
| Tool
|
||||||
|
| undefined);
|
||||||
|
const extras = menuChildren.find(
|
||||||
|
(element) => element.type === DropdownMenuContent
|
||||||
|
)?.props.children;
|
||||||
|
const openCustomizer = () =>
|
||||||
|
setDraft({
|
||||||
|
pinnedKeys: [...preferences.pinnedKeys],
|
||||||
|
showNames: preferences.showNames,
|
||||||
|
});
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setDraft((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
pinnedKeys: current.pinnedKeys.includes(key)
|
||||||
|
? current.pinnedKeys.filter((item) => item !== key)
|
||||||
|
: [...current.pinnedKeys, key],
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
const move = (key: string, target: string) =>
|
||||||
|
setDraft((current) => {
|
||||||
|
if (!current || key === target) return current;
|
||||||
|
const next = [...current.pinnedKeys];
|
||||||
|
const from = next.indexOf(key),
|
||||||
|
to = next.indexOf(target);
|
||||||
|
if (from < 0 || to < 0) return current;
|
||||||
|
next.splice(from, 1);
|
||||||
|
next.splice(to, 0, key);
|
||||||
|
return { ...current, pinnedKeys: next };
|
||||||
|
});
|
||||||
|
if (!referencePresentation)
|
||||||
|
return (
|
||||||
|
<div {...rootProps} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
{...rootProps}
|
||||||
|
ref={ref}
|
||||||
|
style={{ ...rootProps.style, overflowX: "auto" }}
|
||||||
|
data-show-tool-names={preferences.showNames ? "true" : "false"}
|
||||||
|
>
|
||||||
|
{addToChat && tooltipButton(addToChat, preferences.showNames)}
|
||||||
|
{addToChat && <div className="w-px" />}
|
||||||
|
{pinned.map((key) =>
|
||||||
|
tooltipButton(toolMap.get(key)!, preferences.showNames)
|
||||||
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
{moreButton && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
{cloneElement(moreButton, {
|
||||||
|
title: undefined,
|
||||||
|
"aria-label": toolLabel(moreButton),
|
||||||
|
children: toolIcon(moreButton, 25),
|
||||||
|
})}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="lovart-image-tool-tooltip"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
{toolLabel(moreButton)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="lovart-image-action-menu lovart-image-custom-more"
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
{moreTools.map((tool) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={tool.props["data-tool-key"]}
|
||||||
|
disabled={tool.props.disabled}
|
||||||
|
onClick={(event) =>
|
||||||
|
tool.props.onClick?.(
|
||||||
|
event as unknown as MouseEvent<HTMLButtonElement>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toolIcon(tool, 18)}
|
||||||
|
<span>{toolLabel(tool)}</span>
|
||||||
|
{toolCredit(tool) &&
|
||||||
|
cloneElement(toolCredit(tool)!, {
|
||||||
|
compact: false,
|
||||||
|
className: "lovart-image-tool-cost",
|
||||||
|
})}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{extras}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="lovart-customize-entry"
|
||||||
|
onSelect={openCustomizer}
|
||||||
|
>
|
||||||
|
自定义工具栏
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{download && (
|
||||||
|
<>
|
||||||
|
<div className="w-px" />
|
||||||
|
{tooltipButton(download, preferences.showNames)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(draft)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDraft(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
hideCloseButton
|
||||||
|
className="lovart-toolbar-customizer"
|
||||||
|
overlayClassName="lovart-toolbar-customizer-overlay"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="lovart-customizer-header">
|
||||||
|
<div>
|
||||||
|
<DialogTitle>自定义工具栏</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
选择你想在编辑栏中使用的工具
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<div className="lovart-customizer-header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="恢复默认工具栏"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
pinnedKeys: [...defaultPinnedKeys],
|
||||||
|
showNames: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReferenceIcon name="toolbar-reset" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭自定义工具栏"
|
||||||
|
onClick={() => setDraft(null)}
|
||||||
|
>
|
||||||
|
<ReferenceIcon name="toolbar-close" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="lovart-customizer-body">
|
||||||
|
<div
|
||||||
|
className="lovart-customizer-preview"
|
||||||
|
aria-label="工具栏预览"
|
||||||
|
>
|
||||||
|
<div className="lovart-customizer-preview-bar">
|
||||||
|
{addToChat && (
|
||||||
|
<span className="lovart-preview-fixed">
|
||||||
|
{toolIcon(addToChat, 18)}
|
||||||
|
{draft?.showNames && toolLabel(addToChat)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{draft?.pinnedKeys
|
||||||
|
.filter((key) => toolMap.has(key))
|
||||||
|
.map((key, index, keys) => {
|
||||||
|
const tool = toolMap.get(key)!;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="lovart-preview-chip"
|
||||||
|
key={key}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
setDraggedKey(key);
|
||||||
|
event.dataTransfer.setData("text/plain", key);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDraggedKey(null)}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (draggedKey) move(draggedKey, key);
|
||||||
|
setDraggedKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lovart-preview-handle"
|
||||||
|
aria-label={`${toolLabel(tool)},按左右方向键调整顺序`}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const offset =
|
||||||
|
event.key === "ArrowLeft"
|
||||||
|
? -1
|
||||||
|
: event.key === "ArrowRight"
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
if (offset && keys[index + offset]) {
|
||||||
|
event.preventDefault();
|
||||||
|
move(key, keys[index + offset]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toolIcon(tool, 18)}
|
||||||
|
{draft.showNames && <span>{toolLabel(tool)}</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`移除${toolLabel(tool)}`}
|
||||||
|
onClick={() => toggle(key)}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lovart-customizer-tools">
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const key = tool.props["data-tool-key"]!;
|
||||||
|
const isPinned = Boolean(draft?.pinnedKeys.includes(key));
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${isPinned ? "取消固定" : "固定"}${toolLabel(tool)}`}
|
||||||
|
aria-pressed={isPinned}
|
||||||
|
onClick={() => toggle(key)}
|
||||||
|
>
|
||||||
|
{toolIcon(tool, 18)}
|
||||||
|
<span>{toolLabel(tool)}</span>
|
||||||
|
<ReferenceIcon
|
||||||
|
name={
|
||||||
|
isPinned
|
||||||
|
? "toolbar-pin-filled"
|
||||||
|
: "toolbar-pin-outline"
|
||||||
|
}
|
||||||
|
size={13}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer className="lovart-customizer-footer">
|
||||||
|
<label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-label="显示工具名称"
|
||||||
|
aria-checked={Boolean(draft?.showNames)}
|
||||||
|
className="lovart-customizer-switch"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, showNames: !current.showNames }
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
显示工具名称
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => setDraft(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lovart-customizer-save"
|
||||||
|
onClick={() => {
|
||||||
|
if (draft && saveImageToolbarPreferences(draft))
|
||||||
|
setDraft(null);
|
||||||
|
else
|
||||||
|
toast.error("无法保存工具栏设置,请检查浏览器存储权限");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
23
components/canvas/VideoArchiveButton.tsx
Normal file
23
components/canvas/VideoArchiveButton.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function VideoArchiveButton({ url, automaticBusy = false, onSaved }: { url: string; automaticBusy?: boolean; onSaved: (url: string) => void }) {
|
||||||
|
const running = useRef(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return <button type="button" disabled={busy || automaticBusy} title="当前视频使用临时链接,转存后可长期保留,无需重新生成" className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-[15px] text-amber-200 hover:bg-white/10 disabled:opacity-50" onClick={async () => {
|
||||||
|
if (running.current) return;
|
||||||
|
running.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/canvas/video/archive", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), signal: AbortSignal.timeout(290_000) });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success || !result.data?.url) throw new Error(result.error || "视频转存失败");
|
||||||
|
onSaved(result.data.url);
|
||||||
|
toast.success("视频已转存,画布正在保存新地址");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error && error.name === "TimeoutError" ? "视频保存超时,原视频已保留,请稍后重试" : error instanceof Error ? error.message : "视频转存失败");
|
||||||
|
} finally { running.current = false; setBusy(false); }
|
||||||
|
}}>{busy || automaticBusy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}{busy || automaticBusy ? "正在转存" : "转存视频"}</button>;
|
||||||
|
}
|
||||||
58
components/canvas/VideoCanvasConflictNotice.tsx
Normal file
58
components/canvas/VideoCanvasConflictNotice.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
||||||
|
export function VideoCanvasConflictNotice({
|
||||||
|
fields,
|
||||||
|
onResolve,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
fields: string[];
|
||||||
|
onResolve: (choice: CanvasConflictChoice) => Promise<void> | undefined;
|
||||||
|
onDownload: () => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!fields.length) return null;
|
||||||
|
const resolve = async (choice: CanvasConflictChoice) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onResolve(choice);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="absolute left-1/2 top-20 z-[200] w-[min(680px,90vw)] -translate-x-1/2 rounded-xl border border-amber-400/40 bg-[#29251b] p-4 text-sm text-white shadow-xl"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">其他页面修改了相同内容,当前草稿已保留</p>
|
||||||
|
<p className="mt-2 text-white/70">
|
||||||
|
共有 {fields.length} 处冲突。不同位置的修改会自动合并;以下选择仅处理冲突部分。
|
||||||
|
</p>
|
||||||
|
<ul className="my-2 max-h-32 overflow-auto text-xs text-white/60">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<li key={`${field}-${index}`}>{field}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded bg-white/10 px-3 py-2 disabled:opacity-40"
|
||||||
|
onClick={() => void resolve("local")}
|
||||||
|
>
|
||||||
|
冲突处保留本页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded bg-white/10 px-3 py-2 disabled:opacity-40"
|
||||||
|
onClick={() => void resolve("remote")}
|
||||||
|
>
|
||||||
|
冲突处采用服务器版
|
||||||
|
</button>
|
||||||
|
<button className="rounded bg-white/10 px-3 py-2" onClick={onDownload}>
|
||||||
|
下载当前草稿
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
components/canvas/VideoCanvasHome.tsx
Normal file
253
components/canvas/VideoCanvasHome.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
ArrowLeft, Clapperboard, FileText, Film, FolderOpen, Image as ImageIcon,
|
||||||
|
LayoutGrid, MoreHorizontal, Music2, Plus, Scissors, Sparkles, Video,
|
||||||
|
WandSparkles, Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { filterCanvasModels, type CanvasModelDescriptor } from "@/lib/video-canvas/model-matching";
|
||||||
|
|
||||||
|
export type VideoCanvasHomeProject = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = "home" | "projects";
|
||||||
|
type Starter = "image" | "video" | "audio" | "script" | "smart-edit";
|
||||||
|
type BrandingSettings = {
|
||||||
|
siteName?: string | null;
|
||||||
|
siteLogo?: string | null;
|
||||||
|
siteLogoDark?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickStarts: Array<{ label: string; icon: typeof Video; starter: Starter; mode?: string; model?: string }> = [
|
||||||
|
{ label: "文生视频", icon: WandSparkles, starter: "video", mode: "text-to-video" },
|
||||||
|
{ label: "首帧视频", icon: Video, starter: "video", mode: "first-frame" },
|
||||||
|
{ label: "多参考视频", icon: Film, starter: "video", mode: "reference-images" },
|
||||||
|
{ label: "视频生成", icon: Clapperboard, starter: "video" },
|
||||||
|
{ label: "图片生成", icon: ImageIcon, starter: "image" },
|
||||||
|
{ label: "音频生成", icon: Music2, starter: "audio" },
|
||||||
|
{ label: "剧本生成", icon: FileText, starter: "script" },
|
||||||
|
{ label: "智能剪辑", icon: Scissors, starter: "smart-edit" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function projectHref(id: string, starter?: Starter, mode?: string, model?: string) {
|
||||||
|
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
|
||||||
|
if (starter) params.set("starter", starter);
|
||||||
|
if (mode) params.set("starterMode", mode);
|
||||||
|
if (model) params.set("starterModel", model);
|
||||||
|
return `/canvas/${encodeURIComponent(id)}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectDate(value: string) {
|
||||||
|
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
}).formatToParts(new Date(value));
|
||||||
|
const get = (name: string) => parts.find(part => part.type === name)?.value || "";
|
||||||
|
return `${get("year")}-${get("month")}-${get("day")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectTitle(value: string) {
|
||||||
|
return !value || value === "未命名视频画布" ? "未命名" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoCanvasHome({
|
||||||
|
initialHasMore, initialProjects, siteLogo, siteLogoDark, siteName, userName,
|
||||||
|
}: {
|
||||||
|
initialHasMore: boolean;
|
||||||
|
initialProjects: VideoCanvasHomeProject[];
|
||||||
|
siteLogo: string | null;
|
||||||
|
siteLogoDark: string | null;
|
||||||
|
siteName: string;
|
||||||
|
userName: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [section, setSection] = useState<Section>("home");
|
||||||
|
const [projects, setProjects] = useState(initialProjects);
|
||||||
|
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [menuId, setMenuId] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [draftTitle, setDraftTitle] = useState("");
|
||||||
|
const { data: branding } = useSWR<BrandingSettings>(
|
||||||
|
"/api/site-settings?scope=branding",
|
||||||
|
async (url: string) => {
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error("品牌信息不可用");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
{ refreshInterval: 30_000, revalidateOnFocus: true },
|
||||||
|
);
|
||||||
|
const brandName = String(branding?.siteName ?? siteName).trim() || "AI Chatbot";
|
||||||
|
const brandLogo = (branding ? branding.siteLogoDark : siteLogoDark)?.trim()
|
||||||
|
|| (branding ? branding.siteLogo : siteLogo)?.trim();
|
||||||
|
const { data: membership } = useSWR<{ summary?: { currentCredits?: number } }>(
|
||||||
|
"/api/user/membership-summary",
|
||||||
|
async (url: string) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error("积分信息不可用");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { data: modelData } = useSWR<{ models?: CanvasModelDescriptor[] }>("/api/models", async (url: string) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error("模型目录不可用");
|
||||||
|
return response.json();
|
||||||
|
});
|
||||||
|
const availableVideoModels = filterCanvasModels(modelData?.models || [], "video", "text-to-video").slice(0, 3);
|
||||||
|
const shortcuts = quickStarts.map((shortcut, index) => {
|
||||||
|
const model = index < 3 ? availableVideoModels[index] : undefined;
|
||||||
|
return model ? {
|
||||||
|
...shortcut,
|
||||||
|
label: model.displayName || model.modelId || shortcut.label,
|
||||||
|
model: model.modelId || model.id,
|
||||||
|
mode: "text-to-video",
|
||||||
|
} : shortcut;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
async function refreshRecent() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/canvas?workspaceType=video&limit=24&page=1", { cache: "no-store" });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!active || !response.ok || !result.success) return;
|
||||||
|
setProjects(current => result.data.map((item: VideoCanvasHomeProject) => ({
|
||||||
|
...item,
|
||||||
|
thumbnail: item.thumbnail || current.find(existing => existing.id === item.id)?.thumbnail || null,
|
||||||
|
})));
|
||||||
|
setHasMore(Boolean(result.pagination?.hasMore));
|
||||||
|
} catch {
|
||||||
|
// The server-rendered project list remains available if refreshing fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void refreshRecent();
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function createProject(starter?: Starter, mode?: string, model?: string) {
|
||||||
|
if (creating) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/canvas", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ workspaceType: "video", title: "未命名视频画布" }),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success || !result.data?.id) throw new Error(result.error || "创建项目失败");
|
||||||
|
setProjects(current => [{
|
||||||
|
id: result.data.id,
|
||||||
|
title: result.data.title || "未命名视频画布",
|
||||||
|
thumbnail: null,
|
||||||
|
createdAt: result.data.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: result.data.updatedAt || new Date().toISOString(),
|
||||||
|
}, ...current]);
|
||||||
|
router.push(projectHref(result.data.id, starter, mode, model));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "创建项目失败,请重试");
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMore || !hasMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const page = Math.floor(projects.length / 24) + 1;
|
||||||
|
const response = await fetch(`/api/canvas?workspaceType=video&limit=24&page=${page}`, { cache: "no-store" });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) throw new Error(result.error || "加载项目失败");
|
||||||
|
setProjects(current => [...current, ...result.data.filter((item: VideoCanvasHomeProject) => !current.some(existing => existing.id === item.id))]);
|
||||||
|
setHasMore(Boolean(result.pagination?.hasMore));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "加载项目失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTitle(id: string) {
|
||||||
|
const title = draftTitle.trim();
|
||||||
|
if (!title) { toast.error("项目名称不能为空"); return; }
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/canvas/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, workspaceType: "video" }),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) throw new Error(result.error || "重命名失败");
|
||||||
|
setProjects(current => current.map(item => item.id === id ? { ...item, title, updatedAt: result.data.updatedAt || item.updatedAt } : item));
|
||||||
|
setEditingId(null);
|
||||||
|
toast.success("项目已重命名");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "重命名失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleProjects = section === "home" ? projects.slice(0, 4) : projects;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-svh min-w-0 overflow-hidden bg-[#191919] text-[#f7f7f7]">
|
||||||
|
<aside className="flex w-[210px] shrink-0 flex-col border-r border-white/[0.06] bg-[#1c1c1c] px-3 py-[18px] max-sm:w-[60px] max-sm:px-2">
|
||||||
|
<a aria-label={`返回${brandName}首页`} className="mb-4 flex h-7 min-w-0 items-center gap-2 px-2 text-[18px] font-semibold" href="/">
|
||||||
|
{brandLogo ? <img alt="" className="size-6 shrink-0 object-contain" src={brandLogo} /> : <span className="flex size-5 shrink-0 items-center justify-center rounded bg-white text-[#191919]"><Film className="size-3.5" /></span>}
|
||||||
|
<span className="truncate max-sm:hidden">{brandName}</span>
|
||||||
|
</a>
|
||||||
|
<button className="flex h-[33px] items-center gap-2 rounded-[6px] bg-[#15bde4] px-2.5 text-left text-[13px] font-medium text-black hover:bg-[#3ed0ed] disabled:opacity-55" disabled={creating} onClick={() => void createProject()} type="button">
|
||||||
|
<Plus className="size-4" /><span className="max-sm:hidden">{creating ? "正在创建…" : "新建项目"}</span>
|
||||||
|
</button>
|
||||||
|
<div className="mt-5 space-y-1 border-b border-white/[0.06] pb-4">
|
||||||
|
<button className="flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] text-white/75 hover:bg-white/[0.06]" onClick={() => void createProject("video")} type="button"><Sparkles className="size-4" /><span className="max-sm:hidden">视频创作</span></button>
|
||||||
|
<button className="flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] text-white/75 hover:bg-white/[0.06]" onClick={() => void createProject("smart-edit")} type="button"><Scissors className="size-4" /><span className="max-sm:hidden">智能剪辑</span></button>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="视频画布导航" className="mt-3 space-y-1">
|
||||||
|
<button aria-current={section === "home" ? "page" : undefined} className={`flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] ${section === "home" ? "bg-[#282828] text-white" : "text-white/75 hover:bg-white/[0.06]"}`} onClick={() => setSection("home")} type="button"><LayoutGrid className="size-4" /><span className="max-sm:hidden">首页</span></button>
|
||||||
|
<button aria-current={section === "projects" ? "page" : undefined} className={`flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] ${section === "projects" ? "bg-[#282828] text-white" : "text-white/75 hover:bg-white/[0.06]"}`} onClick={() => setSection("projects")} type="button"><FolderOpen className="size-4" /><span className="max-sm:hidden">项目</span></button>
|
||||||
|
<a className="flex h-8 items-center gap-2 rounded-md px-2.5 text-[12px] text-white/75 hover:bg-white/[0.06]" href="/gallery"><ImageIcon className="size-4" /><span className="max-sm:hidden">资产</span></a>
|
||||||
|
</nav>
|
||||||
|
<a aria-label="返回生图画布首页" className="mt-3 flex h-8 items-center gap-2 rounded-md border border-white/10 px-2.5 text-[12px] text-white/75 hover:bg-white/[0.06] hover:text-white" href="/" title="返回生图画布首页"><ArrowLeft className="size-4 shrink-0" /><span className="truncate max-sm:hidden">返回生图画布首页</span></a>
|
||||||
|
<div className="mt-auto flex items-center gap-2 border-t border-white/[0.06] px-2 pt-4 text-[12px] text-white/70"><span className="flex size-7 items-center justify-center rounded-full bg-[#414a62] font-medium text-white">{(userName || "用").slice(0, 1)}</span><span className="truncate max-sm:hidden">{userName || "我的工作区"}</span></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||||
|
<header className="flex h-[46px] items-center justify-end gap-2 border-b border-white/[0.04] px-5 md:px-9">
|
||||||
|
<span className="flex h-8 items-center gap-1 rounded-lg border border-white/10 px-3 text-[12px] text-white/75"><Zap className="size-3.5" />{membership?.summary?.currentCredits ?? "—"}</span>
|
||||||
|
<span aria-label={userName || "当前用户"} className="flex size-8 items-center justify-center rounded-full bg-[#414a62] text-[12px] font-medium">{(userName || "用").slice(0, 1)}</span>
|
||||||
|
</header>
|
||||||
|
<div className="mx-auto max-w-[1500px] px-5 pb-12 pt-[10px] md:px-9">
|
||||||
|
{section === "home" ? <>
|
||||||
|
<button aria-label="新建画布创作" className="flex h-[140px] w-full flex-col items-center justify-center gap-3 rounded-[12px] border border-white/[0.14] bg-[radial-gradient(circle_at_center,rgba(255,255,255,.045),transparent_65%),radial-gradient(#5c5c5c_0.8px,transparent_0.8px)] bg-[length:100%_100%,18px_18px] text-[14px] font-semibold hover:border-white/30" disabled={creating} onClick={() => void createProject()} type="button">
|
||||||
|
<span className="flex h-[48px] w-[80px] items-center justify-center rounded-[15px] bg-[linear-gradient(#fff,#e9e9e9)] text-black shadow-[0_8px_25px_rgba(255,255,255,.1)]"><Plus className="size-8 stroke-[1.8]" /></span>
|
||||||
|
新建画布创作
|
||||||
|
</button>
|
||||||
|
<div className="mt-7 grid grid-cols-4 gap-3 lg:grid-cols-8 lg:gap-5">
|
||||||
|
{shortcuts.map(({ label, icon: Icon, starter, mode, model }, index) => <button className="group min-w-0 text-center disabled:opacity-50" disabled={creating} key={`${index}-${label}`} onClick={() => void createProject(starter, mode, model)} title={label} type="button"><span className="mx-auto flex h-[50px] w-[86px] max-w-full items-center justify-center rounded-[16px] bg-[#262626] transition-colors group-hover:bg-[#343434]"><Icon className="size-5 text-white/90" /></span><span className="mt-2 block truncate text-[12px] font-medium">{label}</span></button>)}
|
||||||
|
</div>
|
||||||
|
</> : null}
|
||||||
|
|
||||||
|
<div className={section === "home" ? "mt-10" : "mt-1"}>
|
||||||
|
<div className="mb-4 flex items-center justify-between"><h1 className="text-[14px] font-semibold">{section === "home" ? "最近项目" : "全部项目"}</h1>{section === "home" && projects.length > 0 ? <button className="text-[12px] text-white/75 hover:text-white" onClick={() => setSection("projects")} type="button">查看全部 ›</button> : null}</div>
|
||||||
|
{visibleProjects.length ? <div className={`grid grid-cols-1 gap-3 ${section === "home" ? "sm:grid-cols-2 lg:grid-cols-4" : "sm:grid-cols-2"}`}>{visibleProjects.map(project => <div className="group relative flex h-[74px] min-w-0 items-center gap-3 rounded-[24px] border border-white/[0.09] bg-[#202020] pr-11 transition-colors hover:border-white/[0.19] hover:bg-[#292929]" key={project.id}>
|
||||||
|
<button aria-label={`打开项目 ${projectTitle(project.title)}`} className="absolute inset-0 rounded-[24px]" onClick={() => router.push(projectHref(project.id))} type="button" />
|
||||||
|
<span className="pointer-events-none z-[1] ml-1 flex h-[66px] w-[88px] shrink-0 items-center justify-center overflow-hidden rounded-[20px] bg-[#292929] text-white/30">{project.thumbnail ? <img alt="" className="size-full object-cover" src={project.thumbnail} /> : <Sparkles className="size-5" />}</span>
|
||||||
|
<span className="pointer-events-none z-[1] min-w-0 flex-1"><span className="block truncate text-[13px] font-semibold">{projectTitle(project.title)}</span><span className="mt-1 block text-[11px] text-white/50">{projectDate(project.updatedAt)}</span></span>
|
||||||
|
<button aria-label={`项目 ${projectTitle(project.title)} 的更多操作`} aria-expanded={menuId === project.id} className="absolute right-4 top-4 z-[2] flex size-7 items-center justify-center rounded-lg text-white/60 opacity-0 hover:bg-white/10 hover:text-white focus:opacity-100 group-hover:opacity-100" onClick={() => setMenuId(current => current === project.id ? null : project.id)} type="button"><MoreHorizontal className="size-4" /></button>
|
||||||
|
{menuId === project.id ? <div className="absolute right-3 top-11 z-[4] min-w-[110px] rounded-lg border border-white/10 bg-[#303030] p-1 shadow-xl"><button className="w-full rounded px-3 py-2 text-left text-[12px] hover:bg-white/10" onClick={() => { setEditingId(project.id); setDraftTitle(projectTitle(project.title)); setMenuId(null); }} type="button">重命名</button></div> : null}
|
||||||
|
{editingId === project.id ? <form className="absolute inset-1 z-[5] flex items-center gap-2 rounded-[20px] bg-[#292929] px-3" onSubmit={event => { event.preventDefault(); void saveTitle(project.id); }}><input aria-label="项目名称" autoFocus className="min-w-0 flex-1 rounded-md border border-white/20 bg-[#202020] px-3 py-2 text-[13px] outline-none focus:border-white/60" maxLength={256} onChange={event => setDraftTitle(event.target.value)} value={draftTitle} /><button className="text-[12px] text-cyan-300" type="submit">保存</button><button className="text-[12px] text-white/50" onClick={() => setEditingId(null)} type="button">取消</button></form> : null}
|
||||||
|
</div>)}</div> : <div className="rounded-2xl border border-dashed border-white/10 px-5 py-14 text-center text-[13px] text-white/55">还没有视频项目,点击上方“新建画布创作”开始。</div>}
|
||||||
|
{section === "projects" && hasMore ? <button className="mx-auto mt-6 block rounded-lg border border-white/15 px-5 py-2 text-[12px] text-white/70 hover:bg-white/10 disabled:opacity-50" disabled={loadingMore} onClick={() => void loadMore()} type="button">{loadingMore ? "加载中…" : "加载更多"}</button> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/canvas/VideoPromptToolDialog.tsx
Normal file
49
components/canvas/VideoPromptToolDialog.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools";
|
||||||
|
|
||||||
|
export function VideoPromptToolDialog({ mode, busy, error, output, source, currentPrompt, model, models, onModelChange, onOutputChange, onRun, onApply, onClose }: {
|
||||||
|
mode: PromptToolMode;
|
||||||
|
busy: boolean;
|
||||||
|
error: string;
|
||||||
|
output: string;
|
||||||
|
source: string;
|
||||||
|
currentPrompt: string;
|
||||||
|
model: string;
|
||||||
|
models: { id: string; label: string }[];
|
||||||
|
onModelChange: (value: string) => void;
|
||||||
|
onOutputChange: (value: string) => void;
|
||||||
|
onRun: () => void;
|
||||||
|
onApply: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const title = mode === "optimize" ? "提示词优化" : "提示词翻译";
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={open => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent hideCloseButton aria-describedby={undefined} onPointerDown={event => event.stopPropagation()} className="flex max-h-[90dvh] w-[620px] max-w-full flex-col gap-4 overflow-y-auto rounded-2xl border border-white/10 bg-[#262626] p-5 text-white shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DialogTitle className="text-base font-medium">{title}</DialogTitle>
|
||||||
|
<button aria-label="关闭提示词工具" className="rounded px-2 py-1 text-white/60 hover:bg-white/10" onClick={onClose} type="button">✕</button>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-3 text-sm text-white/65">
|
||||||
|
处理模型
|
||||||
|
<select aria-label="提示词处理模型" className="min-w-0 flex-1 rounded-lg border border-white/15 bg-[#202020] px-3 py-2" disabled={busy} onChange={event => onModelChange(event.target.value)} value={model}>
|
||||||
|
<option value="auto">使用管理员默认模型</option>
|
||||||
|
{model !== "auto" && !models.some(item => item.id === model) ? <option value={model}>当前配置模型(暂不可选)</option> : null}
|
||||||
|
{models.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<details className="text-sm text-white/55"><summary className="cursor-pointer">查看原提示词</summary><p className="mt-2 whitespace-pre-wrap">{source}</p></details>
|
||||||
|
{error ? <p role="alert" className="rounded-lg bg-red-400/10 p-3 text-sm text-red-200">{error}</p> : null}
|
||||||
|
<textarea aria-label={`${title}结果`} className="min-h-[220px] w-full resize-y rounded-xl border border-white/10 bg-black/20 p-3 text-sm leading-6 outline-none focus:border-white/30" disabled={busy} onChange={event => onOutputChange(event.target.value)} placeholder={busy ? "正在处理,请稍候…" : "处理结果会显示在这里,确认后填入视频节点。"} value={output} />
|
||||||
|
{source !== currentPrompt ? <p className="text-xs text-amber-200">原文已修改,应用结果将替换当前提示词。</p> : null}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button className="rounded-lg px-3 py-2 text-sm text-white/65 hover:bg-white/10" onClick={onClose} type="button">{busy ? "取消处理" : "取消"}</button>
|
||||||
|
<button className="rounded-lg border border-white/15 px-3 py-2 text-sm disabled:opacity-40" disabled={busy} onClick={onRun} type="button">{busy ? "处理中…" : output ? "重新生成" : "重试"}</button>
|
||||||
|
<button className="rounded-lg bg-white px-4 py-2 text-sm text-black disabled:opacity-40" disabled={busy || !output.trim() || Boolean(error)} onClick={onApply} type="button">应用到提示词</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { Sparkles } from "lucide-react";
|
import { BloubAvatar } from "@/components/bloub-avatar";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type CanvasLogoLoadingViewProps = {
|
type CanvasLogoLoadingViewProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -10,46 +9,17 @@ type CanvasLogoLoadingViewProps = {
|
|||||||
|
|
||||||
export function CanvasLogoLoadingView({
|
export function CanvasLogoLoadingView({
|
||||||
label = "正在加载画布...",
|
label = "正在加载画布...",
|
||||||
siteName = "AI Chatbot",
|
|
||||||
siteLogo,
|
|
||||||
siteLogoDark,
|
|
||||||
}: CanvasLogoLoadingViewProps) {
|
}: CanvasLogoLoadingViewProps) {
|
||||||
const resolvedSiteName = String(siteName || "AI Chatbot").trim();
|
|
||||||
const lightLogo = String(siteLogo || siteLogoDark || "").trim();
|
|
||||||
const darkLogo = String(siteLogoDark || siteLogo || "").trim();
|
|
||||||
const hasSeparateDarkLogo = Boolean(darkLogo && darkLogo !== lightLogo);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh w-screen items-center justify-center bg-[#f4f4f5] text-[#20242a] dark:bg-[#08090b] dark:text-zinc-100">
|
<div className="flex h-dvh w-screen items-center justify-center bg-[#f4f4f5] text-[#20242a] dark:bg-[#08090b] dark:text-zinc-100">
|
||||||
<div className="flex flex-col items-center gap-3.5">
|
<div className="flex flex-col items-center gap-2.5">
|
||||||
<div className="relative flex h-[78px] w-[78px] items-center justify-center">
|
<BloubAvatar
|
||||||
<div className="absolute inset-0 animate-spin rounded-full bg-[conic-gradient(from_0deg,#ff7a00,#ffe04b,#48e6a5,#35a7ff,#b15cff,#ff4aa2,#ff7a00)] opacity-90 blur-[1px]" />
|
label={label}
|
||||||
<div className="absolute inset-[6px] animate-ping rounded-full bg-[conic-gradient(from_180deg,#ff7a00,#35a7ff,#b15cff,#48e6a5,#ff7a00)] opacity-25" />
|
playSequence
|
||||||
<div className="absolute inset-1 rounded-full bg-[#f4f4f5] dark:bg-[#08090b]" />
|
size={104}
|
||||||
<div className="relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full border border-black/8 bg-white shadow-[0_13px_38px_rgba(15,23,42,0.16)] dark:border-white/10 dark:bg-zinc-950 dark:shadow-[0_17px_49px_rgba(0,0,0,0.45)]">
|
state="idle"
|
||||||
{lightLogo ? (
|
surface="page"
|
||||||
<>
|
/>
|
||||||
<img
|
|
||||||
alt={resolvedSiteName}
|
|
||||||
className={cn(
|
|
||||||
"h-[34px] w-[34px] object-contain",
|
|
||||||
hasSeparateDarkLogo && "dark:hidden"
|
|
||||||
)}
|
|
||||||
src={lightLogo}
|
|
||||||
/>
|
|
||||||
{hasSeparateDarkLogo ? (
|
|
||||||
<img
|
|
||||||
alt={resolvedSiteName}
|
|
||||||
className="hidden h-[34px] w-[34px] object-contain dark:block"
|
|
||||||
src={darkLogo}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Sparkles className="h-[22px] w-[22px] text-[#20242a] dark:text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] font-medium tracking-[0.16em] text-[#6b7280] dark:text-zinc-400">
|
<div className="text-[12px] font-medium tracking-[0.16em] text-[#6b7280] dark:text-zinc-400">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
598
components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx
Normal file
598
components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx
Normal file
@ -0,0 +1,598 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import { OrbitControls, useGLTF } from "@react-three/drei";
|
||||||
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { Mesh, Object3D } from "three";
|
||||||
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
|
import { LocalStudioEnvironment } from "@/components/canvas/model3d/LocalStudioEnvironment";
|
||||||
|
import { ModelLoadingFallback } from "@/components/canvas/model3d/ModelLoadingFallback";
|
||||||
|
import type {
|
||||||
|
OpenClawModelInteractionManifest,
|
||||||
|
OpenClawModelInteractionPart,
|
||||||
|
} from "@/lib/openclaw-chat/model-interaction";
|
||||||
|
import {
|
||||||
|
getOpenClawExplodedOffsetMm,
|
||||||
|
getOpenClawPartForSceneNode,
|
||||||
|
getOpenClawSourceNodeOffset,
|
||||||
|
mapOpenClawCadOffsetToGlb,
|
||||||
|
} from "@/lib/openclaw-chat/model-interaction";
|
||||||
|
|
||||||
|
export type OpenClawModelTransformRequest = {
|
||||||
|
actionId: string | null;
|
||||||
|
amount: number;
|
||||||
|
animate: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
nonce: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenClawModelCameraState = {
|
||||||
|
position: [number, number, number];
|
||||||
|
target: [number, number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
type InteractiveEntry = {
|
||||||
|
node: Object3D;
|
||||||
|
part: OpenClawModelInteractionPart;
|
||||||
|
initialPosition: THREE.Vector3;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnimationState = {
|
||||||
|
startedAt: number;
|
||||||
|
durationMs: number;
|
||||||
|
starts: Map<string, THREE.Vector3>;
|
||||||
|
targets: Map<string, THREE.Vector3>;
|
||||||
|
easing: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
entry: InteractiveEntry;
|
||||||
|
plane: THREE.Plane;
|
||||||
|
startIntersection: THREE.Vector3;
|
||||||
|
startWorldPosition: THREE.Vector3;
|
||||||
|
startLocalPosition: THREE.Vector3;
|
||||||
|
pointerId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class InteractiveViewerErrorBoundary extends React.Component<
|
||||||
|
{ children: React.ReactNode; fallback: React.ReactNode; resetKey: string },
|
||||||
|
{ hasError: boolean }
|
||||||
|
> {
|
||||||
|
state = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(previousProps: { resetKey: string }) {
|
||||||
|
if (
|
||||||
|
this.state.hasError &&
|
||||||
|
previousProps.resetKey !== this.props.resetKey
|
||||||
|
) {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.hasError ? this.props.fallback : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMesh(node: Object3D): node is Mesh {
|
||||||
|
return (node as Mesh).isMesh === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneScene(scene: Object3D) {
|
||||||
|
const clone = scene.clone(true);
|
||||||
|
clone.traverse((node) => {
|
||||||
|
if (!isMesh(node)) return;
|
||||||
|
node.castShadow = true;
|
||||||
|
node.receiveShadow = true;
|
||||||
|
const materials = Array.isArray(node.material)
|
||||||
|
? node.material
|
||||||
|
: [node.material];
|
||||||
|
const clones = materials.map((material) => material.clone());
|
||||||
|
node.material = Array.isArray(node.material)
|
||||||
|
? clones
|
||||||
|
: clones[0] || node.material;
|
||||||
|
});
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFitTransform(scene: Object3D) {
|
||||||
|
const bounds = new THREE.Box3().setFromObject(scene);
|
||||||
|
if (bounds.isEmpty()) {
|
||||||
|
return {
|
||||||
|
position: [0, 0, 0] as [number, number, number],
|
||||||
|
scale: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const size = bounds.getSize(new THREE.Vector3());
|
||||||
|
const center = bounds.getCenter(new THREE.Vector3());
|
||||||
|
const largestDimension = Math.max(size.x, size.y, size.z);
|
||||||
|
if (!Number.isFinite(largestDimension) || largestDimension <= 0) {
|
||||||
|
return {
|
||||||
|
position: [0, 0, 0] as [number, number, number],
|
||||||
|
scale: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const scale = 2 / largestDimension;
|
||||||
|
return {
|
||||||
|
position: [
|
||||||
|
-center.x * scale,
|
||||||
|
-center.y * scale,
|
||||||
|
-center.z * scale,
|
||||||
|
] as [number, number, number],
|
||||||
|
scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEasedProgress(progress: number, easing: string) {
|
||||||
|
if (easing.toLowerCase() === "linear") return progress;
|
||||||
|
return progress < 0.5
|
||||||
|
? 4 * progress * progress * progress
|
||||||
|
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartTargetPosition(
|
||||||
|
entry: InteractiveEntry,
|
||||||
|
manifest: OpenClawModelInteractionManifest,
|
||||||
|
amount: number
|
||||||
|
) {
|
||||||
|
const offsetMm = getOpenClawExplodedOffsetMm(entry.part);
|
||||||
|
if (!offsetMm) return entry.initialPosition.clone();
|
||||||
|
const offset = mapOpenClawCadOffsetToGlb(offsetMm, manifest.scaleMmToGlb);
|
||||||
|
return entry.initialPosition
|
||||||
|
.clone()
|
||||||
|
.add(new THREE.Vector3(offset.x, offset.y, offset.z).multiplyScalar(amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelInteractionScene({
|
||||||
|
allowDragging,
|
||||||
|
fitExplodedSource,
|
||||||
|
initialCameraState,
|
||||||
|
interactionParts,
|
||||||
|
manifest,
|
||||||
|
modelUrl,
|
||||||
|
onMatchChange,
|
||||||
|
onCameraStateChange,
|
||||||
|
onSelectedPartChange,
|
||||||
|
resetAllToken,
|
||||||
|
resetSelectedToken,
|
||||||
|
selectedNodeName,
|
||||||
|
sourceVariant,
|
||||||
|
transformRequest,
|
||||||
|
}: {
|
||||||
|
allowDragging: boolean;
|
||||||
|
fitExplodedSource: boolean;
|
||||||
|
initialCameraState?: OpenClawModelCameraState | null;
|
||||||
|
interactionParts: OpenClawModelInteractionPart[];
|
||||||
|
manifest: OpenClawModelInteractionManifest;
|
||||||
|
modelUrl: string;
|
||||||
|
onMatchChange: (matched: number, total: number) => void;
|
||||||
|
onCameraStateChange?: (state: OpenClawModelCameraState) => void;
|
||||||
|
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
||||||
|
resetAllToken: number;
|
||||||
|
resetSelectedToken: number;
|
||||||
|
selectedNodeName: string | null;
|
||||||
|
sourceVariant: "assembled" | "exploded";
|
||||||
|
transformRequest: OpenClawModelTransformRequest;
|
||||||
|
}) {
|
||||||
|
const { camera, gl, scene: rootScene } = useThree();
|
||||||
|
const controlsRef = useRef<OrbitControlsImpl | null>(null);
|
||||||
|
const gltf = useGLTF(modelUrl, true);
|
||||||
|
const scene = useMemo(() => {
|
||||||
|
const cloned = cloneScene(gltf.scene);
|
||||||
|
if (sourceVariant !== "exploded") return cloned;
|
||||||
|
const partsByNodeName = new Map(
|
||||||
|
interactionParts.map((part) => [part.nodeName, part])
|
||||||
|
);
|
||||||
|
cloned.traverse((node) => {
|
||||||
|
const part = getOpenClawPartForSceneNode(node, partsByNodeName);
|
||||||
|
if (!part) return;
|
||||||
|
const offset = getOpenClawSourceNodeOffset(
|
||||||
|
part,
|
||||||
|
sourceVariant,
|
||||||
|
manifest.scaleMmToGlb
|
||||||
|
);
|
||||||
|
node.position.add(new THREE.Vector3(offset.x, offset.y, offset.z));
|
||||||
|
});
|
||||||
|
cloned.updateMatrixWorld(true);
|
||||||
|
return cloned;
|
||||||
|
}, [gltf.scene, interactionParts, manifest.scaleMmToGlb, sourceVariant]);
|
||||||
|
const fitTransform = useMemo(
|
||||||
|
() => getFitTransform(fitExplodedSource ? gltf.scene : scene),
|
||||||
|
[fitExplodedSource, gltf.scene, scene]
|
||||||
|
);
|
||||||
|
const entriesRef = useRef<Map<string, InteractiveEntry>>(new Map());
|
||||||
|
const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(new Map());
|
||||||
|
const animationRef = useRef<AnimationState | null>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const currentAmountRef = useRef(0);
|
||||||
|
const selectionHelperRef = useRef<THREE.BoxHelper | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const partsByNodeName = new Map(
|
||||||
|
interactionParts.map((part) => [part.nodeName, part])
|
||||||
|
);
|
||||||
|
const entries = new Map<string, InteractiveEntry>();
|
||||||
|
const objectEntries = new Map<Object3D, InteractiveEntry>();
|
||||||
|
scene.traverse((node) => {
|
||||||
|
const part = getOpenClawPartForSceneNode(node, partsByNodeName);
|
||||||
|
if (!part || entries.has(part.nodeName)) return;
|
||||||
|
const entry = {
|
||||||
|
node,
|
||||||
|
part,
|
||||||
|
initialPosition: node.position.clone(),
|
||||||
|
};
|
||||||
|
entries.set(part.nodeName, entry);
|
||||||
|
objectEntries.set(node, entry);
|
||||||
|
});
|
||||||
|
entriesRef.current = entries;
|
||||||
|
objectEntriesRef.current = objectEntries;
|
||||||
|
onMatchChange(entries.size, interactionParts.length);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
entriesRef.current.clear();
|
||||||
|
objectEntriesRef.current.clear();
|
||||||
|
onMatchChange(0, interactionParts.length);
|
||||||
|
};
|
||||||
|
}, [interactionParts, onMatchChange, scene]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialCameraState || !controlsRef.current) return;
|
||||||
|
camera.position.set(...initialCameraState.position);
|
||||||
|
controlsRef.current.target.set(...initialCameraState.target);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controlsRef.current.update();
|
||||||
|
}, [camera, initialCameraState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previous = selectionHelperRef.current;
|
||||||
|
if (previous) {
|
||||||
|
rootScene.remove(previous);
|
||||||
|
previous.geometry.dispose();
|
||||||
|
(previous.material as THREE.Material).dispose();
|
||||||
|
selectionHelperRef.current = null;
|
||||||
|
}
|
||||||
|
if (!selectedNodeName) return;
|
||||||
|
const entry = entriesRef.current.get(selectedNodeName);
|
||||||
|
if (!entry) return;
|
||||||
|
const helper = new THREE.BoxHelper(entry.node, 0x3ecf8e);
|
||||||
|
helper.raycast = () => undefined;
|
||||||
|
rootScene.add(helper);
|
||||||
|
selectionHelperRef.current = helper;
|
||||||
|
return () => {
|
||||||
|
rootScene.remove(helper);
|
||||||
|
helper.geometry.dispose();
|
||||||
|
(helper.material as THREE.Material).dispose();
|
||||||
|
if (selectionHelperRef.current === helper) {
|
||||||
|
selectionHelperRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [rootScene, selectedNodeName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const amount = THREE.MathUtils.clamp(transformRequest.amount, 0, 1);
|
||||||
|
currentAmountRef.current = amount;
|
||||||
|
const starts = new Map<string, THREE.Vector3>();
|
||||||
|
const targets = new Map<string, THREE.Vector3>();
|
||||||
|
for (const [nodeName, entry] of entriesRef.current) {
|
||||||
|
starts.set(nodeName, entry.node.position.clone());
|
||||||
|
targets.set(nodeName, getPartTargetPosition(entry, manifest, amount));
|
||||||
|
}
|
||||||
|
if (!transformRequest.animate) {
|
||||||
|
animationRef.current = null;
|
||||||
|
for (const [nodeName, target] of targets) {
|
||||||
|
entriesRef.current.get(nodeName)?.node.position.copy(target);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetState = amount >= 0.5 ? "exploded" : "assembled";
|
||||||
|
const action =
|
||||||
|
manifest.actions.find((item) => item.id === transformRequest.actionId) ||
|
||||||
|
manifest.actions.find((item) => item.toState === targetState);
|
||||||
|
animationRef.current = {
|
||||||
|
startedAt: performance.now(),
|
||||||
|
durationMs: action?.durationMs ?? transformRequest.durationMs,
|
||||||
|
starts,
|
||||||
|
targets,
|
||||||
|
easing: action?.easing || "easeInOutCubic",
|
||||||
|
};
|
||||||
|
}, [manifest, transformRequest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetAllToken <= 0) return;
|
||||||
|
animationRef.current = null;
|
||||||
|
for (const entry of entriesRef.current.values()) {
|
||||||
|
entry.node.position.copy(
|
||||||
|
getPartTargetPosition(entry, manifest, currentAmountRef.current)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [manifest, resetAllToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetSelectedToken <= 0 || !selectedNodeName) return;
|
||||||
|
const entry = entriesRef.current.get(selectedNodeName);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.node.position.copy(
|
||||||
|
getPartTargetPosition(entry, manifest, currentAmountRef.current)
|
||||||
|
);
|
||||||
|
}, [manifest, resetSelectedToken, selectedNodeName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = gl.domElement;
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const pointer = new THREE.Vector2();
|
||||||
|
const planeIntersection = new THREE.Vector3();
|
||||||
|
const worldPosition = new THREE.Vector3();
|
||||||
|
|
||||||
|
const updateRay = (event: PointerEvent) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
||||||
|
pointer.set(
|
||||||
|
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
||||||
|
-((event.clientY - bounds.top) / bounds.height) * 2 + 1
|
||||||
|
);
|
||||||
|
raycaster.setFromCamera(pointer, camera);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const findEntry = (object: Object3D) => {
|
||||||
|
let current: Object3D | null = object;
|
||||||
|
while (current) {
|
||||||
|
const entry = objectEntriesRef.current.get(current);
|
||||||
|
if (entry) return entry;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.button !== 0 || animationRef.current || !updateRay(event)) return;
|
||||||
|
const intersections = raycaster.intersectObjects(
|
||||||
|
[...entriesRef.current.values()].map((entry) => entry.node),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const entry = intersections
|
||||||
|
.map((intersection) => findEntry(intersection.object))
|
||||||
|
.find((candidate): candidate is InteractiveEntry => Boolean(candidate));
|
||||||
|
if (!entry) {
|
||||||
|
onSelectedPartChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectedPartChange(entry.part);
|
||||||
|
if (!allowDragging || !entry.part.draggable) return;
|
||||||
|
|
||||||
|
const normal = camera.getWorldDirection(new THREE.Vector3()).normalize();
|
||||||
|
const startWorldPosition = entry.node.getWorldPosition(new THREE.Vector3());
|
||||||
|
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(
|
||||||
|
normal,
|
||||||
|
startWorldPosition
|
||||||
|
);
|
||||||
|
if (!raycaster.ray.intersectPlane(plane, planeIntersection)) return;
|
||||||
|
dragRef.current = {
|
||||||
|
entry,
|
||||||
|
plane,
|
||||||
|
startIntersection: planeIntersection.clone(),
|
||||||
|
startWorldPosition,
|
||||||
|
startLocalPosition: entry.node.position.clone(),
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
};
|
||||||
|
if (controlsRef.current) controlsRef.current.enabled = false;
|
||||||
|
element.setPointerCapture?.(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId || !updateRay(event)) return;
|
||||||
|
if (!raycaster.ray.intersectPlane(drag.plane, planeIntersection)) return;
|
||||||
|
const targetWorld = worldPosition
|
||||||
|
.copy(drag.startWorldPosition)
|
||||||
|
.add(planeIntersection.clone().sub(drag.startIntersection));
|
||||||
|
const targetLocal = drag.entry.node.parent
|
||||||
|
? drag.entry.node.parent.worldToLocal(targetWorld.clone())
|
||||||
|
: targetWorld;
|
||||||
|
const axes = manifest.drag.axes;
|
||||||
|
if (!axes.includes("x")) targetLocal.x = drag.startLocalPosition.x;
|
||||||
|
if (!axes.includes("z")) targetLocal.y = drag.startLocalPosition.y;
|
||||||
|
if (!axes.includes("y")) targetLocal.z = drag.startLocalPosition.z;
|
||||||
|
|
||||||
|
const maxDistance = manifest.drag.maxDistanceMm * manifest.scaleMmToGlb;
|
||||||
|
const fromInitial = targetLocal.clone().sub(drag.entry.initialPosition);
|
||||||
|
if (maxDistance > 0 && fromInitial.length() > maxDistance) {
|
||||||
|
fromInitial.setLength(maxDistance);
|
||||||
|
targetLocal.copy(drag.entry.initialPosition).add(fromInitial);
|
||||||
|
}
|
||||||
|
drag.entry.node.position.copy(targetLocal);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
const finishDrag = (event: PointerEvent) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
const snapDistance =
|
||||||
|
manifest.drag.snapDistanceMm * manifest.scaleMmToGlb;
|
||||||
|
if (snapDistance > 0) {
|
||||||
|
const delta = drag.entry.node.position
|
||||||
|
.clone()
|
||||||
|
.sub(drag.entry.initialPosition);
|
||||||
|
delta.set(
|
||||||
|
Math.round(delta.x / snapDistance) * snapDistance,
|
||||||
|
Math.round(delta.y / snapDistance) * snapDistance,
|
||||||
|
Math.round(delta.z / snapDistance) * snapDistance
|
||||||
|
);
|
||||||
|
drag.entry.node.position.copy(drag.entry.initialPosition).add(delta);
|
||||||
|
}
|
||||||
|
element.releasePointerCapture?.(event.pointerId);
|
||||||
|
dragRef.current = null;
|
||||||
|
if (controlsRef.current) controlsRef.current.enabled = true;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
window.addEventListener("pointermove", handlePointerMove, true);
|
||||||
|
window.addEventListener("pointerup", finishDrag, true);
|
||||||
|
window.addEventListener("pointercancel", finishDrag, true);
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove, true);
|
||||||
|
window.removeEventListener("pointerup", finishDrag, true);
|
||||||
|
window.removeEventListener("pointercancel", finishDrag, true);
|
||||||
|
dragRef.current = null;
|
||||||
|
};
|
||||||
|
}, [allowDragging, camera, gl, manifest, onSelectedPartChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
scene.traverse((node) => {
|
||||||
|
if (!isMesh(node)) return;
|
||||||
|
const materials = Array.isArray(node.material)
|
||||||
|
? node.material
|
||||||
|
: [node.material];
|
||||||
|
for (const material of materials) material.dispose();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [scene]);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
selectionHelperRef.current?.update();
|
||||||
|
const animation = animationRef.current;
|
||||||
|
if (!animation) return;
|
||||||
|
const rawProgress =
|
||||||
|
animation.durationMs <= 0
|
||||||
|
? 1
|
||||||
|
: Math.min(1, (performance.now() - animation.startedAt) / animation.durationMs);
|
||||||
|
const progress = getEasedProgress(rawProgress, animation.easing);
|
||||||
|
for (const [nodeName, target] of animation.targets) {
|
||||||
|
const entry = entriesRef.current.get(nodeName);
|
||||||
|
const start = animation.starts.get(nodeName);
|
||||||
|
if (entry && start) entry.node.position.lerpVectors(start, target, progress);
|
||||||
|
}
|
||||||
|
if (rawProgress >= 1) animationRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.75} />
|
||||||
|
<directionalLight castShadow intensity={1.6} position={[4, 7, 5]} />
|
||||||
|
<directionalLight intensity={0.45} position={[-4, 2, -3]} />
|
||||||
|
<primitive
|
||||||
|
object={scene}
|
||||||
|
position={fitTransform.position}
|
||||||
|
scale={fitTransform.scale}
|
||||||
|
/>
|
||||||
|
<OrbitControls
|
||||||
|
dampingFactor={0.08}
|
||||||
|
enableDamping
|
||||||
|
enablePan
|
||||||
|
enableRotate
|
||||||
|
enableZoom
|
||||||
|
makeDefault
|
||||||
|
maxPolarAngle={Math.PI / 1.65}
|
||||||
|
minDistance={1.2}
|
||||||
|
minPolarAngle={0}
|
||||||
|
onChange={() => {
|
||||||
|
const controls = controlsRef.current;
|
||||||
|
if (!controls || !onCameraStateChange) return;
|
||||||
|
onCameraStateChange({
|
||||||
|
position: camera.position.toArray() as [number, number, number],
|
||||||
|
target: controls.target.toArray() as [number, number, number],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
ref={controlsRef}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenClawInteractiveModelViewer({
|
||||||
|
allowDragging,
|
||||||
|
background,
|
||||||
|
fitExplodedSource = false,
|
||||||
|
initialCameraState,
|
||||||
|
interactionParts,
|
||||||
|
manifest,
|
||||||
|
modelUrl,
|
||||||
|
onMatchChange,
|
||||||
|
onCameraStateChange,
|
||||||
|
onSelectedPartChange,
|
||||||
|
previewImageUrl,
|
||||||
|
resetAllToken,
|
||||||
|
resetSelectedToken,
|
||||||
|
resetViewToken,
|
||||||
|
selectedNodeName,
|
||||||
|
sourceVariant,
|
||||||
|
transformRequest,
|
||||||
|
}: {
|
||||||
|
allowDragging: boolean;
|
||||||
|
background: string;
|
||||||
|
fitExplodedSource?: boolean;
|
||||||
|
initialCameraState?: OpenClawModelCameraState | null;
|
||||||
|
interactionParts?: OpenClawModelInteractionPart[];
|
||||||
|
manifest: OpenClawModelInteractionManifest;
|
||||||
|
modelUrl: string;
|
||||||
|
onMatchChange: (matched: number, total: number) => void;
|
||||||
|
onCameraStateChange?: (state: OpenClawModelCameraState) => void;
|
||||||
|
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
||||||
|
previewImageUrl?: string | null;
|
||||||
|
resetAllToken: number;
|
||||||
|
resetSelectedToken: number;
|
||||||
|
resetViewToken: number;
|
||||||
|
selectedNodeName: string | null;
|
||||||
|
sourceVariant: "assembled" | "exploded";
|
||||||
|
transformRequest: OpenClawModelTransformRequest;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InteractiveViewerErrorBoundary
|
||||||
|
resetKey={`${modelUrl}:${resetViewToken}`}
|
||||||
|
fallback={
|
||||||
|
<div className="relative flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950 text-zinc-200">
|
||||||
|
{previewImageUrl ? (
|
||||||
|
<img
|
||||||
|
alt="3D 模型静态预览"
|
||||||
|
className="absolute inset-0 h-full w-full object-contain opacity-80"
|
||||||
|
src={previewImageUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="relative flex max-w-xs flex-col items-center gap-2 rounded-md bg-black/70 px-4 py-3 text-center backdrop-blur-sm">
|
||||||
|
<AlertCircle className="h-6 w-6 text-amber-300" />
|
||||||
|
<p className="text-xs">{previewImageUrl ? "3D 交互预览加载失败,可查看静态预览或下载模型" : "3D 交互预览加载失败,请退出预览后重试或下载模型"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Canvas
|
||||||
|
camera={{ fov: 42, position: [2.4, 1.8, 2.4] }}
|
||||||
|
dpr={[1, 1.75]}
|
||||||
|
gl={{ antialias: true, powerPreference: "high-performance" }}
|
||||||
|
key={`${modelUrl}:${resetViewToken}`}
|
||||||
|
shadows
|
||||||
|
style={{ background }}
|
||||||
|
>
|
||||||
|
<LocalStudioEnvironment />
|
||||||
|
<Suspense fallback={<ModelLoadingFallback />}>
|
||||||
|
<ModelInteractionScene
|
||||||
|
allowDragging={allowDragging}
|
||||||
|
fitExplodedSource={fitExplodedSource}
|
||||||
|
initialCameraState={initialCameraState}
|
||||||
|
interactionParts={interactionParts || manifest.parts}
|
||||||
|
manifest={manifest}
|
||||||
|
modelUrl={modelUrl}
|
||||||
|
onMatchChange={onMatchChange}
|
||||||
|
onCameraStateChange={onCameraStateChange}
|
||||||
|
onSelectedPartChange={onSelectedPartChange}
|
||||||
|
resetAllToken={resetAllToken}
|
||||||
|
resetSelectedToken={resetSelectedToken}
|
||||||
|
selectedNodeName={selectedNodeName}
|
||||||
|
sourceVariant={sourceVariant}
|
||||||
|
transformRequest={transformRequest}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Canvas>
|
||||||
|
</InteractiveViewerErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
1204
components/canvas/chat-panel/OpenClawModel3DPreview.tsx
Normal file
1204
components/canvas/chat-panel/OpenClawModel3DPreview.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||||
|
import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
|
||||||
export const SESSION_TITLE_MAX_LEN = 18;
|
export const SESSION_TITLE_MAX_LEN = 18;
|
||||||
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
||||||
@ -8,6 +9,7 @@ export const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
|
|||||||
|
|
||||||
const CHAT_UUID_REGEX =
|
const CHAT_UUID_REGEX =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const CANVAS_CHAT_QUERY_PARAM = "chat";
|
||||||
|
|
||||||
const ALLOWED_CHAT_MEDIA_TYPES = new Set([
|
const ALLOWED_CHAT_MEDIA_TYPES = new Set([
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
@ -43,6 +45,24 @@ export interface PickedObjectAttachment extends Attachment {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CanvasChatSessionRecord {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
mode: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
clawdSkillTagId?: string | null;
|
||||||
|
clawdSkillTagPromptApplied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClawdChatHistoryRecord {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
messageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function toRawCanvasChatId(value: string): string {
|
export function toRawCanvasChatId(value: string): string {
|
||||||
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
|
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
|
||||||
}
|
}
|
||||||
@ -51,6 +71,42 @@ export function isValidChatUuid(value?: string | null): value is string {
|
|||||||
return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim()));
|
return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeCanvasChatSessionId(value?: string | null) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const rawUuid = raw.startsWith("canvas-") ? raw.slice("canvas-".length) : raw;
|
||||||
|
return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceCanvasChatSessionHistory(params: {
|
||||||
|
currentUrl: string;
|
||||||
|
history: Pick<History, "replaceState">;
|
||||||
|
sessionId: string;
|
||||||
|
}) {
|
||||||
|
const normalized = normalizeCanvasChatSessionId(params.sessionId);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const url = new URL(params.currentUrl);
|
||||||
|
url.searchParams.set(CANVAS_CHAT_QUERY_PARAM, normalized);
|
||||||
|
|
||||||
|
// Reusing Next.js' existing history state bypasses its URL synchronization.
|
||||||
|
params.history.replaceState(null, "", url);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCanvasChatSessionNavigationTarget(params: {
|
||||||
|
activeSessionId: string;
|
||||||
|
browserSessionId: string | null;
|
||||||
|
rootSessionId: string;
|
||||||
|
routerSessionId: string | null;
|
||||||
|
}) {
|
||||||
|
if (params.browserSessionId !== params.routerSessionId) return null;
|
||||||
|
|
||||||
|
const targetSessionId = params.routerSessionId || params.rootSessionId;
|
||||||
|
return targetSessionId !== params.activeSessionId ? targetSessionId : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidChatHttpUrl(value?: string | null): value is string {
|
export function isValidChatHttpUrl(value?: string | null): value is string {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
try {
|
try {
|
||||||
@ -80,6 +136,9 @@ export function normalizeChatMediaType(value?: string | null): string {
|
|||||||
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
|
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
if (isOpenClawChatFileAttachment("", normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
return "image/jpeg";
|
return "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,6 +172,76 @@ export function getSessionRootStorageKey(canvasId: string, scope: "canvas" | "ag
|
|||||||
return `canvas-chat-root:${scope}:${canvasId}`;
|
return `canvas-chat-root:${scope}:${canvasId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHistoryTimestamp(value: string | Date) {
|
||||||
|
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeClawdHistoryIntoCanvasSessions<
|
||||||
|
T extends CanvasChatSessionRecord,
|
||||||
|
>(sessions: T[], history: ClawdChatHistoryRecord[]): T[] {
|
||||||
|
if (history.length === 0) return sessions;
|
||||||
|
|
||||||
|
const nextSessions = [...sessions];
|
||||||
|
const indexById = new Map(
|
||||||
|
nextSessions.map((session, index) => [session.id, index])
|
||||||
|
);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const item of history) {
|
||||||
|
const rawId = toRawCanvasChatId(String(item.id || "").trim());
|
||||||
|
if (!isValidChatUuid(rawId)) continue;
|
||||||
|
const id = `canvas-${rawId}`;
|
||||||
|
const existingIndex = indexById.get(id);
|
||||||
|
const historyCreatedAt = parseHistoryTimestamp(item.createdAt);
|
||||||
|
const historyUpdatedAt =
|
||||||
|
parseHistoryTimestamp(item.updatedAt) || historyCreatedAt;
|
||||||
|
const historyTitle = String(item.title || "").trim();
|
||||||
|
|
||||||
|
if (existingIndex === undefined) {
|
||||||
|
const created = {
|
||||||
|
id,
|
||||||
|
title: historyTitle || "Claw 对话",
|
||||||
|
mode: "clawd",
|
||||||
|
createdAt: historyCreatedAt || historyUpdatedAt,
|
||||||
|
updatedAt: historyUpdatedAt || historyCreatedAt,
|
||||||
|
} as T;
|
||||||
|
indexById.set(id, nextSessions.length);
|
||||||
|
nextSessions.push(created);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = nextSessions[existingIndex];
|
||||||
|
const nextTitle = historyTitle || existing.title || "Claw 对话";
|
||||||
|
const nextCreatedAt =
|
||||||
|
Number(existing.createdAt) || historyCreatedAt || historyUpdatedAt;
|
||||||
|
const nextUpdatedAt = Math.max(
|
||||||
|
Number(existing.updatedAt) || 0,
|
||||||
|
historyUpdatedAt
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
existing.title === nextTitle &&
|
||||||
|
existing.mode === "clawd" &&
|
||||||
|
existing.createdAt === nextCreatedAt &&
|
||||||
|
existing.updatedAt === nextUpdatedAt
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextSessions[existingIndex] = {
|
||||||
|
...existing,
|
||||||
|
title: nextTitle,
|
||||||
|
mode: "clawd",
|
||||||
|
createdAt: nextCreatedAt,
|
||||||
|
updatedAt: nextUpdatedAt,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return sessions;
|
||||||
|
return nextSessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractUserTextPrompt(message: ChatMessage): string {
|
export function extractUserTextPrompt(message: ChatMessage): string {
|
||||||
const parts = (message as any)?.parts || [];
|
const parts = (message as any)?.parts || [];
|
||||||
const textFromParts = parts
|
const textFromParts = parts
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
|
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
|
||||||
|
import {
|
||||||
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
|
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||||
|
} from "@/lib/image-upload-limits";
|
||||||
|
|
||||||
export const CANVAS_IMAGE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
export const CANVAS_IMAGE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES;
|
||||||
|
|
||||||
export type CanvasUploadFileLike = {
|
export type CanvasUploadFileLike = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -18,7 +22,7 @@ export function validateCanvasImageUploadFile(file?: CanvasUploadFileLike | null
|
|||||||
return { ok: false, message: "请上传图片文件" };
|
return { ok: false, message: "请上传图片文件" };
|
||||||
}
|
}
|
||||||
if (file.size > CANVAS_IMAGE_UPLOAD_MAX_BYTES) {
|
if (file.size > CANVAS_IMAGE_UPLOAD_MAX_BYTES) {
|
||||||
return { ok: false, message: "图片大小不能超过 50MB" };
|
return { ok: false, message: IMAGE_UPLOAD_TOO_LARGE_MESSAGE };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
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